311 lines
9.4 KiB
TypeScript

import { prisma } from '../../config/database';
import type { SocialGroupType } from '@prisma/client';
import { generateSlug } from '../jitsi/jitsi.utils';
import { notificationService } from './notification.service';
const MEMBER_USER_SELECT = {
id: true,
name: true,
email: true,
role: true,
} as const;
const MEETING_SELECT = {
id: true,
slug: true,
isActive: true,
} as const;
export const groupService = {
/** Get or create a shift team group */
async getOrCreateShiftTeam(shiftId: string) {
const shift = await prisma.shift.findUnique({
where: { id: shiftId },
select: { id: true, title: true },
});
if (!shift) throw Object.assign(new Error('Shift not found'), { statusCode: 404 });
return this.getOrCreateGroup('SHIFT_TEAM', String(shift.id), `${shift.title} Team`);
},
/** Get or create a campaign team group */
async getOrCreateCampaignTeam(campaignId: string) {
const campaign = await prisma.campaign.findUnique({
where: { id: campaignId },
select: { id: true, title: true },
});
if (!campaign) throw Object.assign(new Error('Campaign not found'), { statusCode: 404 });
return this.getOrCreateGroup('CAMPAIGN_TEAM', campaign.id, `${campaign.title} Team`);
},
/** Generic get-or-create for a group */
async getOrCreateGroup(type: SocialGroupType, referenceId: string, name: string) {
let group = await prisma.socialGroup.findUnique({
where: { type_referenceId: { type, referenceId } },
include: {
members: {
include: { user: { select: MEMBER_USER_SELECT } },
orderBy: { joinedAt: 'asc' },
},
},
});
if (!group) {
group = await prisma.socialGroup.create({
data: { type, referenceId, name },
include: {
members: {
include: { user: { select: MEMBER_USER_SELECT } },
orderBy: { joinedAt: 'asc' },
},
},
});
}
return group;
},
/** Sync shift team membership from actual signups */
async syncShiftTeam(shiftId: string) {
// Get confirmed signups with user IDs
const signups = await prisma.shiftSignup.findMany({
where: { shiftId, status: 'CONFIRMED', userId: { not: null } },
select: { userId: true },
});
const userIds = signups.map((s) => s.userId).filter(Boolean) as string[];
if (userIds.length < 2) return; // Only create team if 2+ members
const group = await this.getOrCreateShiftTeam(shiftId);
// Get existing member IDs
const existingIds = new Set(group.members.map((m) => m.userId));
// Add new members
const newUserIds = userIds.filter((id) => !existingIds.has(id));
if (newUserIds.length > 0) {
await prisma.socialGroupMember.createMany({
data: newUserIds.map((userId) => ({
groupId: group.id,
userId,
})),
skipDuplicates: true,
});
}
// Remove members who cancelled
const currentIds = new Set(userIds);
const removedIds = group.members
.map((m) => m.userId)
.filter((id) => !currentIds.has(id));
if (removedIds.length > 0) {
await prisma.socialGroupMember.deleteMany({
where: {
groupId: group.id,
userId: { in: removedIds },
},
});
}
},
/** Sync campaign team membership from email participants */
async syncCampaignTeam(campaignId: string) {
const emails = await prisma.campaignEmail.findMany({
where: { campaignId, userId: { not: null } },
distinct: ['userId'],
select: { userId: true },
});
const userIds = emails.map((e) => e.userId).filter(Boolean) as string[];
if (userIds.length < 2) return;
const group = await this.getOrCreateCampaignTeam(campaignId);
const existingIds = new Set(group.members.map((m) => m.userId));
const newUserIds = userIds.filter((id) => !existingIds.has(id));
if (newUserIds.length > 0) {
await prisma.socialGroupMember.createMany({
data: newUserIds.map((userId) => ({
groupId: group.id,
userId,
})),
skipDuplicates: true,
});
}
},
/** List all groups a user belongs to */
async listMyGroups(userId: string, page: number, limit: number) {
const skip = (page - 1) * limit;
const [memberships, total] = await Promise.all([
prisma.socialGroupMember.findMany({
where: { userId },
include: {
group: {
include: {
_count: { select: { members: true } },
meeting: { select: MEETING_SELECT },
},
},
},
orderBy: { joinedAt: 'desc' },
skip,
take: limit,
}),
prisma.socialGroupMember.count({ where: { userId } }),
]);
return {
groups: memberships.map((m) => ({
id: m.group.id,
name: m.group.name,
type: m.group.type,
referenceId: m.group.referenceId,
memberCount: m.group._count.members,
joinedAt: m.joinedAt,
hasActiveCall: m.group.meeting?.isActive === true,
})),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Get group details with members */
async getGroupDetail(groupId: string) {
const group = await prisma.socialGroup.findUnique({
where: { id: groupId },
include: {
members: {
include: { user: { select: MEMBER_USER_SELECT } },
orderBy: { joinedAt: 'asc' },
},
meeting: { select: MEETING_SELECT },
},
});
if (!group) throw Object.assign(new Error('Group not found'), { statusCode: 404 });
return {
id: group.id,
name: group.name,
type: group.type,
referenceId: group.referenceId,
createdAt: group.createdAt,
members: group.members.map((m) => ({
id: m.id,
userId: m.userId,
joinedAt: m.joinedAt,
user: m.user,
})),
memberCount: group.members.length,
meeting: group.meeting,
};
},
/** Start a group call — create or reactivate meeting */
async startGroupCall(groupId: string, userId: string) {
const group = await prisma.socialGroup.findUnique({
where: { id: groupId },
include: {
members: { select: { userId: true } },
meeting: true,
},
});
if (!group) throw Object.assign(new Error('Group not found'), { statusCode: 404 });
// Verify membership
const isMember = group.members.some((m) => m.userId === userId);
if (!isMember) throw Object.assign(new Error('Not a member of this group'), { statusCode: 403 });
let meeting = group.meeting;
if (meeting) {
// Reactivate existing meeting
meeting = await prisma.meeting.update({
where: { id: meeting.id },
data: { isActive: true },
});
} else {
// Create new meeting and link to group
const slug = generateSlug(`${group.name} call`);
meeting = await prisma.meeting.create({
data: {
title: `${group.name} — Group Call`,
slug,
jitsiRoom: slug,
isActive: true,
createdByUserId: userId,
},
});
await prisma.socialGroup.update({
where: { id: groupId },
data: { meetingId: meeting.id },
});
}
// Notify all other group members
const otherMembers = group.members.filter((m) => m.userId !== userId);
const caller = await prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } });
const callerName = caller?.name || caller?.email || 'A member';
await Promise.allSettled(
otherMembers.map((m) =>
notificationService.createNotification(
m.userId,
'group_call',
`${group.name} — Group Call`,
`${callerName} started a call in ${group.name}`,
{ groupId, meetingSlug: meeting!.slug },
)
)
);
return { id: meeting.id, slug: meeting.slug, isActive: meeting.isActive };
},
/** End a group call — deactivate meeting */
async endGroupCall(groupId: string, userId: string) {
const group = await prisma.socialGroup.findUnique({
where: { id: groupId },
include: {
members: { select: { userId: true } },
meeting: true,
},
});
if (!group) throw Object.assign(new Error('Group not found'), { statusCode: 404 });
const isMember = group.members.some((m) => m.userId === userId);
if (!isMember) throw Object.assign(new Error('Not a member of this group'), { statusCode: 403 });
if (!group.meeting) throw Object.assign(new Error('No active call'), { statusCode: 404 });
await prisma.meeting.update({
where: { id: group.meeting.id },
data: { isActive: false },
});
return { success: true };
},
/** Verify membership and active call, return meeting slug for token generation */
async getCallMeeting(groupId: string, userId: string) {
const group = await prisma.socialGroup.findUnique({
where: { id: groupId },
include: {
members: { select: { userId: true } },
meeting: true,
},
});
if (!group) throw Object.assign(new Error('Group not found'), { statusCode: 404 });
const isMember = group.members.some((m) => m.userId === userId);
if (!isMember) throw Object.assign(new Error('Not a member of this group'), { statusCode: 403 });
if (!group.meeting?.isActive) throw Object.assign(new Error('No active call'), { statusCode: 404 });
return group.meeting;
},
};