diff --git a/prisma/migrations/20250919021354_fix_relationship_status/migration.sql b/prisma/migrations/20250919021354_fix_relationship_status/migration.sql new file mode 100644 index 0000000..3e57f76 --- /dev/null +++ b/prisma/migrations/20250919021354_fix_relationship_status/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - The values [accepted,removed] on the enum `RelationshipStatus` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "RelationshipStatus_new" AS ENUM ('none', 'requested', 'pending', 'friend', 'rejected'); +ALTER TABLE "UserRelationship" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "UserRelationship" ALTER COLUMN "status" TYPE "RelationshipStatus_new" USING ("status"::text::"RelationshipStatus_new"); +ALTER TYPE "RelationshipStatus" RENAME TO "RelationshipStatus_old"; +ALTER TYPE "RelationshipStatus_new" RENAME TO "RelationshipStatus"; +DROP TYPE "RelationshipStatus_old"; +ALTER TABLE "UserRelationship" ALTER COLUMN "status" SET DEFAULT 'pending'; +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a166ed8..0f5c855 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -297,10 +297,11 @@ model AccessKey { // enum RelationshipStatus { + none + requested pending - accepted + friend rejected - removed } model UserRelationship { diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index d0ac2f6..6db410b 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -16,10 +16,10 @@ import { versionRoutes } from "./routes/versionRoutes"; import { voiceRoutes } from "./routes/voiceRoutes"; import { artifactsRoutes } from "./routes/artifactsRoutes"; import { accessKeysRoutes } from "./routes/accessKeysRoutes"; -import { friendshipRoutes } from "./routes/friendshipRoutes"; import { enableMonitoring } from "./utils/enableMonitoring"; import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; +import { userRoutes } from "./routes/userRoutes"; export async function startApi(eventRouter: EventRouter) { @@ -62,7 +62,7 @@ export async function startApi(eventRouter: EventRouter) { devRoutes(typed); versionRoutes(typed); voiceRoutes(typed); - friendshipRoutes(typed, eventRouter); + userRoutes(typed); // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; diff --git a/sources/app/api/routes/friendshipRoutes.ts b/sources/app/api/routes/friendshipRoutes.ts deleted file mode 100644 index 2a51f31..0000000 --- a/sources/app/api/routes/friendshipRoutes.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { z } from "zod"; -import { Fastify } from "../types"; -import { EventRouter, buildRelationshipUpdatedEvent } from "@/app/events/eventRouter"; -import { FriendshipService } from "@/services/friendshipService"; -import { log } from "@/utils/log"; -import { allocateUserSeq } from "@/storage/seq"; -import { randomKeyNaked } from "@/utils/randomKeyNaked"; -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(), - lastName: z.string().nullable(), - avatar: z.object({ - path: z.string(), - url: z.string(), - width: z.number().optional(), - height: z.number().optional(), - thumbhash: z.string().optional() - }).nullable(), - username: z.string(), - status: RelationshipStatusSchema -}); - -const RelationshipSchema = z.object({ - fromUserId: z.string(), - toUserId: z.string(), - status: RelationshipStatusSchema, - createdAt: z.string(), - updatedAt: z.string(), - acceptedAt: z.string().nullable() -}); - -export function friendshipRoutes(app: Fastify, eventRouter: EventRouter) { - - // Get multiple user profiles - app.get('/v1/profiles', { - schema: { - querystring: z.object({ - userIds: z.string() // Comma-separated list - }), - response: { - 200: z.object({ - profiles: z.array(UserProfileSchema) - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const { userIds } = request.query; - const userIdArray = userIds.split(',').filter(id => id.trim()); - - try { - const profiles = await FriendshipService.getUserProfiles(userIdArray, request.userId); - return reply.send({ profiles }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to get profiles: ${error}`); - return reply.code(500).send({ profiles: [] }); - } - }); - - // Search user by username - app.get('/v1/profiles/search', { - schema: { - querystring: z.object({ - username: z.string() - }), - response: { - 200: z.object({ - profile: UserProfileSchema.nullable() - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const { username } = request.query; - - try { - const profile = await FriendshipService.searchUserByUsername(username, request.userId); - return reply.send({ profile }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to search user: ${error}`); - return reply.send({ profile: null }); - } - }); - - // Send friend request - app.post('/v1/friends/request', { - schema: { - body: z.object({ - recipientId: z.string() - }), - response: { - 200: z.object({ profile: UserProfileSchema.nullable() }), - 400: z.object({ error: z.string() }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { recipientId } = request.body; - - if (userId === recipientId) { - return reply.code(400).send({ error: 'Cannot send friend request to yourself' }); - } - - try { - // Check if both users have GitHub connected - const [hasGitHub, recipientHasGitHub] = await Promise.all([ - FriendshipService.hasGitHubConnected(userId), - FriendshipService.hasGitHubConnected(recipientId) - ]); - - if (!hasGitHub || !recipientHasGitHub) { - return reply.send({ profile: null }); - } - - const profile = await FriendshipService.sendFriendRequest(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: userId, - toUserId: recipientId, - status: 'pending', - action: 'created', - fromUser: fromUserProfile, - toUser: toUserProfile, - timestamp: Date.now() - }, - updateSeq, - randomKeyNaked(12) - ); - - eventRouter.emitUpdate({ - userId: recipientId, - payload: updatePayload, - recipientFilter: { type: 'user-scoped-only' } - }); - - return reply.send({ profile }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to send friend request: ${error}`); - return reply.send({ profile: null }); - } - }); - - // Respond to friend request - app.post('/v1/friends/respond', { - schema: { - body: z.object({ - fromUserId: z.string(), - toUserId: z.string(), - accept: z.boolean() - }), - response: { - 200: z.object({ profile: UserProfileSchema.nullable() }), - 400: z.object({ error: z.string() }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { fromUserId, toUserId, accept } = request.body; - - // Verify the user is the recipient of the request - if (toUserId !== userId) { - return reply.code(403).send({ error: 'You can only respond to requests sent to you' }); - } - - try { - if (accept) { - const profile = await FriendshipService.acceptFriendRequest(fromUserId, toUserId); - - // 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, - toUserId, - status: 'accepted', - action: 'updated', - fromUser: fromUserProfile, - toUser: toUserProfile, - timestamp: Date.now() - }, - updateSeq, - randomKeyNaked(12) - ); - - eventRouter.emitUpdate({ - userId: targetUserId, - payload: updatePayload, - recipientFilter: { type: 'user-scoped-only' } - }); - } - - return reply.send({ profile }); - } else { - const profile = await FriendshipService.rejectFriendRequest(fromUserId, toUserId); - // No socket event for rejections (hidden from requestor) - return reply.send({ profile }); - } - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to respond to friend request: ${error}`); - return reply.send({ profile: null }); - } - }); - - // Get pending friend requests - app.get('/v1/friends/requests', { - schema: { - response: { - 200: z.object({ - requests: z.array(z.object({ - fromUserId: z.string(), - toUserId: z.string(), - status: RelationshipStatusSchema, - fromUser: UserProfileSchema, - createdAt: z.string() - })) - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - - try { - const requests = await FriendshipService.getPendingRequests(userId); - - return reply.send({ - requests: requests.map(req => ({ - fromUserId: req.fromUserId, - toUserId: req.toUserId, - status: req.status as any, - fromUser: req.fromUser, - createdAt: req.createdAt.toISOString() - })) - }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to get pending requests: ${error}`); - return reply.send({ requests: [] }); - } - }); - - // Get friends list - app.get('/v1/friends/list', { - schema: { - response: { - 200: z.object({ - friends: z.array(UserProfileSchema) - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - - try { - const friends = await FriendshipService.getFriends(userId); - return reply.send({ friends }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to get friends: ${error}`); - return reply.send({ friends: [] }); - } - }); - - // Remove friend - app.delete('/v1/friends/:friendId', { - schema: { - params: z.object({ - friendId: z.string() - }), - response: { - 200: z.object({ profile: UserProfileSchema.nullable() }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { friendId } = request.params; - - try { - const profile = await FriendshipService.removeFriend(userId, friendId); - - // Get profiles for the socket event - const [userProfile] = await FriendshipService.getUserProfiles([userId], friendId); - - // Emit socket event to the friend - const updateSeq = await allocateUserSeq(friendId); - const updatePayload = buildRelationshipUpdatedEvent( - { - fromUserId: userId, - toUserId: friendId, - status: 'removed', - action: 'deleted', - fromUser: userProfile, - timestamp: Date.now() - }, - updateSeq, - randomKeyNaked(12) - ); - - eventRouter.emitUpdate({ - userId: friendId, - payload: updatePayload, - recipientFilter: { type: 'user-scoped-only' } - }); - - return reply.send({ profile }); - } catch (error) { - log({ module: 'api', level: 'error' }, `Failed to remove friend: ${error}`); - return reply.send({ profile: null }); - } - }); -} diff --git a/sources/app/api/routes/sessionRoutes.ts b/sources/app/api/routes/sessionRoutes.ts index f71888f..3049e85 100644 --- a/sources/app/api/routes/sessionRoutes.ts +++ b/sources/app/api/routes/sessionRoutes.ts @@ -265,7 +265,7 @@ export function sessionRoutes(app: Fastify, eventRouter: EventRouter) { accountId: userId, tag: tag, metadata: metadata, - dataEncryptionKey: dataEncryptionKey ? Buffer.from(dataEncryptionKey, 'base64') : undefined + dataEncryptionKey: dataEncryptionKey ? new Uint8Array(Buffer.from(dataEncryptionKey, 'base64')) : undefined } }); log({ module: 'session-create', sessionId: session.id, userId }, `Session created: ${session.id}`); diff --git a/sources/app/api/routes/userRoutes.ts b/sources/app/api/routes/userRoutes.ts new file mode 100644 index 0000000..6d1b7b8 --- /dev/null +++ b/sources/app/api/routes/userRoutes.ts @@ -0,0 +1,216 @@ +import { z } from "zod"; +import { Fastify } from "../types"; +import { db } from "@/storage/db"; +import { RelationshipStatus } from "@prisma/client"; +import { getPublicUrl } from "@/storage/files"; +import { friendAdd } from "@/app/social/friendAdd"; +import { Context } from "@/context"; +import { friendRemove } from "@/app/social/friendRemove"; +import { friendList } from "@/app/social/friendList"; + +export async function userRoutes(app: Fastify) { + + // Get user profile + app.get('/v1/user/:id', { + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + user: UserProfileSchema + }), + 404: z.object({ + error: z.literal('User not found') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const { id } = request.params; + + // Fetch user + const user = await db.account.findUnique({ + where: { + id: id + }, + include: { + githubUser: true + } + }); + + if (!user || !user.githubUser) { + return reply.code(404).send({ error: 'User not found' }); + } + + // Resolve relationship status + const relationship = await db.userRelationship.findFirst({ + where: { + fromUserId: request.userId, + toUserId: id + } + }); + const status: RelationshipStatus = relationship?.status || RelationshipStatus.none; + + // Build user profile + return reply.send({ + user: { + id: user.id, + firstName: user.firstName || '', + lastName: user.lastName, + avatar: user.avatar ? { + path: user.avatar.path, + url: getPublicUrl(user.avatar.path), + width: user.avatar.width, + height: user.avatar.height, + thumbhash: user.avatar.thumbhash + } : null, + username: user.githubUser.profile.login, + status: status + } + }); + }); + + // Search for user + app.get('/v1/user/search', { + schema: { + querystring: z.object({ + query: z.string() + }), + response: { + 200: z.object({ + user: UserProfileSchema + }), + 404: z.object({ + error: z.literal('User not found') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const { query } = request.query; + + // Search for user + const user = await db.account.findFirst({ + where: { + githubUser: { + profile: { + path: ['login'], + equals: query + } + } + }, + include: { + githubUser: true + } + }); + + if (!user || !user.githubUser) { + return reply.code(404).send({ error: 'User not found' }); + } + + // Resolve relationship status + const relationship = await db.userRelationship.findFirst({ + where: { + fromUserId: request.userId, + toUserId: user.id + } + }); + const status: RelationshipStatus = relationship?.status || RelationshipStatus.none; + + return reply.send({ + user: { + id: user.id, + firstName: user.firstName || '', + lastName: user.lastName, + avatar: user.avatar ? { + path: user.avatar.path, + url: getPublicUrl(user.avatar.path), + width: user.avatar.width, + height: user.avatar.height, + thumbhash: user.avatar.thumbhash + } : null, + username: user.githubUser.profile.login, + status: status + } + }); + }); + + // Add friend + app.post('/v1/friends/add', { + schema: { + body: z.object({ + uid: z.string() + }), + response: { + 200: z.object({ + user: UserProfileSchema + }), + 404: z.object({ + error: z.literal('User not found') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const user = await friendAdd(Context.create(request.userId), request.body.uid); + if (!user) { + return reply.code(404).send({ error: 'User not found' }); + } + return reply.send({ user }); + }); + + app.post('/v1/friends/remove', { + schema: { + body: z.object({ + uid: z.string() + }), + response: { + 200: z.object({ + user: UserProfileSchema + }), + 404: z.object({ + error: z.literal('User not found') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const user = await friendRemove(Context.create(request.userId), request.body.uid); + if (!user) { + return reply.code(404).send({ error: 'User not found' }); + } + return reply.send({ user }); + }); + + app.get('/v1/friends', { + schema: { + response: { + 200: z.object({ + friends: z.array(UserProfileSchema) + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const friends = await friendList(Context.create(request.userId)); + return reply.send({ friends }); + }); +}; + +// Shared Zod Schemas +const RelationshipStatusSchema = z.enum(['none', 'requested', 'pending', 'friend', 'rejected']); +const UserProfileSchema = z.object({ + id: z.string(), + firstName: z.string(), + lastName: z.string().nullable(), + avatar: z.object({ + path: z.string(), + url: z.string(), + width: z.number().optional(), + height: z.number().optional(), + thumbhash: z.string().optional() + }).nullable(), + username: z.string(), + status: RelationshipStatusSchema +}); \ No newline at end of file diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index 5200d2c..cf8ad4f 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -132,24 +132,8 @@ export type UpdateEvent = { artifactId: string; } | { type: 'relationship-updated'; - fromUserId: string; - toUserId: string; - status: 'pending' | 'accepted' | 'rejected' | 'removed'; - action: 'created' | 'updated' | 'deleted'; - fromUser?: { - id: string; - firstName: string; - lastName: string | null; - avatar: any | null; - username: string; - }; - toUser?: { - id: string; - firstName: string; - lastName: string | null; - avatar: any | null; - username: string; - }; + uid: string; + status: 'none' | 'requested' | 'pending' | 'friend' | 'rejected'; timestamp: number; }; @@ -553,24 +537,8 @@ export function buildDeleteArtifactUpdate(artifactId: string, updateSeq: number, export function buildRelationshipUpdatedEvent( data: { - fromUserId: string; - toUserId: string; - status: 'pending' | 'accepted' | 'rejected' | 'removed'; - action: 'created' | 'updated' | 'deleted'; - fromUser?: { - id: string; - firstName: string; - lastName: string | null; - avatar: any | null; - username: string; - }; - toUser?: { - id: string; - firstName: string; - lastName: string | null; - avatar: any | null; - username: string; - }; + uid: string; + status: 'none' | 'requested' | 'pending' | 'friend' | 'rejected'; timestamp: number; }, updateSeq: number, diff --git a/sources/app/social/friendAdd.ts b/sources/app/social/friendAdd.ts new file mode 100644 index 0000000..c3841df --- /dev/null +++ b/sources/app/social/friendAdd.ts @@ -0,0 +1,64 @@ +import { Context } from "@/context"; +import { buildUserProfile, UserProfile } from "./type"; +import { db } from "@/storage/db"; +import { RelationshipStatus } from "@prisma/client"; +import { relationshipSet } from "./relationshipSet"; +import { relationshipGet } from "./relationshipGet"; + +export async function friendAdd(ctx: Context, uid: string): Promise { + // Prevent self-friendship + if (ctx.uid === uid) { + return null; + } + + // Update relationship status + return await db.$transaction(async (tx) => { + + // Read current user objects + const currentUser = await tx.account.findUnique({ + where: { id: ctx.uid }, + include: { githubUser: true } + }); + const targetUser = await tx.account.findUnique({ + where: { id: uid }, + include: { githubUser: true } + }); + if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) { + return null; + } + + // Read relationship status + const currentUserRelationship = await relationshipGet(tx, currentUser.id, targetUser.id); + const targetUserRelationship = await relationshipGet(tx, targetUser.id, currentUser.id); + + // Handle cases + + // Case 1: There's a pending request from the target user - accept it + if (targetUserRelationship === RelationshipStatus.requested) { + + // Accept the friend request - update both to friends + await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.friend); + await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.friend); + + // Return the target user profile + return buildUserProfile(targetUser, RelationshipStatus.friend); + } + + // Case 2: If status is none or rejected, create a new request (since other side is not in requested state) + if (currentUserRelationship === RelationshipStatus.none + || currentUserRelationship === RelationshipStatus.rejected) { + await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.requested); + + // If other side is in none state, set it to pending, ignore for other states + if (targetUserRelationship === RelationshipStatus.none) { + await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.pending); + } + + // Return the target user profile + return buildUserProfile(targetUser, RelationshipStatus.requested); + } + + // Do not change anything and return the target user profile + return buildUserProfile(targetUser, currentUserRelationship); + }); +} \ No newline at end of file diff --git a/sources/app/social/friendList.ts b/sources/app/social/friendList.ts new file mode 100644 index 0000000..78359ca --- /dev/null +++ b/sources/app/social/friendList.ts @@ -0,0 +1,33 @@ +import { Context } from "@/context"; +import { buildUserProfile, UserProfile } from "./type"; +import { db } from "@/storage/db"; +import { RelationshipStatus } from "@prisma/client"; + +export async function friendList(ctx: Context): Promise { + // Query all relationships where current user is fromUserId with friend, pending, or requested status + const relationships = await db.userRelationship.findMany({ + where: { + fromUserId: ctx.uid, + status: { + in: [RelationshipStatus.friend, RelationshipStatus.pending, RelationshipStatus.requested] + } + }, + include: { + toUser: { + include: { + githubUser: true + } + } + } + }); + + // Filter out users without GitHub profiles and build UserProfile objects + const profiles: UserProfile[] = []; + for (const relationship of relationships) { + if (relationship.toUser.githubUser) { + profiles.push(buildUserProfile(relationship.toUser, relationship.status)); + } + } + + return profiles; +} \ No newline at end of file diff --git a/sources/app/social/friendRemove.ts b/sources/app/social/friendRemove.ts new file mode 100644 index 0000000..b83182c --- /dev/null +++ b/sources/app/social/friendRemove.ts @@ -0,0 +1,53 @@ +import { Context } from "@/context"; +import { buildUserProfile, UserProfile } from "./type"; +import { db } from "@/storage/db"; +import { RelationshipStatus } from "@prisma/client"; +import { relationshipSet } from "./relationshipSet"; +import { relationshipGet } from "./relationshipGet"; + +export async function friendRemove(ctx: Context, uid: string): Promise { + return await db.$transaction(async (tx) => { + + // Read current user objects + const currentUser = await tx.account.findUnique({ + where: { id: ctx.uid }, + include: { githubUser: true } + }); + const targetUser = await tx.account.findUnique({ + where: { id: uid }, + include: { githubUser: true } + }); + if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) { + return null; + } + + // Read relationship status + const currentUserRelationship = await relationshipGet(tx, currentUser.id, targetUser.id); + const targetUserRelationship = await relationshipGet(tx, targetUser.id, currentUser.id); + + // If status is requested, set it to rejected + if (currentUserRelationship === RelationshipStatus.requested) { + await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.rejected); + return buildUserProfile(targetUser, RelationshipStatus.rejected); + } + + // If they are friends, change it to pending and requested + if (currentUserRelationship === RelationshipStatus.friend) { + await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.requested); + await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.pending); + return buildUserProfile(targetUser, RelationshipStatus.requested); + } + + // If status is pending, set it to none + if (currentUserRelationship === RelationshipStatus.pending) { + await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.none); + if (targetUserRelationship !== RelationshipStatus.rejected) { + await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.none); + } + return buildUserProfile(targetUser, RelationshipStatus.none); + } + + // Return the target user profile with status none + return buildUserProfile(targetUser, currentUserRelationship); + }); +} \ No newline at end of file diff --git a/sources/app/social/relationshipGet.ts b/sources/app/social/relationshipGet.ts new file mode 100644 index 0000000..a58462e --- /dev/null +++ b/sources/app/social/relationshipGet.ts @@ -0,0 +1,12 @@ +import { Prisma, PrismaClient } from "@prisma/client"; +import { RelationshipStatus } from "@prisma/client"; + +export async function relationshipGet(tx: Prisma.TransactionClient | PrismaClient, from: string, to: string): Promise { + const relationship = await tx.userRelationship.findFirst({ + where: { + fromUserId: from, + toUserId: to + } + }); + return relationship?.status || RelationshipStatus.none; +} \ No newline at end of file diff --git a/sources/app/social/relationshipSet.ts b/sources/app/social/relationshipSet.ts new file mode 100644 index 0000000..b6e8d05 --- /dev/null +++ b/sources/app/social/relationshipSet.ts @@ -0,0 +1,44 @@ +import { Prisma } from "@prisma/client"; +import { RelationshipStatus } from "@prisma/client"; + +export async function relationshipSet(tx: Prisma.TransactionClient, from: string, to: string, status: RelationshipStatus) { + if (status === RelationshipStatus.friend) { + await tx.userRelationship.upsert({ + where: { + fromUserId_toUserId: { + fromUserId: from, + toUserId: to + } + }, + create: { + fromUserId: from, + toUserId: to, + status, + acceptedAt: new Date() + }, + update: { + status, + acceptedAt: new Date() + } + }); + } else { + await tx.userRelationship.upsert({ + where: { + fromUserId_toUserId: { + fromUserId: from, + toUserId: to + } + }, + create: { + fromUserId: from, + toUserId: to, + status, + acceptedAt: null + }, + update: { + status, + acceptedAt: null + } + }); + } +} \ No newline at end of file diff --git a/sources/app/social/type.ts b/sources/app/social/type.ts new file mode 100644 index 0000000..93d25f8 --- /dev/null +++ b/sources/app/social/type.ts @@ -0,0 +1,61 @@ +import { getPublicUrl, ImageRef } from "@/storage/files"; +import { Prisma, RelationshipStatus } from "@prisma/client"; +import { GitHubProfile } from "../api/types"; + +export type UserProfile = { + id: string; + firstName: string; + lastName: string | null; + avatar: { + path: string; + url: string; + width?: number; + height?: number; + thumbhash?: string; + } | null; + username: string; + status: RelationshipStatus; +} + +// Avatar type definition matching the database JSON structure +type AvatarData = { + path: string; + width?: number; + height?: number; + thumbhash?: string; +}; + +export function buildUserProfile( + account: { + id: string; + firstName: string | null; + lastName: string | null; + avatar: ImageRef | null; + githubUser: { profile: GitHubProfile } | null; + }, + status: RelationshipStatus +): UserProfile { + const githubProfile = account.githubUser?.profile; + const avatarJson = account.avatar; + + let avatar: UserProfile['avatar'] = null; + if (avatarJson) { + const avatarData = avatarJson; + avatar = { + path: avatarData.path, + url: getPublicUrl(avatarData.path), + width: avatarData.width, + height: avatarData.height, + thumbhash: avatarData.thumbhash + }; + } + + return { + id: account.id, + firstName: account.firstName || '', + lastName: account.lastName, + avatar, + username: githubProfile?.login || '', + status + }; +} \ No newline at end of file diff --git a/sources/context.ts b/sources/context.ts new file mode 100644 index 0000000..ad84787 --- /dev/null +++ b/sources/context.ts @@ -0,0 +1,12 @@ +export class Context { + + static create(uid: string) { + return new Context(uid); + } + + readonly uid: string; + + private constructor(uid: string) { + this.uid = uid; + } +} \ No newline at end of file diff --git a/sources/services/friendshipService.ts b/sources/services/friendshipService.ts deleted file mode 100644 index 58c5aac..0000000 --- a/sources/services/friendshipService.ts +++ /dev/null @@ -1,441 +0,0 @@ -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"; - -export interface UserProfile { - id: string; - firstName: string; - lastName: string | null; - avatar: { - path: string; - url: string; - width?: number; - height?: number; - 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: 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, - username: githubProfile?.login || '', - status - }; - } - - /** - * Get multiple user profiles by IDs - */ - static async getUserProfiles(userIds: string[], relativeToUserId?: string): Promise { - if (userIds.length === 0) { - return []; - } - - const accounts = await db.account.findMany({ - where: { - id: { in: userIds }, - githubUserId: { not: null } - }, - include: { - githubUser: true - } - }); - - 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, relativeToUserId?: string): Promise { - const githubUser = await db.githubUser.findFirst({ - where: { - profile: { - path: ['login'], - equals: username - } - } - }); - - if (!githubUser) { - return null; - } - - const account = await db.account.findFirst({ - where: { - githubUserId: githubUser.id - }, - include: { - githubUser: true - } - }); - - if (!account) { - return null; - } - - 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 { - // Verify both users exist and have GitHub connected - const [fromUser, toUser] = await Promise.all([ - db.account.findFirst({ - where: { id: fromUserId, githubUserId: { not: null } } - }), - db.account.findFirst({ - where: { id: toUserId, githubUserId: { not: null } } - }) - ]); - - if (!fromUser || !toUser) { - return null; - } - - // 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 - } - } - }); - - 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 { - // Verify the request exists and is pending - const request = await db.userRelationship.findUnique({ - where: { - fromUserId_toUserId: { - fromUserId, - toUserId - } - } - }); - - if (!request || request.status !== RelationshipStatus.pending) { - return null; - } - - // Use transaction to ensure both operations succeed - const ok = await db.$transaction(async (tx) => { - // Update original request to accepted - const relationship = await tx.userRelationship.update({ - where: { - fromUserId_toUserId: { - fromUserId, - toUserId - } - }, - data: { - status: RelationshipStatus.accepted, - acceptedAt: new Date() - } - }); - - // Create reverse relationship - const reverseRelationship = await tx.userRelationship.create({ - data: { - fromUserId: toUserId, - toUserId: fromUserId, - status: RelationshipStatus.accepted, - acceptedAt: new Date() - } - }); - - return !!relationship && !!reverseRelationship; - }); - 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 { - 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; - } - - const _ = await tx.userRelationship.update({ - where: { - fromUserId_toUserId: { - fromUserId, - toUserId - } - }, - 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 { - const ok = await db.$transaction(async (tx) => { - await tx.userRelationship.deleteMany({ - where: { - OR: [ - { fromUserId: userId, toUserId: friendId }, - { fromUserId: friendId, toUserId: userId } - ] - } - }); - 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); - } - - /** - * Get all pending friend requests for a user - */ - static async getPendingRequests(userId: string): Promise> { - const requests = await db.userRelationship.findMany({ - where: { - toUserId: userId, - status: RelationshipStatus.pending - }, - include: { - fromUser: { - include: { - githubUser: true - } - } - }, - orderBy: { - createdAt: 'desc' - } - }); - - return requests.map(request => ({ - ...request, - fromUser: this.buildUserProfile(request.fromUser, RelationshipStatus.pending) - })); - } - - /** - * Get all friends (mutual accepted relationships) - */ - static async getFriends(userId: string): Promise { - // Find all accepted relationships where user is either fromUser or toUser - const relationships = await db.userRelationship.findMany({ - where: { - AND: [ - { fromUserId: userId }, - { status: RelationshipStatus.accepted } - ] - }, - include: { - toUser: { - include: { - githubUser: true - } - } - } - }); - - // Check for mutual relationships - const friendIds = new Set(); - for (const rel of relationships) { - // Check if reverse relationship exists and is accepted - const reverseRel = await db.userRelationship.findUnique({ - where: { - fromUserId_toUserId: { - fromUserId: rel.toUserId, - toUserId: userId - } - } - }); - - if (reverseRel && reverseRel.status === RelationshipStatus.accepted) { - friendIds.add(rel.toUserId); - } - } - - if (friendIds.size === 0) { - return []; - } - - const friends = await db.account.findMany({ - where: { - id: { in: Array.from(friendIds) } - }, - include: { - githubUser: true - } - }); - - return friends.map(friend => this.buildUserProfile(friend, RelationshipStatus.accepted)); - } - - /** - * Remove all relationships when GitHub is disconnected - */ - static async removeAllRelationships(userId: string): Promise { - await db.userRelationship.deleteMany({ - where: { - OR: [ - { fromUserId: userId }, - { toUserId: userId } - ] - } - }); - } - - /** - * Check if a user has GitHub connected - */ - static async hasGitHubConnected(userId: string): Promise { - const account = await db.account.findUnique({ - where: { id: userId }, - select: { githubUserId: true } - }); - - return !!account?.githubUserId; - } -}