import bcrypt from 'bcryptjs'; import { Prisma, ShiftStatus, SignupStatus, SignupSource, UserRole } from '@prisma/client'; import { prisma } from '../../../config/database'; import { AppError } from '../../../middleware/error-handler'; import { emailService } from '../../../services/email.service'; import { notificationQueueService } from '../../../services/notification-queue.service'; import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper'; import { env } from '../../../config/env'; import { logger } from '../../../utils/logger'; import { recordShiftSignup } from '../../../utils/metrics'; import { eventBus } from '../../../services/event-bus.service'; import { unifiedCalendarService } from '../../events/unified-calendar.service'; import { groupService } from '../../social/group.service'; import { achievementsService } from '../../social/achievements.service'; import { generateSlug } from '../../../utils/slug'; import { siteSettingsService } from '../../settings/settings.service'; import { smsNotificationService } from '../../../services/sms-notification.service'; import crypto from 'crypto'; import type { CreateShiftInput, UpdateShiftInput, ListShiftsInput, AddSignupInput, PublicSignupInput, } from './shifts.schemas'; function generateReadablePassword(): string { // Generate a cryptographically strong random password (128 bits of entropy) return crypto.randomBytes(16).toString('base64url'); } const meetingSelect = { id: true, slug: true, title: true, isActive: true, jitsiRoom: true, } as const; export const shiftsService = { async findAll(filters: ListShiftsInput) { const { page, limit, search, status, kind, upcoming, sortBy, sortOrder } = filters; const skip = (page - 1) * limit; const where: Prisma.ShiftWhereInput = {}; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { location: { contains: search, mode: 'insensitive' } }, ]; } if (status) where.status = status; if (kind) where.kind = kind; if (upcoming) { where.date = { gte: new Date() }; } const orderBy: Prisma.ShiftOrderByWithRelationInput = sortBy === 'title' ? { title: sortOrder } : sortBy === 'createdAt' ? { createdAt: sortOrder } : { date: sortOrder }; const [shifts, total] = await Promise.all([ prisma.shift.findMany({ where, skip, take: limit, orderBy, include: { cut: { select: { id: true, name: true } }, meeting: { select: meetingSelect }, _count: { select: { signups: { where: { status: SignupStatus.CONFIRMED } }, }, }, }, }), prisma.shift.count({ where }), ]); return { shifts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id: string) { const shift = await prisma.shift.findUnique({ where: { id }, include: { cut: { select: { id: true, name: true } }, meeting: { select: meetingSelect }, signups: { where: { status: SignupStatus.CONFIRMED }, include: { user: { select: { id: true, email: true, name: true, phone: true } } }, orderBy: { signupDate: 'desc' }, }, _count: { select: { signups: { where: { status: SignupStatus.CONFIRMED } }, }, }, }, }); if (!shift) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } return shift; }, async create(data: CreateShiftInput, userId: string) { const shift = await prisma.shift.create({ data: { title: data.title, description: data.description, date: new Date(data.date), startTime: data.startTime, endTime: data.endTime, location: data.location, maxVolunteers: data.maxVolunteers, isPublic: data.isPublic, cutId: data.cutId, kind: data.kind, createdBy: userId, }, }); // Publish shift.created event (listeners: Gancio, Calendar, n8n) eventBus.publish('shift.created', { shiftId: shift.id, title: shift.title, date: new Date(shift.date).toISOString().split('T')[0], startTime: shift.startTime, endTime: shift.endTime, cutId: shift.cutId, cutName: null, createdByUserId: userId, }); // Bust unified calendar cache unifiedCalendarService.bustCache().catch(() => {}); return shift; }, async update(id: string, data: UpdateShiftInput) { const existing = await prisma.shift.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } const updateData: Prisma.ShiftUncheckedUpdateInput = { ...data }; if (data.date) { updateData.date = new Date(data.date); } // Auto-update status when capacity changes if (data.maxVolunteers !== undefined) { if (existing.currentVolunteers >= data.maxVolunteers && existing.status === ShiftStatus.OPEN) { updateData.status = ShiftStatus.FULL; } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === ShiftStatus.FULL) { updateData.status = ShiftStatus.OPEN; } } const shift = await prisma.shift.update({ where: { id }, data: updateData, }); // Publish shift.updated event (listeners: Gancio, Calendar, n8n) eventBus.publish('shift.updated', { shiftId: shift.id, title: shift.title, date: new Date(shift.date).toISOString().split('T')[0], startTime: shift.startTime, endTime: shift.endTime, cutId: shift.cutId, cutName: null, changes: Object.keys(data), }); // Bust unified calendar cache unifiedCalendarService.bustCache().catch(() => {}); return shift; }, async delete(id: string) { const existing = await prisma.shift.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } // Publish shift.deleted event (listeners: Gancio, Calendar, n8n) eventBus.publish('shift.deleted', { shiftId: id, title: existing.title, date: new Date(existing.date).toISOString().split('T')[0], }); // Delete associated meeting if exists if (existing.meetingId) { await prisma.meeting.delete({ where: { id: existing.meetingId } }).catch(() => {}); } await prisma.shift.delete({ where: { id } }); // Bust unified calendar cache unifiedCalendarService.bustCache().catch(() => {}); }, async createMeetingForShift(shiftId: string, userId: string) { const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (shift.meetingId) throw new AppError(400, 'Shift already has a meeting', 'MEETING_EXISTS'); const settings = await siteSettingsService.get(); if (!settings.enableMeet) throw new AppError(400, 'Video meetings are not enabled', 'MEET_DISABLED'); const meeting = await prisma.meeting.create({ data: { slug: generateSlug(shift.title), title: `${shift.title} — Video Briefing`, jitsiRoom: crypto.randomUUID(), createdByUserId: userId, }, }); await prisma.shift.update({ where: { id: shiftId }, data: { meetingId: meeting.id }, }); return meeting; }, async removeMeetingFromShift(shiftId: string) { const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (!shift.meetingId) throw new AppError(400, 'Shift has no meeting', 'NO_MEETING'); const meetingId = shift.meetingId; await prisma.shift.update({ where: { id: shiftId }, data: { meetingId: null }, }); // Delete the meeting record await prisma.meeting.delete({ where: { id: meetingId } }).catch(() => {}); }, async getStats() { const [total, open, full, cancelled, upcoming, totalSignups] = await Promise.all([ prisma.shift.count(), prisma.shift.count({ where: { status: ShiftStatus.OPEN } }), prisma.shift.count({ where: { status: ShiftStatus.FULL } }), prisma.shift.count({ where: { status: ShiftStatus.CANCELLED } }), prisma.shift.count({ where: { date: { gte: new Date() }, status: { not: ShiftStatus.CANCELLED } } }), prisma.shiftSignup.count({ where: { status: SignupStatus.CONFIRMED } }), ]); return { total, open, full, cancelled, upcoming, totalSignups }; }, async getSignups(shiftId: string) { const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } return prisma.shiftSignup.findMany({ where: { shiftId }, include: { user: { select: { id: true, email: true, name: true, phone: true } } }, orderBy: { signupDate: 'desc' }, }); }, async addSignup(shiftId: string, data: AddSignupInput) { const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } if (shift.currentVolunteers >= shift.maxVolunteers) { throw new AppError(400, 'Shift is full', 'SHIFT_FULL'); } // Check unique constraint const existing = await prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: data.userEmail } }, }); if (existing) { if (existing.status === SignupStatus.CANCELLED) { // Re-activate cancelled signup const [signup] = await prisma.$transaction([ prisma.shiftSignup.update({ where: { id: existing.id }, data: { status: SignupStatus.CONFIRMED, signupSource: SignupSource.ADMIN }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined, }, }), ]); return signup; } throw new AppError(409, 'Volunteer already signed up', 'DUPLICATE_SIGNUP'); } // Look up user const user = await prisma.user.findUnique({ where: { email: data.userEmail } }); const [signup] = await prisma.$transaction([ prisma.shiftSignup.create({ data: { shiftId, shiftTitle: shift.title, userId: user?.id, userEmail: data.userEmail, userName: data.userName || user?.name, signupSource: SignupSource.ADMIN, }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined, }, }), ]); // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n) eventBus.publish('shift.signup.created', { shiftId, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], userName: data.userName || data.userEmail, userEmail: data.userEmail, userId: user?.id ?? null, cutName: null, signupType: 'admin', }); // Social group sync (fire-and-forget) groupService.syncShiftTeam(shiftId).catch(() => {}); // Achievement check (fire-and-forget) if (user?.id) achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => {}); return signup; }, async removeSignup(signupId: string) { const signup = await prisma.shiftSignup.findUnique({ where: { id: signupId } }); if (!signup) { throw new AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); } if (signup.status === SignupStatus.CANCELLED) { throw new AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED'); } await prisma.$transaction([ prisma.shiftSignup.update({ where: { id: signupId }, data: { status: SignupStatus.CANCELLED }, }), prisma.shift.update({ where: { id: signup.shiftId }, data: { currentVolunteers: { decrement: 1 }, status: ShiftStatus.OPEN, }, }), ]); // Social group sync (fire-and-forget) groupService.syncShiftTeam(signup.shiftId).catch(() => {}); }, async publicSignup(shiftId: string, data: PublicSignupInput) { const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } if (!shift.isPublic) { throw new AppError(403, 'This shift is not open for public signup', 'NOT_PUBLIC'); } if (shift.status !== ShiftStatus.OPEN) { throw new AppError(400, 'This shift is not accepting signups', 'NOT_OPEN'); } if (shift.date < new Date(new Date().toISOString().split('T')[0])) { throw new AppError(400, 'This shift has already passed', 'SHIFT_PAST'); } // Pre-check capacity (definitive check is inside transaction below) if (shift.currentVolunteers >= shift.maxVolunteers) { throw new AppError(400, 'Shift is full', 'SHIFT_FULL'); } // Check unique constraint const existingSignup = await prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: data.email } }, }); if (existingSignup && existingSignup.status === SignupStatus.CONFIRMED) { throw new AppError(409, 'You are already signed up for this shift', 'DUPLICATE_SIGNUP'); } // Look up existing user let user = await prisma.user.findUnique({ where: { email: data.email } }); let isNewUser = false; let tempPassword: string | undefined; if (!user) { // Create temp user tempPassword = generateReadablePassword(); const hashedPassword = await bcrypt.hash(tempPassword, 12); const shiftDate = new Date(shift.date); shiftDate.setDate(shiftDate.getDate() + 1); user = await prisma.user.create({ data: { email: data.email, password: hashedPassword, name: data.name, phone: data.phone, role: 'TEMP', roles: JSON.parse(JSON.stringify(['TEMP'])), createdVia: 'PUBLIC_SHIFT_SIGNUP', expiresAt: shiftDate, }, }); isNewUser = true; } // Atomic signup + capacity check inside transaction to prevent TOCTOU race const signup = await prisma.$transaction(async (tx) => { // Re-check capacity atomically inside the transaction const currentShift = await tx.shift.findUnique({ where: { id: shiftId } }); if (!currentShift || currentShift.currentVolunteers >= currentShift.maxVolunteers) { throw new AppError(400, 'Shift is full', 'SHIFT_FULL'); } let created; if (existingSignup && existingSignup.status === SignupStatus.CANCELLED) { created = await tx.shiftSignup.update({ where: { id: existingSignup.id }, data: { status: SignupStatus.CONFIRMED, signupSource: user ? SignupSource.AUTHENTICATED : SignupSource.PUBLIC, userName: data.name, userPhone: data.phone, userId: user!.id, }, }); } else { created = await tx.shiftSignup.create({ data: { shiftId, shiftTitle: currentShift.title, userId: user!.id, userEmail: data.email, userName: data.name, userPhone: data.phone, signupSource: isNewUser ? SignupSource.PUBLIC : SignupSource.AUTHENTICATED, }, }); } await tx.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: currentShift.currentVolunteers + 1 >= currentShift.maxVolunteers ? ShiftStatus.FULL : undefined, }, }); return created; }); // Send confirmation email try { const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); await emailService.sendShiftSignupConfirmation({ recipientEmail: data.email, recipientName: data.name, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, shiftLocation: shift.location || 'TBD', isNewUser, tempPassword, loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`, }); } catch (err) { logger.error('Failed to send shift signup confirmation email:', err); } // SMS signup confirmation (fire-and-forget) if (data.phone) { const shiftDate = new Date(shift.date); const smsDateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); smsNotificationService.sendShiftSignupConfirmation(data.phone, { name: data.name, shiftTitle: shift.title, shiftDate: smsDateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, }).catch(err => logger.error('SMS signup confirmation failed:', err)); } // Notification: admin shift signup alert try { if (await isNotificationEnabled('notifyAdminShiftSignup')) { const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`; const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); await notificationQueueService.enqueue({ type: 'admin-shift-signup', adminEmails, shiftTitle: shift.title, shiftDate: dateStr, volunteerName: data.name, volunteerEmail: data.email, signupSource: 'Public Form', adminUrl, }); } } } catch (err) { logger.error('Failed to enqueue admin shift signup notification:', err); } // Notification: schedule 24h pre-shift reminder try { if (await isNotificationEnabled('notifyVolunteerShiftReminder')) { const shiftDatetime = new Date(shift.date); const [startH, startM] = shift.startTime.split(':').map(Number); shiftDatetime.setHours(startH || 0, startM || 0, 0, 0); await notificationQueueService.scheduleShiftReminder({ type: 'volunteer-shift-reminder', recipientEmail: data.email, recipientName: data.name, shiftTitle: shift.title, shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }), shiftStartTime: shift.startTime, shiftEndTime: shift.endTime, shiftLocation: shift.location || 'TBD', shiftDescription: shift.description || '', currentVolunteers: shift.currentVolunteers + 1, maxVolunteers: shift.maxVolunteers, shiftStatus: shift.status, }, shiftDatetime); } } catch (err) { logger.error('Failed to schedule shift reminder:', err); } // SMS shift reminder (fire-and-forget, delay calculated by notification service) if (data.phone) { const smsShiftDatetime = new Date(shift.date); const [smsH, smsM] = shift.startTime.split(':').map(Number); smsShiftDatetime.setHours(smsH || 0, smsM || 0, 0, 0); smsNotificationService.sendShiftReminder(data.phone, { name: data.name, shiftTitle: shift.title, shiftTime: shift.startTime, shiftLocation: shift.location || 'TBD', }, smsShiftDatetime).catch(err => logger.error('SMS shift reminder failed:', err)); } // Notification: schedule post-shift thank-you (2h after end) try { if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) { const shiftEndDatetime = new Date(shift.date); const [endH, endM] = shift.endTime.split(':').map(Number); shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0); const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notificationQueueService.scheduleShiftThankYou({ type: 'volunteer-shift-thank-you', volunteerEmail: data.email, volunteerName: data.name, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, shiftLocation: shift.location || 'TBD', signupUrl, }, shiftEndDatetime); } } catch (err) { logger.error('Failed to schedule shift thank-you:', err); } recordShiftSignup(); // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n) eventBus.publish('shift.signup.created', { shiftId, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], userName: data.name || data.email, userEmail: data.email, userId: user?.id ?? null, cutName: null, signupType: 'public', }); // Social group sync (fire-and-forget) groupService.syncShiftTeam(shiftId).catch(() => {}); // Achievement check (fire-and-forget) if (user?.id) achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => {}); return { signup, isNewUser }; }, async cancelPublicSignup(shiftId: string, userEmail: string) { const signup = await prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail } }, }); if (!signup) { throw new AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); } if (signup.status === SignupStatus.CANCELLED) { throw new AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED'); } const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); await prisma.$transaction([ prisma.shiftSignup.update({ where: { id: signup.id }, data: { status: SignupStatus.CANCELLED }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { decrement: 1 }, status: ShiftStatus.OPEN, }, }), ]); // Notification: cancellation acknowledgement + cancel reminder try { if (shift && await isNotificationEnabled('notifyVolunteerCancellation')) { const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notificationQueueService.enqueue({ type: 'volunteer-cancellation', volunteerEmail: userEmail, volunteerName: signup.userName || userEmail, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, signupUrl, }); } // Cancel the pending shift reminder if (shift) { const shiftDatetime = new Date(shift.date); const [startH, startM] = shift.startTime.split(':').map(Number); shiftDatetime.setHours(startH || 0, startM || 0, 0, 0); await notificationQueueService.cancelShiftReminder(userEmail, shiftDatetime); } // Cancel the pending shift thank-you if (shift) { const shiftEndDatetime = new Date(shift.date); const [endH, endM] = shift.endTime.split(':').map(Number); shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0); await notificationQueueService.cancelShiftThankYou(userEmail, shiftEndDatetime); } } catch (err) { logger.error('Failed to enqueue cancellation notification:', err); } // Publish shift.signup.cancelled event (listeners: RC, n8n) if (shift) { eventBus.publish('shift.signup.cancelled', { shiftId, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], userName: signup.userName || userEmail, userEmail, signupType: 'public', }); } // Notification: admin shift cancellation alert try { if (shift && await isNotificationEnabled('notifyAdminShiftCancellation')) { const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`; const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); await notificationQueueService.enqueue({ type: 'admin-shift-cancellation', adminEmails, shiftTitle: shift.title, shiftDate: dateStr, volunteerName: signup.userName || userEmail, volunteerEmail: userEmail, cancellationSource: 'Public Form', adminUrl, }); } } } catch (err) { logger.error('Failed to enqueue admin shift cancellation notification:', err); } // Social group sync (fire-and-forget) groupService.syncShiftTeam(shiftId).catch(() => {}); }, async getUpcomingForVolunteer(userId: string) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); const shifts = await prisma.shift.findMany({ where: { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) }, }, select: { id: true, title: true, description: true, date: true, startTime: true, endTime: true, location: true, maxVolunteers: true, currentVolunteers: true, status: true, meeting: { select: { id: true, slug: true, isActive: true } }, }, orderBy: [{ date: 'asc' }, { startTime: 'asc' }], }); // Check signup status for each shift const signups = await prisma.shiftSignup.findMany({ where: { userEmail: user.email, shiftId: { in: shifts.map((s) => s.id) }, status: SignupStatus.CONFIRMED, }, select: { shiftId: true }, }); const signedUpSet = new Set(signups.map((s) => s.shiftId)); return shifts.map((s) => ({ ...s, isSignedUp: signedUpSet.has(s.id), })); }, async volunteerSignup(shiftId: string, userId: string) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, phone: true }, }); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (!shift.isPublic) throw new AppError(403, 'This shift is not open for signup', 'NOT_PUBLIC'); if (shift.status === ShiftStatus.CANCELLED) throw new AppError(400, 'This shift is cancelled', 'SHIFT_CANCELLED'); if (shift.date < new Date(new Date().toISOString().split('T')[0])) throw new AppError(400, 'This shift has already passed', 'SHIFT_PAST'); if (shift.currentVolunteers >= shift.maxVolunteers) throw new AppError(400, 'Shift is full', 'SHIFT_FULL'); const existing = await prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: user.email } }, }); if (existing && existing.status === SignupStatus.CONFIRMED) { throw new AppError(409, 'Already signed up for this shift', 'DUPLICATE_SIGNUP'); } let signup; if (existing && existing.status === SignupStatus.CANCELLED) { [signup] = await prisma.$transaction([ prisma.shiftSignup.update({ where: { id: existing.id }, data: { status: SignupStatus.CONFIRMED, signupSource: SignupSource.AUTHENTICATED }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined, }, }), ]); } else { [signup] = await prisma.$transaction([ prisma.shiftSignup.create({ data: { shiftId, shiftTitle: shift.title, userId: user.id, userEmail: user.email, userName: user.name, userPhone: user.phone, signupSource: SignupSource.AUTHENTICATED, }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined, }, }), ]); } // Send confirmation email try { const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); await emailService.sendShiftSignupConfirmation({ recipientEmail: user.email, recipientName: user.name || user.email, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, shiftLocation: shift.location || 'TBD', isNewUser: false, loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`, }); } catch (err) { logger.error('Failed to send volunteer shift signup confirmation email:', err); } // Notification: admin shift signup alert try { if (await isNotificationEnabled('notifyAdminShiftSignup')) { const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`; const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); await notificationQueueService.enqueue({ type: 'admin-shift-signup', adminEmails, shiftTitle: shift.title, shiftDate: dateStr, volunteerName: user.name || user.email, volunteerEmail: user.email, signupSource: 'Authenticated Volunteer', adminUrl, }); } } } catch (err) { logger.error('Failed to enqueue admin shift signup notification:', err); } // Notification: schedule 24h pre-shift reminder try { if (await isNotificationEnabled('notifyVolunteerShiftReminder')) { const shiftDatetime = new Date(shift.date); const [startH, startM] = shift.startTime.split(':').map(Number); shiftDatetime.setHours(startH || 0, startM || 0, 0, 0); await notificationQueueService.scheduleShiftReminder({ type: 'volunteer-shift-reminder', recipientEmail: user.email, recipientName: user.name || user.email, shiftTitle: shift.title, shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }), shiftStartTime: shift.startTime, shiftEndTime: shift.endTime, shiftLocation: shift.location || 'TBD', shiftDescription: shift.description || '', currentVolunteers: shift.currentVolunteers + 1, maxVolunteers: shift.maxVolunteers, shiftStatus: shift.status, }, shiftDatetime); } } catch (err) { logger.error('Failed to schedule shift reminder:', err); } // Notification: schedule post-shift thank-you (2h after end) try { if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) { const shiftEndDatetime = new Date(shift.date); const [endH, endM] = shift.endTime.split(':').map(Number); shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0); const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notificationQueueService.scheduleShiftThankYou({ type: 'volunteer-shift-thank-you', volunteerEmail: user.email, volunteerName: user.name || user.email, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, shiftLocation: shift.location || 'TBD', signupUrl, }, shiftEndDatetime); } } catch (err) { logger.error('Failed to schedule shift thank-you:', err); } // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n) eventBus.publish('shift.signup.created', { shiftId, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], userName: user.name || user.email, userEmail: user.email, userId, cutName: null, signupType: 'volunteer', }); // Social group sync (fire-and-forget) groupService.syncShiftTeam(shiftId).catch(() => {}); // Achievement check (fire-and-forget) achievementsService.checkAndUnlock(userId, ['shifts']).catch(() => {}); return signup; }, async cancelVolunteerSignup(shiftId: string, userId: string) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); const signup = await prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: user.email } }, }); if (!signup) throw new AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); if (signup.status === SignupStatus.CANCELLED) throw new AppError(400, 'Already cancelled', 'ALREADY_CANCELLED'); const shift = await prisma.shift.findUnique({ where: { id: shiftId } }); await prisma.$transaction([ prisma.shiftSignup.update({ where: { id: signup.id }, data: { status: SignupStatus.CANCELLED }, }), prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { decrement: 1 }, status: ShiftStatus.OPEN, }, }), ]); // Notification: cancellation acknowledgement + cancel reminder try { if (shift && await isNotificationEnabled('notifyVolunteerCancellation')) { const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notificationQueueService.enqueue({ type: 'volunteer-cancellation', volunteerEmail: user.email, volunteerName: user.name || user.email, shiftTitle: shift.title, shiftDate: dateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, signupUrl, }); } // Cancel the pending shift reminder if (shift) { const shiftDatetime = new Date(shift.date); const [startH, startM] = shift.startTime.split(':').map(Number); shiftDatetime.setHours(startH || 0, startM || 0, 0, 0); await notificationQueueService.cancelShiftReminder(user.email, shiftDatetime); } // Cancel the pending shift thank-you if (shift) { const shiftEndDatetime = new Date(shift.date); const [endH, endM] = shift.endTime.split(':').map(Number); shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0); await notificationQueueService.cancelShiftThankYou(user.email, shiftEndDatetime); } } catch (err) { logger.error('Failed to enqueue cancellation notification:', err); } // Publish shift.signup.cancelled event (listeners: RC, n8n) if (shift) { eventBus.publish('shift.signup.cancelled', { shiftId, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], userName: user.name || user.email, userEmail: user.email, signupType: 'volunteer', }); } // Notification: admin shift cancellation alert try { if (shift && await isNotificationEnabled('notifyAdminShiftCancellation')) { const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`; const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); await notificationQueueService.enqueue({ type: 'admin-shift-cancellation', adminEmails, shiftTitle: shift.title, shiftDate: dateStr, volunteerName: user.name || user.email, volunteerEmail: user.email, cancellationSource: 'Volunteer Portal', adminUrl, }); } } } catch (err) { logger.error('Failed to enqueue admin shift cancellation notification:', err); } // Social group sync (fire-and-forget) groupService.syncShiftTeam(shiftId).catch(() => {}); }, async getMySignups(userId: string) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); const signups = await prisma.shiftSignup.findMany({ where: { userEmail: user.email, status: SignupStatus.CONFIRMED, shift: { date: { gte: new Date(new Date().toISOString().split('T')[0]) }, status: { not: ShiftStatus.CANCELLED }, }, }, include: { shift: { select: { id: true, title: true, description: true, date: true, startTime: true, endTime: true, location: true, maxVolunteers: true, currentVolunteers: true, status: true, meeting: { select: { id: true, slug: true, isActive: true } }, }, }, }, orderBy: { shift: { date: 'asc' } }, }); return signups; }, async getPublicShifts(page: number = 1, limit: number = 20) { const where = { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) }, }; const skip = (page - 1) * limit; const [shifts, total] = await Promise.all([ prisma.shift.findMany({ where, select: { id: true, title: true, description: true, date: true, startTime: true, endTime: true, location: true, maxVolunteers: true, currentVolunteers: true, status: true, meeting: { select: { id: true, slug: true, isActive: true } }, }, orderBy: [{ date: 'asc' }, { startTime: 'asc' }], skip, take: limit, }), prisma.shift.count({ where: { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) } } }), ]); return { shifts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, async emailShiftDetails(shiftId: string) { const shift = await prisma.shift.findUnique({ where: { id: shiftId }, include: { signups: { where: { status: SignupStatus.CONFIRMED }, }, }, }); if (!shift) { throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } const shiftDate = new Date(shift.date); const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); let sent = 0; let failed = 0; for (const signup of shift.signups) { try { const result = await emailService.sendShiftDetailsEmail({ recipientEmail: signup.userEmail, recipientName: signup.userName || signup.userEmail, shiftTitle: shift.title, shiftDate: dateStr, shiftStartTime: shift.startTime, shiftEndTime: shift.endTime, shiftLocation: shift.location || 'TBD', shiftDescription: shift.description || '', currentVolunteers: shift.currentVolunteers, maxVolunteers: shift.maxVolunteers, shiftStatus: shift.status, }); if (result.success) { sent++; } else { failed++; } } catch (err) { logger.error(`Failed to send shift details to ${signup.userEmail}:`, err); failed++; } } return { sent, failed }; }, async getCalendarData(startDate: string, endDate: string) { const shifts = await prisma.shift.findMany({ where: { date: { gte: new Date(startDate), lte: new Date(endDate), }, }, include: { cut: { select: { id: true, name: true } }, signups: true, series: { select: { id: true, frequency: true } }, }, orderBy: { startTime: 'asc' }, }); // Group by date const dateMap: Record = {}; for (const shift of shifts) { const dateKey = shift.date.toISOString().split('T')[0]; if (!dateMap[dateKey]) { dateMap[dateKey] = { count: 0, shifts: [] }; } dateMap[dateKey].count++; dateMap[dateKey].shifts.push(shift); } return { dates: dateMap }; }, };