ref: improve friendship endpoints

This commit is contained in:
Steve Korshakov 2025-09-17 22:25:06 -07:00
parent aaff5dcaf4
commit b8bc0734a0
3 changed files with 232 additions and 178 deletions

View File

@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "RelationshipStatus" AS ENUM ('pending', 'accepted', 'rejected', 'removed');
-- CreateTable
CREATE TABLE "UserRelationship" (
"fromUserId" TEXT NOT NULL,
"toUserId" TEXT NOT NULL,
"status" "RelationshipStatus" NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
CONSTRAINT "UserRelationship_pkey" PRIMARY KEY ("fromUserId","toUserId")
);
-- CreateIndex
CREATE INDEX "UserRelationship_toUserId_status_idx" ON "UserRelationship"("toUserId", "status");
-- CreateIndex
CREATE INDEX "UserRelationship_fromUserId_status_idx" ON "UserRelationship"("fromUserId", "status");
-- AddForeignKey
ALTER TABLE "UserRelationship" ADD CONSTRAINT "UserRelationship_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRelationship" ADD CONSTRAINT "UserRelationship_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -9,6 +9,8 @@ import { db } from "@/storage/db";
import { RelationshipStatus } from "@prisma/client"; import { RelationshipStatus } from "@prisma/client";
// Shared Zod Schemas // Shared Zod Schemas
const RelationshipStatusSchema = z.enum(['pending', 'accepted', 'rejected', 'removed']);
const UserProfileSchema = z.object({ const UserProfileSchema = z.object({
id: z.string(), id: z.string(),
firstName: z.string(), firstName: z.string(),
@ -20,11 +22,10 @@ const UserProfileSchema = z.object({
height: z.number().optional(), height: z.number().optional(),
thumbhash: z.string().optional() thumbhash: z.string().optional()
}).nullable(), }).nullable(),
username: z.string() username: z.string(),
status: RelationshipStatusSchema
}); });
const RelationshipStatusSchema = z.enum(['pending', 'accepted', 'rejected', 'removed']);
const RelationshipSchema = z.object({ const RelationshipSchema = z.object({
fromUserId: z.string(), fromUserId: z.string(),
toUserId: z.string(), toUserId: z.string(),
@ -37,7 +38,7 @@ const RelationshipSchema = z.object({
export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
// Get multiple user profiles // Get multiple user profiles
app.get('/v1/friends/profiles', { app.get('/v1/profiles', {
schema: { schema: {
querystring: z.object({ querystring: z.object({
userIds: z.string() // Comma-separated list userIds: z.string() // Comma-separated list
@ -54,7 +55,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
const userIdArray = userIds.split(',').filter(id => id.trim()); const userIdArray = userIds.split(',').filter(id => id.trim());
try { try {
const profiles = await FriendshipService.getUserProfiles(userIdArray); const profiles = await FriendshipService.getUserProfiles(userIdArray, request.userId);
return reply.send({ profiles }); return reply.send({ profiles });
} catch (error) { } catch (error) {
log({ module: 'api', level: 'error' }, `Failed to get profiles: ${error}`); log({ module: 'api', level: 'error' }, `Failed to get profiles: ${error}`);
@ -63,7 +64,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
}); });
// Search user by username // Search user by username
app.get('/v1/friends/search', { app.get('/v1/profiles/search', {
schema: { schema: {
querystring: z.object({ querystring: z.object({
username: z.string() username: z.string()
@ -79,7 +80,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
const { username } = request.query; const { username } = request.query;
try { try {
const profile = await FriendshipService.searchUserByUsername(username); const profile = await FriendshipService.searchUserByUsername(username, request.userId);
return reply.send({ profile }); return reply.send({ profile });
} catch (error) { } catch (error) {
log({ module: 'api', level: 'error' }, `Failed to search user: ${error}`); log({ module: 'api', level: 'error' }, `Failed to search user: ${error}`);
@ -94,9 +95,8 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
recipientId: z.string() recipientId: z.string()
}), }),
response: { response: {
200: RelationshipSchema, 200: z.object({ profile: UserProfileSchema.nullable() }),
400: z.object({ error: z.string() }), 400: z.object({ error: z.string() })
403: z.object({ error: z.string() })
} }
}, },
preHandler: app.authenticate preHandler: app.authenticate
@ -116,23 +116,24 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
]); ]);
if (!hasGitHub || !recipientHasGitHub) { if (!hasGitHub || !recipientHasGitHub) {
return reply.code(403).send({ error: 'Both users must have GitHub connected' }); return reply.send({ profile: null });
} }
const relationship = await FriendshipService.sendFriendRequest(userId, recipientId); const profile = await FriendshipService.sendFriendRequest(userId, recipientId);
// Get profiles for the socket event // Get profiles (relative to recipient) for the socket event
const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([userId, recipientId]); const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([userId, recipientId], recipientId);
// Emit socket event to recipient // Emit socket event to recipient
const updateSeq = await allocateUserSeq(recipientId); const updateSeq = await allocateUserSeq(recipientId);
const updatePayload = buildRelationshipUpdatedEvent( const updatePayload = buildRelationshipUpdatedEvent(
{ {
fromUserId: relationship.fromUserId, fromUserId: userId,
toUserId: relationship.toUserId, toUserId: recipientId,
status: relationship.status, status: 'pending',
action: 'created', action: 'created',
fromUser: fromUserProfile, fromUser: fromUserProfile,
toUser: toUserProfile,
timestamp: Date.now() timestamp: Date.now()
}, },
updateSeq, updateSeq,
@ -145,20 +146,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
recipientFilter: { type: 'user-scoped-only' } recipientFilter: { type: 'user-scoped-only' }
}); });
return reply.send({ return reply.send({ profile });
fromUserId: relationship.fromUserId, } catch (error) {
toUserId: relationship.toUserId,
status: relationship.status as any,
createdAt: relationship.createdAt.toISOString(),
updatedAt: relationship.updatedAt.toISOString(),
acceptedAt: relationship.acceptedAt?.toISOString() || null
});
} catch (error: any) {
log({ module: 'api', level: 'error' }, `Failed to send friend request: ${error}`); log({ module: 'api', level: 'error' }, `Failed to send friend request: ${error}`);
if (error.message === 'Friend request already exists') { return reply.send({ profile: null });
return reply.code(400).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to send friend request' });
} }
}); });
@ -171,12 +162,8 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
accept: z.boolean() accept: z.boolean()
}), }),
response: { response: {
200: z.object({ 200: z.object({ profile: UserProfileSchema.nullable() }),
relationship: RelationshipSchema, 400: z.object({ error: z.string() })
reverseRelationship: RelationshipSchema.optional()
}),
400: z.object({ error: z.string() }),
404: z.object({ error: z.string() })
} }
}, },
preHandler: app.authenticate preHandler: app.authenticate
@ -191,19 +178,17 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
try { try {
if (accept) { if (accept) {
const result = await FriendshipService.acceptFriendRequest(fromUserId, toUserId); const profile = await FriendshipService.acceptFriendRequest(fromUserId, toUserId);
// Get profiles for the socket event
const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([fromUserId, toUserId]);
// Emit socket event to both users // Emit socket event to both users with status accepted
for (const targetUserId of [fromUserId, toUserId]) { for (const targetUserId of [fromUserId, toUserId]) {
const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([fromUserId, toUserId], targetUserId);
const updateSeq = await allocateUserSeq(targetUserId); const updateSeq = await allocateUserSeq(targetUserId);
const updatePayload = buildRelationshipUpdatedEvent( const updatePayload = buildRelationshipUpdatedEvent(
{ {
fromUserId: result.relationship.fromUserId, fromUserId,
toUserId: result.relationship.toUserId, toUserId,
status: result.relationship.status, status: 'accepted',
action: 'updated', action: 'updated',
fromUser: fromUserProfile, fromUser: fromUserProfile,
toUser: toUserProfile, toUser: toUserProfile,
@ -220,46 +205,15 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
}); });
} }
return reply.send({ return reply.send({ profile });
relationship: {
fromUserId: result.relationship.fromUserId,
toUserId: result.relationship.toUserId,
status: result.relationship.status as any,
createdAt: result.relationship.createdAt.toISOString(),
updatedAt: result.relationship.updatedAt.toISOString(),
acceptedAt: result.relationship.acceptedAt?.toISOString() || null
},
reverseRelationship: {
fromUserId: result.reverseRelationship.fromUserId,
toUserId: result.reverseRelationship.toUserId,
status: result.reverseRelationship.status as any,
createdAt: result.reverseRelationship.createdAt.toISOString(),
updatedAt: result.reverseRelationship.updatedAt.toISOString(),
acceptedAt: result.reverseRelationship.acceptedAt?.toISOString() || null
}
});
} else { } else {
const relationship = await FriendshipService.rejectFriendRequest(fromUserId, toUserId); const profile = await FriendshipService.rejectFriendRequest(fromUserId, toUserId);
// No socket event for rejections (hidden from requestor) // No socket event for rejections (hidden from requestor)
return reply.send({ profile });
return reply.send({
relationship: {
fromUserId: relationship.fromUserId,
toUserId: relationship.toUserId,
status: relationship.status as any,
createdAt: relationship.createdAt.toISOString(),
updatedAt: relationship.updatedAt.toISOString(),
acceptedAt: relationship.acceptedAt?.toISOString() || null
}
});
} }
} catch (error: any) { } catch (error) {
log({ module: 'api', level: 'error' }, `Failed to respond to friend request: ${error}`); log({ module: 'api', level: 'error' }, `Failed to respond to friend request: ${error}`);
if (error.message === 'No pending friend request found') { return reply.send({ profile: null });
return reply.code(404).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to respond to friend request' });
} }
}); });
@ -329,9 +283,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
friendId: z.string() friendId: z.string()
}), }),
response: { response: {
200: z.object({ 200: z.object({ profile: UserProfileSchema.nullable() })
removed: z.boolean()
})
} }
}, },
preHandler: app.authenticate preHandler: app.authenticate
@ -340,10 +292,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
const { friendId } = request.params; const { friendId } = request.params;
try { try {
const removed = await FriendshipService.removeFriend(userId, friendId); const profile = await FriendshipService.removeFriend(userId, friendId);
// Get profiles for the socket event // Get profiles for the socket event
const [userProfile] = await FriendshipService.getUserProfiles([userId]); const [userProfile] = await FriendshipService.getUserProfiles([userId], friendId);
// Emit socket event to the friend // Emit socket event to the friend
const updateSeq = await allocateUserSeq(friendId); const updateSeq = await allocateUserSeq(friendId);
@ -366,10 +318,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) {
recipientFilter: { type: 'user-scoped-only' } recipientFilter: { type: 'user-scoped-only' }
}); });
return reply.send({ removed }); return reply.send({ profile });
} catch (error) { } catch (error) {
log({ module: 'api', level: 'error' }, `Failed to remove friend: ${error}`); log({ module: 'api', level: 'error' }, `Failed to remove friend: ${error}`);
return reply.code(500).send({ removed: false }); return reply.send({ profile: null });
} }
}); });
} }

