import type { FastifyInstance } from 'fastify'; import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from '../ws/handler.js'; import type { Friend, FriendRequest, SendFriendRequest, UpdateFriendRequest, DiscoverUser, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; export async function socialRoutes(app: FastifyInstance): Promise { // GET /api/social/friends - List all friends app.get('/api/social/friends', { preHandler: authenticate, }, async (request, reply) => { const db = getDb(); // Get all friends where current user is either userId or friendId const friendRows = db.select() .from(schema.friends) .where(or( eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, request.userId) )) .all(); if (friendRows.length === 0) { return reply.code(200).send([]); } // Get the IDs of the actual friends (not the current user) const friendIds = friendRows.map(f => f.userId === request.userId ? f.friendId : f.userId); const friendUsers = db.select() .from(schema.users) .where(or(...friendIds.map(id => eq(schema.users.id, id)))) .all(); const friends: Friend[] = friendUsers.map(u => { const relationship = friendRows.find(f => f.userId === u.id || f.friendId === u.id); return { ...sanitizeUser(u), addedAt: relationship?.createdAt ?? Date.now(), }; }); return reply.code(200).send(friends); }); // GET /api/social/requests - List pending friend requests app.get('/api/social/requests', { preHandler: authenticate, }, async (request, reply) => { const db = getDb(); const requests = db.select() .from(schema.friendRequests) .where(and( or( eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, request.userId) ), eq(schema.friendRequests.status, 'pending') )) .all(); if (requests.length === 0) { return reply.code(200).send([]); } // Enhance with user data const userIds = requests.map(r => r.fromId === request.userId ? r.toId : r.fromId); const users = db.select() .from(schema.users) .where(or(...userIds.map(id => eq(schema.users.id, id)))) .all(); const userMap = new Map(users.map(u => [u.id, u])); const result: FriendRequest[] = requests.map(r => { const otherId = r.fromId === request.userId ? r.toId : r.fromId; const otherUser = userMap.get(otherId); return { id: r.id, fromId: r.fromId, toId: r.toId, status: (r.status ?? 'pending') as any, createdAt: r.createdAt, user: otherUser ? sanitizeUser(otherUser) : undefined, }; }); return reply.code(200).send(result); }); // POST /api/social/requests - Send a friend request app.post<{ Body: SendFriendRequest }>('/api/social/requests', { preHandler: authenticate, }, async (request, reply) => { const { username } = request.body; const db = getDb(); if (!username) { return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); } // Find the target user const targetUser = db.select().from(schema.users).where(eq(schema.users.username, username)).get(); if (!targetUser) { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } if (targetUser.id === request.userId) { return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 }); } // Check if already friends const existingFriend = db.select().from(schema.friends).where(or( and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)), and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId)) )).get(); if (existingFriend) { return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 }); } // Check for existing pending request const existingRequest = db.select().from(schema.friendRequests).where(and( or( and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)), and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId)) ), eq(schema.friendRequests.status, 'pending') )).get(); if (existingRequest) { return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 }); } // Create the request const id = generateSnowflake(); const now = Date.now(); db.insert(schema.friendRequests).values({ id, fromId: request.userId, toId: targetUser.id, status: 'pending', createdAt: now, }).run(); // Get the sender user for the WS event const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); // Broadcast friend_request_received to the target user const friendRequestPayload: FriendRequest = { id, fromId: request.userId, toId: targetUser.id, status: 'pending', createdAt: now, user: senderUser ? sanitizeUser(senderUser) : undefined, }; connectionManager.sendToUser(targetUser.id, { type: 'friend_request_received', request: friendRequestPayload, }); return reply.code(201).send({ success: true, requestId: id }); }); // PATCH /api/social/requests/:id - Accept/Decline a friend request app.patch<{ Params: { id: string }; Body: UpdateFriendRequest }>('/api/social/requests/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const { status } = request.body; const db = getDb(); if (!['accepted', 'declined'].includes(status)) { return reply.code(400).send({ error: 'Invalid status', statusCode: 400 }); } const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get(); if (!friendRequest) { return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 }); } if (friendRequest.toId !== request.userId) { return reply.code(403).send({ error: 'You can only manage requests sent to you', statusCode: 403 }); } if (status === 'accepted') { // Insert friend and update request status atomically const now = Date.now(); db.transaction((tx) => { tx.insert(schema.friends).values({ userId: friendRequest.fromId, friendId: friendRequest.toId, createdAt: now, }).run(); tx.update(schema.friendRequests) .set({ status }) .where(eq(schema.friendRequests.id, id)) .run(); }); // WS broadcast AFTER transaction commits const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); if (acceptingUser) { const friend: Friend = { ...sanitizeUser(acceptingUser), addedAt: now, }; connectionManager.sendToUser(friendRequest.fromId, { type: 'friend_request_accepted', friend, requestId: id, }); } } else { // For declined, just update the status (single write, no transaction needed) db.update(schema.friendRequests) .set({ status }) .where(eq(schema.friendRequests.id, id)) .run(); // Broadcast to the original sender so their UI updates in real-time connectionManager.sendToUser(friendRequest.fromId, { type: 'friend_request_declined', requestId: id, userId: request.userId, }); } return reply.code(200).send({ success: true }); }); // DELETE /api/social/requests/:id - Cancel an outgoing friend request app.delete<{ Params: { id: string } }>('/api/social/requests/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get(); if (!friendRequest) { return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 }); } // Only the sender can cancel an outgoing request if (friendRequest.fromId !== request.userId) { return reply.code(403).send({ error: 'You can only cancel requests you sent', statusCode: 403 }); } if (friendRequest.status !== 'pending') { return reply.code(400).send({ error: 'Can only cancel pending requests', statusCode: 400 }); } db.delete(schema.friendRequests) .where(eq(schema.friendRequests.id, id)) .run(); // Broadcast to the recipient so their UI updates in real-time connectionManager.sendToUser(friendRequest.toId, { type: 'friend_request_cancelled', requestId: id, userId: request.userId, }); return reply.code(200).send({ success: true }); }); // DELETE /api/social/friends/:id - Remove a friend app.delete<{ Params: { id: string } }>('/api/social/friends/:id', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; const db = getDb(); // Check friendship exists before deleting const existing = db.select().from(schema.friends).where(or( and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)), and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId)) )).get(); if (!existing) { return reply.code(404).send({ error: 'You are not friends with this user', statusCode: 404 }); } db.delete(schema.friends).where(or( and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)), and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId)) )).run(); // Broadcast friend_removed to the other user so their friends list updates connectionManager.sendToUser(id, { type: 'friend_removed', userId: request.userId, }); return reply.code(200).send({ success: true }); }); // GET /api/social/discover - Discover users on this instance app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/social/discover', { preHandler: authenticate, config: { rateLimit: { max: 30, timeWindow: '1 minute' } }, }, async (request, reply) => { const db = getDb(); const q = request.query.q?.trim() || ''; const limit = Math.min(Math.max(parseInt(request.query.limit || '24', 10) || 24, 1), 100); const offset = Math.max(parseInt(request.query.offset || '0', 10) || 0, 0); const myId = request.userId; // Build WHERE clause const conditions = [ eq(schema.users.discoverable, 1), eq(schema.users.isDeleted, 0), ne(schema.users.id, myId), // Exclude replicated federated users — each instance only surfaces its own native users. // Federated users are discovered through the parallel fetch across connected instances. sql`(${schema.users.homeInstance} IS NULL OR ${schema.users.homeInstance} = '')`, ]; if (q) { const pattern = `%${q}%`; conditions.push(or( like(schema.users.username, pattern), like(schema.users.displayName, pattern), )!); } // Get total count const countResult = db.select({ count: sql`count(*)` }) .from(schema.users) .where(and(...conditions)) .get(); const total = countResult?.count ?? 0; if (total === 0) { return reply.code(200).send({ users: [], total: 0 }); } // Pre-load my social graph const myFriendRows = db.select().from(schema.friends).where( or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId)) ).all(); const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId)); const mySpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId }) .from(schema.spaceMembers) .where(eq(schema.spaceMembers.userId, myId)) .all(); const mySpaceIds = new Set(mySpaceRows.map(s => s.spaceId)); const outboundRequests = db.select().from(schema.friendRequests).where( and(eq(schema.friendRequests.fromId, myId), eq(schema.friendRequests.status, 'pending')) ).all(); const outboundMap = new Map(outboundRequests.map(r => [r.toId, r.id])); const inboundRequests = db.select().from(schema.friendRequests).where( and(eq(schema.friendRequests.toId, myId), eq(schema.friendRequests.status, 'pending')) ).all(); const inboundMap = new Map(inboundRequests.map(r => [r.fromId, r.id])); // Fetch page of users const userRows = db.select() .from(schema.users) .where(and(...conditions)) .orderBy(sql`created_at DESC`) .limit(limit) .offset(offset) .all(); // Batch fetch friends and space memberships for all page users const pageUserIds = userRows.map(r => r.id); // Batch fetch all friends for page users const pageFriendRows = pageUserIds.length > 0 ? db.select().from(schema.friends).where( or( inArray(schema.friends.userId, pageUserIds), inArray(schema.friends.friendId, pageUserIds), ) ).all() : []; // Build Map> for page users const friendIdsByUser = new Map>(); for (const f of pageFriendRows) { // Map both directions if (pageUserIds.includes(f.userId)) { if (!friendIdsByUser.has(f.userId)) friendIdsByUser.set(f.userId, new Set()); friendIdsByUser.get(f.userId)!.add(f.friendId); } if (pageUserIds.includes(f.friendId)) { if (!friendIdsByUser.has(f.friendId)) friendIdsByUser.set(f.friendId, new Set()); friendIdsByUser.get(f.friendId)!.add(f.userId); } } // Batch fetch all space memberships for page users const pageSpaceMemberRows = pageUserIds.length > 0 ? db.select({ userId: schema.spaceMembers.userId, spaceId: schema.spaceMembers.spaceId }) .from(schema.spaceMembers) .where(inArray(schema.spaceMembers.userId, pageUserIds)) .all() : []; // Build Map> for page users const spaceIdsByUser = new Map>(); for (const sm of pageSpaceMemberRows) { if (!spaceIdsByUser.has(sm.userId)) spaceIdsByUser.set(sm.userId, new Set()); spaceIdsByUser.get(sm.userId)!.add(sm.spaceId); } // Compute mutual counts + relationship for each user const discoverUsers: DiscoverUser[] = userRows.map(row => { const u = sanitizeUser(row); // Mutual friends (using batch-fetched data) const theirFriendIds = friendIdsByUser.get(row.id) ?? new Set(); const mutualFriendCount = [...myFriendIds].filter(id => theirFriendIds.has(id)).length; // Mutual spaces (using batch-fetched data) const theirSpaceIds = spaceIdsByUser.get(row.id) ?? new Set(); const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length; // Relationship let relationship: DiscoverUser['relationship'] = 'none'; let requestId: string | undefined; if (myFriendIds.has(row.id)) { relationship = 'friends'; } else if (outboundMap.has(row.id)) { relationship = 'outbound_pending'; requestId = outboundMap.get(row.id); } else if (inboundMap.has(row.id)) { relationship = 'inbound_pending'; requestId = inboundMap.get(row.id); } return { id: u.id, username: u.username, displayName: u.displayName, avatar: u.avatar, banner: u.banner, avatarColor: u.avatarColor, bio: u.bio, status: u.status, customStatus: u.customStatus, createdAt: u.createdAt, homeInstance: u.homeInstance, homeUserId: u.homeUserId, mutualFriendCount, mutualSpaceCount, relationship, ...(requestId ? { requestId } : {}), }; }); // Sort: mutual friends DESC, then created_at DESC discoverUsers.sort((a, b) => { if (b.mutualFriendCount !== a.mutualFriendCount) return b.mutualFriendCount - a.mutualFriendCount; return b.createdAt - a.createdAt; }); return reply.code(200).send({ users: discoverUsers, total }); }); // GET /api/social/search?q=... - Search for users to add as friends app.get<{ Querystring: { q: string } }>('/api/social/search', { preHandler: authenticate, config: { rateLimit: { max: 30, timeWindow: '1 minute' } }, }, async (request, reply) => { const { q } = request.query; const db = getDb(); if (!q || q.length < 2) { return reply.code(200).send([]); } const pattern = `%${q}%`; // Search by username or display name with partial matching, excluding current user const users = db.select() .from(schema.users) .where(and( or( like(schema.users.username, pattern), like(schema.users.displayName, pattern) ), ne(schema.users.id, request.userId) )) .limit(10) .all(); return reply.code(200).send(users.map(u => sanitizeUser(u))); }); }