From b8bc0734a0f53b9c8af0d249e35b0725db91a0a2 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Wed, 17 Sep 2025 22:25:06 -0700 Subject: [PATCH] ref: improve friendship endpoints --- .../migration.sql | 26 ++ sources/app/api/routes/friendshipRoutes.ts | 128 +++------ sources/services/friendshipService.ts | 256 ++++++++++++------ 3 files changed, 232 insertions(+), 178 deletions(-) create mode 100644 prisma/migrations/20250918045344_add_friendships/migration.sql diff --git a/prisma/migrations/20250918045344_add_friendships/migration.sql b/prisma/migrations/20250918045344_add_friendships/migration.sql new file mode 100644 index 0000000..cedee53 --- /dev/null +++ b/prisma/migrations/20250918045344_add_friendships/migration.sql @@ -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; diff --git a/sources/app/api/routes/friendshipRoutes.ts b/sources/app/api/routes/friendshipRoutes.ts index 304f01a..2a51f31 100644 --- a/sources/app/api/routes/friendshipRoutes.ts +++ b/sources/app/api/routes/friendshipRoutes.ts @@ -9,6 +9,8 @@ import { db } from "@/storage/db"; import { RelationshipStatus } from "@prisma/client"; // Shared Zod Schemas + +const RelationshipStatusSchema = z.enum(['pending', 'accepted', 'rejected', 'removed']); const UserProfileSchema = z.object({ id: z.string(), firstName: z.string(), @@ -20,11 +22,10 @@ const UserProfileSchema = z.object({ height: z.number().optional(), thumbhash: z.string().optional() }).nullable(), - username: z.string() + username: z.string(), + status: RelationshipStatusSchema }); -const RelationshipStatusSchema = z.enum(['pending', 'accepted', 'rejected', 'removed']); - const RelationshipSchema = z.object({ fromUserId: z.string(), toUserId: z.string(), @@ -37,7 +38,7 @@ const RelationshipSchema = z.object({ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { // Get multiple user profiles - app.get('/v1/friends/profiles', { + app.get('/v1/profiles', { schema: { querystring: z.object({ 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()); try { - const profiles = await FriendshipService.getUserProfiles(userIdArray); + const profiles = await FriendshipService.getUserProfiles(userIdArray, request.userId); return reply.send({ profiles }); } catch (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 - app.get('/v1/friends/search', { + app.get('/v1/profiles/search', { schema: { querystring: z.object({ username: z.string() @@ -79,7 +80,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { const { username } = request.query; try { - const profile = await FriendshipService.searchUserByUsername(username); + const profile = await FriendshipService.searchUserByUsername(username, request.userId); return reply.send({ profile }); } catch (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() }), response: { - 200: RelationshipSchema, - 400: z.object({ error: z.string() }), - 403: z.object({ error: z.string() }) + 200: z.object({ profile: UserProfileSchema.nullable() }), + 400: z.object({ error: z.string() }) } }, preHandler: app.authenticate @@ -116,23 +116,24 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { ]); 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 - const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([userId, recipientId]); + // Get profiles (relative to recipient) for the socket event + const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([userId, recipientId], recipientId); // Emit socket event to recipient const updateSeq = await allocateUserSeq(recipientId); const updatePayload = buildRelationshipUpdatedEvent( { - fromUserId: relationship.fromUserId, - toUserId: relationship.toUserId, - status: relationship.status, + fromUserId: userId, + toUserId: recipientId, + status: 'pending', action: 'created', fromUser: fromUserProfile, + toUser: toUserProfile, timestamp: Date.now() }, updateSeq, @@ -145,20 +146,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { recipientFilter: { type: 'user-scoped-only' } }); - return reply.send({ - 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) { + return reply.send({ profile }); + } catch (error) { log({ module: 'api', level: 'error' }, `Failed to send friend request: ${error}`); - if (error.message === 'Friend request already exists') { - return reply.code(400).send({ error: error.message }); - } - return reply.code(500).send({ error: 'Failed to send friend request' }); + return reply.send({ profile: null }); } }); @@ -171,12 +162,8 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { accept: z.boolean() }), response: { - 200: z.object({ - relationship: RelationshipSchema, - reverseRelationship: RelationshipSchema.optional() - }), - 400: z.object({ error: z.string() }), - 404: z.object({ error: z.string() }) + 200: z.object({ profile: UserProfileSchema.nullable() }), + 400: z.object({ error: z.string() }) } }, preHandler: app.authenticate @@ -191,19 +178,17 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { try { if (accept) { - const result = await FriendshipService.acceptFriendRequest(fromUserId, toUserId); - - // Get profiles for the socket event - const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([fromUserId, toUserId]); + const profile = await FriendshipService.acceptFriendRequest(fromUserId, toUserId); - // Emit socket event to both users + // Emit socket event to both users with status accepted for (const targetUserId of [fromUserId, toUserId]) { + const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([fromUserId, toUserId], targetUserId); const updateSeq = await allocateUserSeq(targetUserId); const updatePayload = buildRelationshipUpdatedEvent( { - fromUserId: result.relationship.fromUserId, - toUserId: result.relationship.toUserId, - status: result.relationship.status, + fromUserId, + toUserId, + status: 'accepted', action: 'updated', fromUser: fromUserProfile, toUser: toUserProfile, @@ -220,46 +205,15 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { }); } - return reply.send({ - 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 - } - }); + return reply.send({ profile }); } else { - const relationship = await FriendshipService.rejectFriendRequest(fromUserId, toUserId); - + const profile = await FriendshipService.rejectFriendRequest(fromUserId, toUserId); // No socket event for rejections (hidden from requestor) - - 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 - } - }); + return reply.send({ profile }); } - } catch (error: any) { + } catch (error) { log({ module: 'api', level: 'error' }, `Failed to respond to friend request: ${error}`); - if (error.message === 'No pending friend request found') { - return reply.code(404).send({ error: error.message }); - } - return reply.code(500).send({ error: 'Failed to respond to friend request' }); + return reply.send({ profile: null }); } }); @@ -329,9 +283,7 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { friendId: z.string() }), response: { - 200: z.object({ - removed: z.boolean() - }) + 200: z.object({ profile: UserProfileSchema.nullable() }) } }, preHandler: app.authenticate @@ -340,10 +292,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { const { friendId } = request.params; try { - const removed = await FriendshipService.removeFriend(userId, friendId); + const profile = await FriendshipService.removeFriend(userId, friendId); // 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 const updateSeq = await allocateUserSeq(friendId); @@ -366,10 +318,10 @@ export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { recipientFilter: { type: 'user-scoped-only' } }); - return reply.send({ removed }); + return reply.send({ profile }); } catch (error) { log({ module: 'api', level: 'error' }, `Failed to remove friend: ${error}`); - return reply.code(500).send({ removed: false }); + return reply.send({ profile: null }); } }); -} \ No newline at end of file +} diff --git a/sources/services/friendshipService.ts b/sources/services/friendshipService.ts index f0cbf4e..58c5aac 100644 --- a/sources/services/friendshipService.ts +++ b/sources/services/friendshipService.ts @@ -1,4 +1,5 @@ import { db } from "@/storage/db"; +import type { Prisma, PrismaClient } from "@prisma/client"; import { Account, RelationshipStatus, UserRelationship } from "@prisma/client"; import { getPublicUrl } from "@/storage/files"; import { GitHubProfile } from "@/app/api/types"; @@ -15,36 +16,69 @@ export interface UserProfile { thumbhash?: string; } | null; username: string; + status: RelationshipStatus; } 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; + 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 { + 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 */ - static buildUserProfile(account: Account & { - githubUser?: { - profile: any; - } | null; - avatar?: any; - }): UserProfile { - const githubProfile = account.githubUser?.profile as GitHubProfile | undefined; - + static buildUserProfile( + account: Account & { githubUser?: { profile: GitHubProfile } | null }, + status: RelationshipStatus = RelationshipStatus.removed + ): UserProfile { + const githubProfile = account.githubUser?.profile; + const avatarJson = account.avatar as Prisma.JsonValue | null; + 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 { id: account.id, firstName: account.firstName || '', lastName: account.lastName, - avatar: account.avatar ? { - ...account.avatar, - url: getPublicUrl(account.avatar.path) - } : null, - username: githubProfile?.login || '' + avatar, + username: githubProfile?.login || '', + status }; } /** * Get multiple user profiles by IDs */ - static async getUserProfiles(userIds: string[]): Promise { + static async getUserProfiles(userIds: string[], relativeToUserId?: string): Promise { if (userIds.length === 0) { return []; } @@ -59,13 +93,37 @@ export class FriendshipService { } }); - return accounts.map(account => this.buildUserProfile(account)); + let statusMap: Record = {}; + if (relativeToUserId) { + const rels = await db.userRelationship.findMany({ + where: { + OR: [ + { fromUserId: relativeToUserId, toUserId: { in: userIds } }, + { fromUserId: { in: userIds }, toUserId: relativeToUserId } + ] + } + }); + const tmp: Record = {}; + 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 */ - static async searchUserByUsername(username: string): Promise { + static async searchUserByUsername(username: string, relativeToUserId?: string): Promise { const githubUser = await db.githubUser.findFirst({ where: { profile: { @@ -92,13 +150,14 @@ export class FriendshipService { 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 */ - static async sendFriendRequest(fromUserId: string, toUserId: string): Promise { + static async sendFriendRequest(fromUserId: string, toUserId: string): Promise { // Verify both users exist and have GitHub connected const [fromUser, toUser] = await Promise.all([ db.account.findFirst({ @@ -110,55 +169,59 @@ export class FriendshipService { ]); if (!fromUser || !toUser) { - throw new Error('Both users must exist and have GitHub connected'); + return null; } - // Check if relationship already exists - const existing = await db.userRelationship.findUnique({ - where: { - fromUserId_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() + // Interactive transaction to avoid races between check and write + const created = await db.$transaction(async (tx) => { + const existing = await tx.userRelationship.findUnique({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId } - }); - } - throw new Error('Friend request already exists'); - } + } + }); - // Create new friend request - return await db.userRelationship.create({ - data: { - fromUserId, - toUserId, - status: RelationshipStatus.pending + if (existing) { + if (existing.status === RelationshipStatus.rejected) { + // Allow re-sending if previously rejected + return await tx.userRelationship.update({ + where: { + 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 */ - static async acceptFriendRequest(fromUserId: string, toUserId: string): Promise<{ - relationship: UserRelationship; - reverseRelationship: UserRelationship; - }> { + static async acceptFriendRequest(fromUserId: string, toUserId: string): Promise { // Verify the request exists and is pending const request = await db.userRelationship.findUnique({ where: { @@ -170,11 +233,11 @@ export class FriendshipService { }); if (!request || request.status !== RelationshipStatus.pending) { - throw new Error('No pending friend request found'); + return null; } // 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 const relationship = await tx.userRelationship.update({ where: { @@ -199,58 +262,71 @@ export class FriendshipService { } }); - return { relationship, reverseRelationship }; + return !!relationship && !!reverseRelationship; }); - - return result; + if (!ok) return null; + 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 */ - static async rejectFriendRequest(fromUserId: string, toUserId: string): Promise { - const request = await db.userRelationship.findUnique({ - where: { - fromUserId_toUserId: { - fromUserId, - toUserId + static async rejectFriendRequest(fromUserId: string, toUserId: string): Promise { + return await db.$transaction(async (tx) => { + const request = await tx.userRelationship.findUnique({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId + } } + }); + + if (!request || request.status !== RelationshipStatus.pending) { + return null; } - }); - if (!request || request.status !== RelationshipStatus.pending) { - throw new Error('No pending friend request found'); - } - - return await db.userRelationship.update({ - where: { - fromUserId_toUserId: { - fromUserId, - toUserId + const _ = await tx.userRelationship.update({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId + } + }, + data: { + status: RelationshipStatus.rejected } - }, - data: { - status: RelationshipStatus.rejected - } + }); + const account = await tx.account.findUnique({ where: { id: fromUserId }, include: { githubUser: true } }); + if (!account) return null; + const status = await this.getStatusBetween(toUserId, fromUserId, tx); + return this.buildUserProfile(account, status); }); } /** * Remove a friendship (both directions) */ - static async removeFriend(userId: string, friendId: string): Promise { - await db.$transaction([ - db.userRelationship.deleteMany({ + static async removeFriend(userId: string, friendId: string): Promise { + const ok = await db.$transaction(async (tx) => { + await tx.userRelationship.deleteMany({ where: { OR: [ { fromUserId: userId, toUserId: friendId }, { 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 => ({ ...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; } -} \ No newline at end of file +}