View File

@ -1,4 +1,5 @@
import { db } from "@/storage/db"; import { db } from "@/storage/db";
import type { Prisma, PrismaClient } from "@prisma/client";
import { Account, RelationshipStatus, UserRelationship } from "@prisma/client"; import { Account, RelationshipStatus, UserRelationship } from "@prisma/client";
import { getPublicUrl } from "@/storage/files"; import { getPublicUrl } from "@/storage/files";
import { GitHubProfile } from "@/app/api/types"; import { GitHubProfile } from "@/app/api/types";
@ -15,36 +16,69 @@ export interface UserProfile {
thumbhash?: string; thumbhash?: string;
} | null; } | null;
username: string; username: string;
status: RelationshipStatus;
} }
export class FriendshipService { export class FriendshipService {
private static isImageRefLike(value: unknown): value is { path: string; width?: number; height?: number; thumbhash?: string } {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v.path === 'string';
}
private static client(client?: Prisma.TransactionClient | PrismaClient) {
return client ?? db;
}
private static async getStatusBetween(a: string, b: string, client?: Prisma.TransactionClient | PrismaClient): Promise<RelationshipStatus> {
const c = this.client(client);
const rels = await c.userRelationship.findMany({
where: {
OR: [
{ fromUserId: a, toUserId: b },
{ fromUserId: b, toUserId: a }
]
}
});
if (rels.some(r => r.status === RelationshipStatus.accepted)) return RelationshipStatus.accepted;
if (rels.some(r => r.status === RelationshipStatus.pending)) return RelationshipStatus.pending;
if (rels.some(r => r.status === RelationshipStatus.rejected)) return RelationshipStatus.rejected;
return RelationshipStatus.removed;
}
/** /**
* Build user profile from account data * Build user profile from account data
*/ */
static buildUserProfile(account: Account & { static buildUserProfile(
githubUser?: { account: Account & { githubUser?: { profile: GitHubProfile } | null },
profile: any; status: RelationshipStatus = RelationshipStatus.removed
} | null; ): UserProfile {
avatar?: any; const githubProfile = account.githubUser?.profile;
}): UserProfile { const avatarJson = account.avatar as Prisma.JsonValue | null;
const githubProfile = account.githubUser?.profile as GitHubProfile | undefined; let avatar: UserProfile['avatar'] = null;
if (this.isImageRefLike(avatarJson)) {
avatar = {
path: avatarJson.path,
url: getPublicUrl(avatarJson.path),
width: avatarJson.width,
height: avatarJson.height,
thumbhash: avatarJson.thumbhash
};
}
return { return {
id: account.id, id: account.id,
firstName: account.firstName || '', firstName: account.firstName || '',
lastName: account.lastName, lastName: account.lastName,
avatar: account.avatar ? { avatar,
...account.avatar, username: githubProfile?.login || '',
url: getPublicUrl(account.avatar.path) status
} : null,
username: githubProfile?.login || ''
}; };
} }
/** /**
* Get multiple user profiles by IDs * Get multiple user profiles by IDs
*/ */
static async getUserProfiles(userIds: string[]): Promise<UserProfile[]> { static async getUserProfiles(userIds: string[], relativeToUserId?: string): Promise<UserProfile[]> {
if (userIds.length === 0) { if (userIds.length === 0) {
return []; return [];
} }
@ -59,13 +93,37 @@ export class FriendshipService {
} }
}); });
return accounts.map(account => this.buildUserProfile(account)); let statusMap: Record<string, RelationshipStatus> = {};
if (relativeToUserId) {
const rels = await db.userRelationship.findMany({
where: {
OR: [
{ fromUserId: relativeToUserId, toUserId: { in: userIds } },
{ fromUserId: { in: userIds }, toUserId: relativeToUserId }
]
}
});
const tmp: Record<string, RelationshipStatus[]> = {};
for (const r of rels) {
const otherId = r.fromUserId === relativeToUserId ? r.toUserId : r.fromUserId;
(tmp[otherId] ||= []).push(r.status);
}
for (const [uid, statuses] of Object.entries(tmp)) {
let s: RelationshipStatus = RelationshipStatus.removed;
if (statuses.includes(RelationshipStatus.accepted)) s = RelationshipStatus.accepted;
else if (statuses.includes(RelationshipStatus.pending)) s = RelationshipStatus.pending;
else if (statuses.includes(RelationshipStatus.rejected)) s = RelationshipStatus.rejected;
statusMap[uid] = s;
}
}
return accounts.map(account => this.buildUserProfile(account, statusMap[account.id] ?? RelationshipStatus.removed));
} }
/** /**
* Search for a user by exact username match * Search for a user by exact username match
*/ */
static async searchUserByUsername(username: string): Promise<UserProfile | null> { static async searchUserByUsername(username: string, relativeToUserId?: string): Promise<UserProfile | null> {
const githubUser = await db.githubUser.findFirst({ const githubUser = await db.githubUser.findFirst({
where: { where: {
profile: { profile: {
@ -92,13 +150,14 @@ export class FriendshipService {
return null; return null;
} }
return this.buildUserProfile(account); const status = relativeToUserId ? await this.getStatusBetween(relativeToUserId, account.id) : RelationshipStatus.removed;
return this.buildUserProfile(account, status);
} }
/** /**
* Send a friend request from one user to another * Send a friend request from one user to another
*/ */
static async sendFriendRequest(fromUserId: string, toUserId: string): Promise<UserRelationship> { static async sendFriendRequest(fromUserId: string, toUserId: string): Promise<UserProfile | null> {
// Verify both users exist and have GitHub connected // Verify both users exist and have GitHub connected
const [fromUser, toUser] = await Promise.all([ const [fromUser, toUser] = await Promise.all([
db.account.findFirst({ db.account.findFirst({
@ -110,55 +169,59 @@ export class FriendshipService {
]); ]);
if (!fromUser || !toUser) { if (!fromUser || !toUser) {
throw new Error('Both users must exist and have GitHub connected'); return null;
} }
// Check if relationship already exists // Interactive transaction to avoid races between check and write
const existing = await db.userRelationship.findUnique({ const created = await db.$transaction(async (tx) => {
where: { const existing = await tx.userRelationship.findUnique({
fromUserId_toUserId: { where: {
fromUserId, fromUserId_toUserId: {
toUserId fromUserId,
} toUserId
}
});
if (existing) {
if (existing.status === RelationshipStatus.rejected) {
// Allow re-sending if previously rejected
return await db.userRelationship.update({
where: {
fromUserId_toUserId: {
fromUserId,
toUserId
}
},
data: {
status: RelationshipStatus.pending,
updatedAt: new Date()
} }
}); }
} });
throw new Error('Friend request already exists');
}
// Create new friend request if (existing) {
return await db.userRelationship.create({ if (existing.status === RelationshipStatus.rejected) {
data: { // Allow re-sending if previously rejected
fromUserId, return await tx.userRelationship.update({
toUserId, where: {
status: RelationshipStatus.pending fromUserId_toUserId: {
fromUserId,
toUserId
}
},
data: {
status: RelationshipStatus.pending,
updatedAt: new Date()
}
});
}
return null;
} }
// Create new friend request
return await tx.userRelationship.create({
data: {
fromUserId,
toUserId,
status: RelationshipStatus.pending
}
});
}); });
if (!created) return null;
const account = await db.account.findUnique({ where: { id: toUserId }, include: { githubUser: true } });
if (!account) return null;
const status = await this.getStatusBetween(fromUserId, toUserId);
return this.buildUserProfile(account, status);
} }
/** /**
* Accept a friend request * Accept a friend request
*/ */
static async acceptFriendRequest(fromUserId: string, toUserId: string): Promise<{ static async acceptFriendRequest(fromUserId: string, toUserId: string): Promise<UserProfile | null> {
relationship: UserRelationship;
reverseRelationship: UserRelationship;
}> {
// Verify the request exists and is pending // Verify the request exists and is pending
const request = await db.userRelationship.findUnique({ const request = await db.userRelationship.findUnique({
where: { where: {
@ -170,11 +233,11 @@ export class FriendshipService {
}); });
if (!request || request.status !== RelationshipStatus.pending) { if (!request || request.status !== RelationshipStatus.pending) {
throw new Error('No pending friend request found'); return null;
} }
// Use transaction to ensure both operations succeed // Use transaction to ensure both operations succeed
const result = await db.$transaction(async (tx) => { const ok = await db.$transaction(async (tx) => {
// Update original request to accepted // Update original request to accepted
const relationship = await tx.userRelationship.update({ const relationship = await tx.userRelationship.update({
where: { where: {
@ -199,58 +262,71 @@ export class FriendshipService {
} }
}); });
return { relationship, reverseRelationship }; return !!relationship && !!reverseRelationship;
}); });
if (!ok) return null;
return result; const account = await db.account.findUnique({ where: { id: fromUserId }, include: { githubUser: true } });
if (!account) return null;
const status = await this.getStatusBetween(toUserId, fromUserId);
return this.buildUserProfile(account, status);
} }
/** /**
* Reject a friend request * Reject a friend request
*/ */
static async rejectFriendRequest(fromUserId: string, toUserId: string): Promise<UserRelationship> { static async rejectFriendRequest(fromUserId: string, toUserId: string): Promise<UserProfile | null> {
const request = await db.userRelationship.findUnique({ return await db.$transaction(async (tx) => {
where: { const request = await tx.userRelationship.findUnique({
fromUserId_toUserId: { where: {
fromUserId, fromUserId_toUserId: {
toUserId fromUserId,
toUserId
}
} }
});
if (!request || request.status !== RelationshipStatus.pending) {
return null;
} }
});
if (!request || request.status !== RelationshipStatus.pending) { const _ = await tx.userRelationship.update({
throw new Error('No pending friend request found'); where: {
} fromUserId_toUserId: {
fromUserId,
return await db.userRelationship.update({ toUserId
where: { }
fromUserId_toUserId: { },
fromUserId, data: {
toUserId status: RelationshipStatus.rejected
} }
}, });
data: { const account = await tx.account.findUnique({ where: { id: fromUserId }, include: { githubUser: true } });
status: RelationshipStatus.rejected if (!account) return null;
} const status = await this.getStatusBetween(toUserId, fromUserId, tx);
return this.buildUserProfile(account, status);
}); });
} }
/** /**
* Remove a friendship (both directions) * Remove a friendship (both directions)
*/ */
static async removeFriend(userId: string, friendId: string): Promise<boolean> { static async removeFriend(userId: string, friendId: string): Promise<UserProfile | null> {
await db.$transaction([ const ok = await db.$transaction(async (tx) => {
db.userRelationship.deleteMany({ await tx.userRelationship.deleteMany({
where: { where: {
OR: [ OR: [
{ fromUserId: userId, toUserId: friendId }, { fromUserId: userId, toUserId: friendId },
{ fromUserId: friendId, toUserId: userId } { fromUserId: friendId, toUserId: userId }
] ]
} }
}) });
]); return true;
});
return true; if (!ok) return null;
const account = await db.account.findUnique({ where: { id: friendId }, include: { githubUser: true } });
if (!account) return null;
return this.buildUserProfile(account, RelationshipStatus.removed);
} }
/** /**
@ -278,7 +354,7 @@ export class FriendshipService {
return requests.map(request => ({ return requests.map(request => ({
...request, ...request,
fromUser: this.buildUserProfile(request.fromUser) fromUser: this.buildUserProfile(request.fromUser, RelationshipStatus.pending)
})); }));
} }
@ -334,7 +410,7 @@ export class FriendshipService {
} }
}); });
return friends.map(friend => this.buildUserProfile(friend)); return friends.map(friend => this.buildUserProfile(friend, RelationshipStatus.accepted));
} }
/** /**
@ -362,4 +438,4 @@ export class FriendshipService {
return !!account?.githubUserId; return !!account?.githubUserId;
} }
} }