import { prisma } from '../../config/database'; import type { FriendshipStatus } from '@prisma/client'; import { notificationService } from './notification.service'; import { achievementsService } from './achievements.service'; import { sseService } from './sse.service'; const FRIEND_SELECT = { id: true, email: true, name: true, role: true, createdAt: true, } as const; export const friendshipService = { /** Send a friend request (creates pending Friendship) */ async sendRequest(userId: string, friendId: string) { // Validation: cannot friend yourself if (userId === friendId) { throw Object.assign(new Error('Cannot send friend request to yourself'), { statusCode: 400 }); } // Check target user exists const target = await prisma.user.findUnique({ where: { id: friendId }, select: { id: true } }); if (!target) { throw Object.assign(new Error('User not found'), { statusCode: 404 }); } // Check block in either direction const blocked = await this.isBlocked(userId, friendId); if (blocked) { throw Object.assign(new Error('Cannot send friend request'), { statusCode: 403 }); } // Check target privacy settings const privacy = await prisma.privacySettings.findUnique({ where: { userId: friendId } }); if (privacy?.allowFriendRequests === false) { throw Object.assign(new Error('This user is not accepting friend requests'), { statusCode: 403 }); } // Check for existing friendship in either direction const existing = await prisma.friendship.findFirst({ where: { OR: [ { userId, friendId }, { userId: friendId, friendId: userId }, ], }, }); if (existing) { if (existing.status === 'accepted') { throw Object.assign(new Error('Already friends'), { statusCode: 409 }); } if (existing.status === 'pending') { // If they already sent us a request, auto-accept if (existing.userId === friendId) { return this.acceptRequest(userId, existing.id); } throw Object.assign(new Error('Friend request already sent'), { statusCode: 409 }); } if (existing.status === 'declined') { // Allow re-sending after decline — update existing record return prisma.friendship.update({ where: { id: existing.id }, data: { userId, friendId, status: 'pending', acceptedAt: null }, include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } }, }); } } const friendship = await prisma.friendship.create({ data: { userId, friendId, status: 'pending' }, include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } }, }); // Notify the recipient const senderName = friendship.user.name || friendship.user.email; notificationService.createNotification( friendId, 'friend_request', 'New Friend Request', `${senderName} sent you a friend request`, { friendshipId: friendship.id, fromUserId: userId }, ).catch(() => {}); // fire-and-forget // Push real-time SSE event to recipient sseService.sendToUser(friendId, 'friend_request', { friendshipId: friendship.id, from: friendship.user, }); return friendship; }, /** Accept an incoming friend request */ async acceptRequest(userId: string, friendshipId: number) { const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId }, }); if (!friendship) { throw Object.assign(new Error('Friend request not found'), { statusCode: 404 }); } if (friendship.friendId !== userId) { throw Object.assign(new Error('Not your friend request to accept'), { statusCode: 403 }); } if (friendship.status !== 'pending') { throw Object.assign(new Error('Request is not pending'), { statusCode: 400 }); } const updated = await prisma.friendship.update({ where: { id: friendshipId }, data: { status: 'accepted', acceptedAt: new Date() }, include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } }, }); // Notify the original sender that their request was accepted const accepterName = updated.friend.name || updated.friend.email; notificationService.createNotification( updated.userId, // the original sender 'friend_accepted', 'Friend Request Accepted', `${accepterName} accepted your friend request`, { friendshipId: updated.id, fromUserId: userId }, ).catch(() => {}); // Push real-time SSE event to original sender sseService.sendToUser(updated.userId, 'friend_accepted', { friendshipId: updated.id, friend: updated.friend, }); // Achievement check for both users (fire-and-forget) achievementsService.checkAndUnlock(userId, ['social']).catch(() => {}); achievementsService.checkAndUnlock(updated.userId, ['social']).catch(() => {}); return updated; }, /** Decline an incoming friend request */ async declineRequest(userId: string, friendshipId: number) { const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId }, }); if (!friendship) { throw Object.assign(new Error('Friend request not found'), { statusCode: 404 }); } if (friendship.friendId !== userId) { throw Object.assign(new Error('Not your friend request to decline'), { statusCode: 403 }); } if (friendship.status !== 'pending') { throw Object.assign(new Error('Request is not pending'), { statusCode: 400 }); } return prisma.friendship.update({ where: { id: friendshipId }, data: { status: 'declined' }, }); }, /** Cancel an outgoing friend request */ async cancelRequest(userId: string, friendshipId: number) { const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId }, }); if (!friendship) { throw Object.assign(new Error('Friend request not found'), { statusCode: 404 }); } if (friendship.userId !== userId) { throw Object.assign(new Error('Not your friend request to cancel'), { statusCode: 403 }); } if (friendship.status !== 'pending') { throw Object.assign(new Error('Request is not pending'), { statusCode: 400 }); } await prisma.friendship.delete({ where: { id: friendshipId } }); return { success: true }; }, /** Remove an accepted friendship */ async unfriend(userId: string, friendId: string) { const friendship = await prisma.friendship.findFirst({ where: { OR: [ { userId, friendId, status: 'accepted' }, { userId: friendId, friendId: userId, status: 'accepted' }, ], }, }); if (!friendship) { throw Object.assign(new Error('Not friends'), { statusCode: 404 }); } // Also remove from close friends await prisma.$transaction([ prisma.friendship.delete({ where: { id: friendship.id } }), prisma.closeFriend.deleteMany({ where: { OR: [ { userId, closeFriendId: friendId }, { userId: friendId, closeFriendId: userId }, ], }, }), ]); return { success: true }; }, /** List accepted friends (paginated) */ async listFriends(userId: string, page: number, limit: number) { const skip = (page - 1) * limit; const [friendships, total] = await Promise.all([ prisma.friendship.findMany({ where: { OR: [ { userId, status: 'accepted' }, { friendId: userId, status: 'accepted' }, ], }, include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT }, }, orderBy: { acceptedAt: 'desc' }, skip, take: limit, }), prisma.friendship.count({ where: { OR: [ { userId, status: 'accepted' }, { friendId: userId, status: 'accepted' }, ], }, }), ]); // Return the "other" user for each friendship const friends = friendships.map((f) => ({ friendshipId: f.id, acceptedAt: f.acceptedAt, user: f.userId === userId ? f.friend : f.user, })); return { friends, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, /** List incoming pending requests */ async listPendingReceived(userId: string) { const requests = await prisma.friendship.findMany({ where: { friendId: userId, status: 'pending' }, include: { user: { select: FRIEND_SELECT } }, orderBy: { createdAt: 'desc' }, }); return requests.map((r) => ({ friendshipId: r.id, createdAt: r.createdAt, from: r.user, })); }, /** List outgoing pending requests */ async listPendingSent(userId: string) { const requests = await prisma.friendship.findMany({ where: { userId, status: 'pending' }, include: { friend: { select: FRIEND_SELECT } }, orderBy: { createdAt: 'desc' }, }); return requests.map((r) => ({ friendshipId: r.id, createdAt: r.createdAt, to: r.friend, })); }, /** Get friendship status between two users */ async getFriendshipStatus(userId: string, otherUserId: string) { if (userId === otherUserId) { return { status: 'self' as const }; } const blocked = await this.isBlocked(userId, otherUserId); if (blocked) { return { status: 'blocked' as const }; } const friendship = await prisma.friendship.findFirst({ where: { OR: [ { userId, friendId: otherUserId }, { userId: otherUserId, friendId: userId }, ], }, }); if (!friendship) { return { status: 'none' as const }; } const direction = friendship.userId === userId ? 'sent' : 'received'; return { status: friendship.status as FriendshipStatus, friendshipId: friendship.id, direction, }; }, /** Get mutual friends between two users */ async getMutualFriends(userId: string, otherUserId: string) { // Get friend IDs for both users const [myFriends, theirFriends] = await Promise.all([ this.getFriendIds(userId), this.getFriendIds(otherUserId), ]); const mySet = new Set(myFriends); const mutualIds = theirFriends.filter((id) => mySet.has(id)); if (mutualIds.length === 0) { return { count: 0, users: [] }; } const users = await prisma.user.findMany({ where: { id: { in: mutualIds } }, select: FRIEND_SELECT, take: 10, }); return { count: mutualIds.length, users }; }, /** Get all accepted friend IDs for a user */ async getFriendIds(userId: string): Promise { const friendships = await prisma.friendship.findMany({ where: { OR: [ { userId, status: 'accepted' }, { friendId: userId, status: 'accepted' }, ], }, select: { userId: true, friendId: true }, }); return friendships.map((f) => (f.userId === userId ? f.friendId : f.userId)); }, /** Check if either user has blocked the other */ async isBlocked(userId: string, otherUserId: string): Promise { const block = await prisma.userBlock.findFirst({ where: { OR: [ { userId, blockedUserId: otherUserId }, { userId: otherUserId, blockedUserId: userId }, ], }, }); return !!block; }, };