changemaker.lite/api/src/modules/social/friendship.service.ts

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