372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
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<string[]> {
|
|
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<boolean> {
|
|
const block = await prisma.userBlock.findFirst({
|
|
where: {
|
|
OR: [
|
|
{ userId, blockedUserId: otherUserId },
|
|
{ userId: otherUserId, blockedUserId: userId },
|
|
],
|
|
},
|
|
});
|
|
return !!block;
|
|
},
|
|
};
|