happy-server/sources/app/api/routes/friendshipRoutes.ts
2025-09-17 22:25:06 -07:00

328 lines
11 KiB
TypeScript

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 });
}
});
}