diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d97d29e..a166ed8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,8 @@ model Account { Machine Machine[] UploadedFile UploadedFile[] ServiceAccountToken ServiceAccountToken[] + RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") + RelationshipsTo UserRelationship[] @relation("RelationshipsTo") Artifact Artifact[] AccessKey AccessKey[] } @@ -289,3 +291,29 @@ model AccessKey { @@index([sessionId]) @@index([machineId]) } + +// +// Social Network - Relationships +// + +enum RelationshipStatus { + pending + accepted + rejected + removed +} + +model UserRelationship { + fromUserId String + fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade) + toUserId String + toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade) + status RelationshipStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + acceptedAt DateTime? + + @@id([fromUserId, toUserId]) + @@index([toUserId, status]) + @@index([fromUserId, status]) +} diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 37e0b78..d0ac2f6 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -16,6 +16,7 @@ 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"; @@ -61,6 +62,7 @@ export async function startApi(eventRouter: EventRouter) { devRoutes(typed); versionRoutes(typed); voiceRoutes(typed); + friendshipRoutes(typed, eventRouter); // 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 new file mode 100644 index 0000000..304f01a --- /dev/null +++ b/sources/app/api/routes/friendshipRoutes.ts @@ -0,0 +1,375 @@ +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 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() +}); + +const RelationshipStatusSchema = z.enum(['pending', 'accepted', 'rejected', 'removed']); + +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/friends/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); + 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/friends/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); + 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: RelationshipSchema, + 400: z.object({ error: z.string() }), + 403: 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.code(403).send({ error: 'Both users must have GitHub connected' }); + } + + const relationship = await FriendshipService.sendFriendRequest(userId, recipientId); + + // Get profiles for the socket event + const [fromUserProfile, toUserProfile] = await FriendshipService.getUserProfiles([userId, recipientId]); + + // Emit socket event to recipient + const updateSeq = await allocateUserSeq(recipientId); + const updatePayload = buildRelationshipUpdatedEvent( + { + fromUserId: relationship.fromUserId, + toUserId: relationship.toUserId, + status: relationship.status, + action: 'created', + fromUser: fromUserProfile, + timestamp: Date.now() + }, + updateSeq, + randomKeyNaked(12) + ); + + eventRouter.emitUpdate({ + userId: recipientId, + payload: updatePayload, + 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) { + 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' }); + } + }); + + // 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({ + relationship: RelationshipSchema, + reverseRelationship: RelationshipSchema.optional() + }), + 400: z.object({ error: z.string() }), + 404: 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 result = 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 + for (const targetUserId of [fromUserId, toUserId]) { + const updateSeq = await allocateUserSeq(targetUserId); + const updatePayload = buildRelationshipUpdatedEvent( + { + fromUserId: result.relationship.fromUserId, + toUserId: result.relationship.toUserId, + status: result.relationship.status, + 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({ + 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 { + const relationship = 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 + } + }); + } + } catch (error: any) { + 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' }); + } + }); + + // 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({ + removed: z.boolean() + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { friendId } = request.params; + + try { + const removed = await FriendshipService.removeFriend(userId, friendId); + + // Get profiles for the socket event + const [userProfile] = await FriendshipService.getUserProfiles([userId]); + + // 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({ removed }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to remove friend: ${error}`); + return reply.code(500).send({ removed: false }); + } + }); +} \ No newline at end of file diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index 03676c7..5200d2c 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -130,6 +130,27 @@ export type UpdateEvent = { } | { type: 'delete-artifact'; 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; + }; + timestamp: number; }; // === EPHEMERAL EVENT TYPES (Transient) === @@ -528,4 +549,40 @@ export function buildDeleteArtifactUpdate(artifactId: string, updateSeq: number, }, createdAt: Date.now() }; -} \ No newline at end of file +} + +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; + }; + timestamp: number; + }, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'relationship-updated', + ...data + }, + createdAt: Date.now() + }; +} diff --git a/sources/services/friendshipService.ts b/sources/services/friendshipService.ts new file mode 100644 index 0000000..f0cbf4e --- /dev/null +++ b/sources/services/friendshipService.ts @@ -0,0 +1,365 @@ +import { db } from "@/storage/db"; +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; +} + +export class FriendshipService { + /** + * 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; + + return { + id: account.id, + firstName: account.firstName || '', + lastName: account.lastName, + avatar: account.avatar ? { + ...account.avatar, + url: getPublicUrl(account.avatar.path) + } : null, + username: githubProfile?.login || '' + }; + } + + /** + * Get multiple user profiles by IDs + */ + static async getUserProfiles(userIds: string[]): Promise { + if (userIds.length === 0) { + return []; + } + + const accounts = await db.account.findMany({ + where: { + id: { in: userIds }, + githubUserId: { not: null } + }, + include: { + githubUser: true + } + }); + + return accounts.map(account => this.buildUserProfile(account)); + } + + /** + * Search for a user by exact username match + */ + static async searchUserByUsername(username: 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; + } + + return this.buildUserProfile(account); + } + + /** + * 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) { + throw new Error('Both users must exist and have GitHub connected'); + } + + // 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() + } + }); + } + throw new Error('Friend request already exists'); + } + + // Create new friend request + return await db.userRelationship.create({ + data: { + fromUserId, + toUserId, + status: RelationshipStatus.pending + } + }); + } + + /** + * Accept a friend request + */ + static async acceptFriendRequest(fromUserId: string, toUserId: string): Promise<{ + relationship: UserRelationship; + reverseRelationship: UserRelationship; + }> { + // Verify the request exists and is pending + const request = await db.userRelationship.findUnique({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId + } + } + }); + + if (!request || request.status !== RelationshipStatus.pending) { + throw new Error('No pending friend request found'); + } + + // Use transaction to ensure both operations succeed + const result = 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 }; + }); + + return result; + } + + /** + * Reject a friend request + */ + static async rejectFriendRequest(fromUserId: string, toUserId: string): Promise { + const request = await db.userRelationship.findUnique({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId + } + } + }); + + if (!request || request.status !== RelationshipStatus.pending) { + throw new Error('No pending friend request found'); + } + + return await db.userRelationship.update({ + where: { + fromUserId_toUserId: { + fromUserId, + toUserId + } + }, + data: { + status: RelationshipStatus.rejected + } + }); + } + + /** + * Remove a friendship (both directions) + */ + static async removeFriend(userId: string, friendId: string): Promise { + await db.$transaction([ + db.userRelationship.deleteMany({ + where: { + OR: [ + { fromUserId: userId, toUserId: friendId }, + { fromUserId: friendId, toUserId: userId } + ] + } + }) + ]); + + return true; + } + + /** + * 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) + })); + } + + /** + * 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)); + } + + /** + * 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; + } +} \ No newline at end of file