feat: add social network friendship API
- Add UserRelationship model with unidirectional relationships - Implement friendship service with GitHub requirement - Add REST endpoints for friend requests, acceptance, and listing - Add single relationship-updated WebSocket event - Support batch profile fetching with consistent schemas Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
parent
fbd8e57ed6
commit
aaff5dcaf4
@ -44,6 +44,8 @@ model Account {
|
|||||||
Machine Machine[]
|
Machine Machine[]
|
||||||
UploadedFile UploadedFile[]
|
UploadedFile UploadedFile[]
|
||||||
ServiceAccountToken ServiceAccountToken[]
|
ServiceAccountToken ServiceAccountToken[]
|
||||||
|
RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom")
|
||||||
|
RelationshipsTo UserRelationship[] @relation("RelationshipsTo")
|
||||||
Artifact Artifact[]
|
Artifact Artifact[]
|
||||||
AccessKey AccessKey[]
|
AccessKey AccessKey[]
|
||||||
}
|
}
|
||||||
@ -289,3 +291,29 @@ model AccessKey {
|
|||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([machineId])
|
@@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])
|
||||||
|
}
|
||||||
|
@ -16,6 +16,7 @@ import { versionRoutes } from "./routes/versionRoutes";
|
|||||||
import { voiceRoutes } from "./routes/voiceRoutes";
|
import { voiceRoutes } from "./routes/voiceRoutes";
|
||||||
import { artifactsRoutes } from "./routes/artifactsRoutes";
|
import { artifactsRoutes } from "./routes/artifactsRoutes";
|
||||||
import { accessKeysRoutes } from "./routes/accessKeysRoutes";
|
import { accessKeysRoutes } from "./routes/accessKeysRoutes";
|
||||||
|
import { friendshipRoutes } from "./routes/friendshipRoutes";
|
||||||
import { enableMonitoring } from "./utils/enableMonitoring";
|
import { enableMonitoring } from "./utils/enableMonitoring";
|
||||||
import { enableErrorHandlers } from "./utils/enableErrorHandlers";
|
import { enableErrorHandlers } from "./utils/enableErrorHandlers";
|
||||||
import { enableAuthentication } from "./utils/enableAuthentication";
|
import { enableAuthentication } from "./utils/enableAuthentication";
|
||||||
@ -61,6 +62,7 @@ export async function startApi(eventRouter: EventRouter) {
|
|||||||
devRoutes(typed);
|
devRoutes(typed);
|
||||||
versionRoutes(typed);
|
versionRoutes(typed);
|
||||||
voiceRoutes(typed);
|
voiceRoutes(typed);
|
||||||
|
friendshipRoutes(typed, eventRouter);
|
||||||
|
|
||||||
// Start HTTP
|
// Start HTTP
|
||||||
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
|
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
|
||||||
|
375
sources/app/api/routes/friendshipRoutes.ts
Normal file
375
sources/app/api/routes/friendshipRoutes.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -130,6 +130,27 @@ export type UpdateEvent = {
|
|||||||
} | {
|
} | {
|
||||||
type: 'delete-artifact';
|
type: 'delete-artifact';
|
||||||
artifactId: string;
|
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) ===
|
// === EPHEMERAL EVENT TYPES (Transient) ===
|
||||||
@ -528,4 +549,40 @@ export function buildDeleteArtifactUpdate(artifactId: string, updateSeq: number,
|
|||||||
},
|
},
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
365
sources/services/friendshipService.ts
Normal file
365
sources/services/friendshipService.ts
Normal file
@ -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<UserProfile[]> {
|
||||||
|
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<UserProfile | null> {
|
||||||
|
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<UserRelationship> {
|
||||||
|
// 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<UserRelationship> {
|
||||||
|
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<boolean> {
|
||||||
|
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<Array<UserRelationship & {
|
||||||
|
fromUser: UserProfile;
|
||||||
|
}>> {
|
||||||
|
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<UserProfile[]> {
|
||||||
|
// 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<string>();
|
||||||
|
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<void> {
|
||||||
|
await db.userRelationship.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromUserId: userId },
|
||||||
|
{ toUserId: userId }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has GitHub connected
|
||||||
|
*/
|
||||||
|
static async hasGitHubConnected(userId: string): Promise<boolean> {
|
||||||
|
const account = await db.account.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { githubUserId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!account?.githubUserId;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user