fix: fix friendship
This commit is contained in:
parent
b8bc0734a0
commit
79b97c1b88
@ -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;
|
@ -297,10 +297,11 @@ model AccessKey {
|
|||||||
//
|
//
|
||||||
|
|
||||||
enum RelationshipStatus {
|
enum RelationshipStatus {
|
||||||
|
none
|
||||||
|
requested
|
||||||
pending
|
pending
|
||||||
accepted
|
friend
|
||||||
rejected
|
rejected
|
||||||
removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserRelationship {
|
model UserRelationship {
|
||||||
|
@ -16,10 +16,10 @@ 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";
|
||||||
|
import { userRoutes } from "./routes/userRoutes";
|
||||||
|
|
||||||
export async function startApi(eventRouter: EventRouter) {
|
export async function startApi(eventRouter: EventRouter) {
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ export async function startApi(eventRouter: EventRouter) {
|
|||||||
devRoutes(typed);
|
devRoutes(typed);
|
||||||
versionRoutes(typed);
|
versionRoutes(typed);
|
||||||
voiceRoutes(typed);
|
voiceRoutes(typed);
|
||||||
friendshipRoutes(typed, eventRouter);
|
userRoutes(typed);
|
||||||
|
|
||||||
// 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;
|
||||||
|
@ -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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
@ -265,7 +265,7 @@ export function sessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
|||||||
accountId: userId,
|
accountId: userId,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
metadata: metadata,
|
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}`);
|
log({ module: 'session-create', sessionId: session.id, userId }, `Session created: ${session.id}`);
|
||||||
|
216
sources/app/api/routes/userRoutes.ts
Normal file
216
sources/app/api/routes/userRoutes.ts
Normal file
@ -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
|
||||||
|
});
|
@ -132,24 +132,8 @@ export type UpdateEvent = {
|
|||||||
artifactId: string;
|
artifactId: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'relationship-updated';
|
type: 'relationship-updated';
|
||||||
fromUserId: string;
|
uid: string;
|
||||||
toUserId: string;
|
status: 'none' | 'requested' | 'pending' | 'friend' | 'rejected';
|
||||||
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;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -553,24 +537,8 @@ export function buildDeleteArtifactUpdate(artifactId: string, updateSeq: number,
|
|||||||
|
|
||||||
export function buildRelationshipUpdatedEvent(
|
export function buildRelationshipUpdatedEvent(
|
||||||
data: {
|
data: {
|
||||||
fromUserId: string;
|
uid: string;
|
||||||
toUserId: string;
|
status: 'none' | 'requested' | 'pending' | 'friend' | 'rejected';
|
||||||
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;
|
timestamp: number;
|
||||||
},
|
},
|
||||||
updateSeq: number,
|
updateSeq: number,
|
||||||
|
64
sources/app/social/friendAdd.ts
Normal file
64
sources/app/social/friendAdd.ts
Normal file
@ -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<UserProfile | null> {
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
33
sources/app/social/friendList.ts
Normal file
33
sources/app/social/friendList.ts
Normal file
@ -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<UserProfile[]> {
|
||||||
|
// 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;
|
||||||
|
}
|
53
sources/app/social/friendRemove.ts
Normal file
53
sources/app/social/friendRemove.ts
Normal file
@ -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<UserProfile | null> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
12
sources/app/social/relationshipGet.ts
Normal file
12
sources/app/social/relationshipGet.ts
Normal file
@ -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<RelationshipStatus> {
|
||||||
|
const relationship = await tx.userRelationship.findFirst({
|
||||||
|
where: {
|
||||||
|
fromUserId: from,
|
||||||
|
toUserId: to
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return relationship?.status || RelationshipStatus.none;
|
||||||
|
}
|
44
sources/app/social/relationshipSet.ts
Normal file
44
sources/app/social/relationshipSet.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
61
sources/app/social/type.ts
Normal file
61
sources/app/social/type.ts
Normal file
@ -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
|
||||||
|
};
|
||||||
|
}
|
12
sources/context.ts
Normal file
12
sources/context.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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<string, unknown>;
|
|
||||||
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<RelationshipStatus> {
|
|
||||||
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<UserProfile[]> {
|
|
||||||
if (userIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await db.account.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: userIds },
|
|
||||||
githubUserId: { not: null }
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
githubUser: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let statusMap: Record<string, RelationshipStatus> = {};
|
|
||||||
if (relativeToUserId) {
|
|
||||||
const rels = await db.userRelationship.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ fromUserId: relativeToUserId, toUserId: { in: userIds } },
|
|
||||||
{ fromUserId: { in: userIds }, toUserId: relativeToUserId }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tmp: Record<string, RelationshipStatus[]> = {};
|
|
||||||
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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<UserProfile | null> {
|
|
||||||
// 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<UserProfile | null> {
|
|
||||||
// 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<UserProfile | null> {
|
|
||||||
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<UserProfile | null> {
|
|
||||||
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<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, RelationshipStatus.pending)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, RelationshipStatus.accepted));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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