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