311 lines
9.4 KiB
TypeScript
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;
|
|
},
|
|
};
|