"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.shiftsService = void 0; const bcryptjs_1 = __importDefault(require("bcryptjs")); const client_1 = require("@prisma/client"); const database_1 = require("../../../config/database"); const error_handler_1 = require("../../../middleware/error-handler"); const email_service_1 = require("../../../services/email.service"); const notification_queue_service_1 = require("../../../services/notification-queue.service"); const notification_helper_1 = require("../../../services/notification.helper"); const env_1 = require("../../../config/env"); const logger_1 = require("../../../utils/logger"); const metrics_1 = require("../../../utils/metrics"); const rocketchat_webhook_service_1 = require("../../../services/rocketchat-webhook.service"); const listmonk_event_sync_service_1 = require("../../../services/listmonk-event-sync.service"); const gancio_client_1 = require("../../../services/gancio.client"); const unified_calendar_service_1 = require("../../events/unified-calendar.service"); const group_service_1 = require("../../social/group.service"); const achievements_service_1 = require("../../social/achievements.service"); const slug_1 = require("../../../utils/slug"); const settings_service_1 = require("../../settings/settings.service"); const sms_notification_service_1 = require("../../../services/sms-notification.service"); const crypto_1 = __importDefault(require("crypto")); const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair']; const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk']; function generateReadablePassword() { const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const num = Math.floor(Math.random() * 90) + 10; return `${adj}${noun}${num}`; } const meetingSelect = { id: true, slug: true, title: true, isActive: true, jitsiRoom: true, }; exports.shiftsService = { async findAll(filters) { const { page, limit, search, status, upcoming, sortBy, sortOrder } = filters; const skip = (page - 1) * limit; const where = {}; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { location: { contains: search, mode: 'insensitive' } }, ]; } if (status) where.status = status; if (upcoming) { where.date = { gte: new Date() }; } const orderBy = sortBy === 'title' ? { title: sortOrder } : sortBy === 'createdAt' ? { createdAt: sortOrder } : { date: sortOrder }; const [shifts, total] = await Promise.all([ database_1.prisma.shift.findMany({ where, skip, take: limit, orderBy, include: { cut: { select: { id: true, name: true } }, meeting: { select: meetingSelect }, _count: { select: { signups: { where: { status: client_1.SignupStatus.CONFIRMED } }, }, }, }, }), database_1.prisma.shift.count({ where }), ]); return { shifts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id) { const shift = await database_1.prisma.shift.findUnique({ where: { id }, include: { cut: { select: { id: true, name: true } }, meeting: { select: meetingSelect }, signups: { where: { status: client_1.SignupStatus.CONFIRMED }, include: { user: { select: { id: true, email: true, name: true, phone: true } } }, orderBy: { signupDate: 'desc' }, }, _count: { select: { signups: { where: { status: client_1.SignupStatus.CONFIRMED } }, }, }, }, }); if (!shift) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } return shift; }, async create(data, userId) { const shift = await database_1.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, createdBy: userId, }, }); // Gancio event sync (fire-and-forget) if (gancio_client_1.gancioClient.enabled) { gancio_client_1.gancioClient.createEvent({ title: shift.title, description: shift.description, location: shift.location, date: shift.date, startTime: shift.startTime, endTime: shift.endTime, }).then(async (eventId) => { if (eventId) { await database_1.prisma.shift.update({ where: { id: shift.id }, data: { gancioEventId: eventId }, }); } }).catch((err) => { logger_1.logger.warn('Gancio sync on shift create failed:', err); }); } // Bust unified calendar cache unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { }); return shift; }, async update(id, data) { const existing = await database_1.prisma.shift.findUnique({ where: { id } }); if (!existing) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } const updateData = { ...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 === client_1.ShiftStatus.OPEN) { updateData.status = client_1.ShiftStatus.FULL; } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === client_1.ShiftStatus.FULL) { updateData.status = client_1.ShiftStatus.OPEN; } } const shift = await database_1.prisma.shift.update({ where: { id }, data: updateData, }); // Gancio event sync (fire-and-forget) if (gancio_client_1.gancioClient.enabled && shift.gancioEventId) { gancio_client_1.gancioClient.updateEvent(shift.gancioEventId, { title: shift.title, description: shift.description, location: shift.location, date: shift.date, startTime: shift.startTime, endTime: shift.endTime, }).catch((err) => { logger_1.logger.warn('Gancio sync on shift update failed:', err); }); } // Bust unified calendar cache unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { }); return shift; }, async delete(id) { const existing = await database_1.prisma.shift.findUnique({ where: { id } }); if (!existing) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } // Delete Gancio event before deleting shift (fire-and-forget) if (gancio_client_1.gancioClient.enabled && existing.gancioEventId) { gancio_client_1.gancioClient.deleteEvent(existing.gancioEventId).catch((err) => { logger_1.logger.warn('Gancio sync on shift delete failed:', err); }); } // Delete associated meeting if exists if (existing.meetingId) { await database_1.prisma.meeting.delete({ where: { id: existing.meetingId } }).catch(() => { }); } await database_1.prisma.shift.delete({ where: { id } }); // Bust unified calendar cache unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { }); }, async createMeetingForShift(shiftId, userId) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (shift.meetingId) throw new error_handler_1.AppError(400, 'Shift already has a meeting', 'MEETING_EXISTS'); const settings = await settings_service_1.siteSettingsService.get(); if (!settings.enableMeet) throw new error_handler_1.AppError(400, 'Video meetings are not enabled', 'MEET_DISABLED'); const meeting = await database_1.prisma.meeting.create({ data: { slug: (0, slug_1.generateSlug)(shift.title), title: `${shift.title} — Video Briefing`, jitsiRoom: crypto_1.default.randomUUID(), createdByUserId: userId, }, }); await database_1.prisma.shift.update({ where: { id: shiftId }, data: { meetingId: meeting.id }, }); return meeting; }, async removeMeetingFromShift(shiftId) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (!shift.meetingId) throw new error_handler_1.AppError(400, 'Shift has no meeting', 'NO_MEETING'); const meetingId = shift.meetingId; await database_1.prisma.shift.update({ where: { id: shiftId }, data: { meetingId: null }, }); // Delete the meeting record await database_1.prisma.meeting.delete({ where: { id: meetingId } }).catch(() => { }); }, async getStats() { const [total, open, full, cancelled, upcoming, totalSignups] = await Promise.all([ database_1.prisma.shift.count(), database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.OPEN } }), database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.FULL } }), database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.CANCELLED } }), database_1.prisma.shift.count({ where: { date: { gte: new Date() }, status: { not: client_1.ShiftStatus.CANCELLED } } }), database_1.prisma.shiftSignup.count({ where: { status: client_1.SignupStatus.CONFIRMED } }), ]); return { total, open, full, cancelled, upcoming, totalSignups }; }, async getSignups(shiftId) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } return database_1.prisma.shiftSignup.findMany({ where: { shiftId }, include: { user: { select: { id: true, email: true, name: true, phone: true } } }, orderBy: { signupDate: 'desc' }, }); }, async addSignup(shiftId, data) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } if (shift.currentVolunteers >= shift.maxVolunteers) { throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL'); } // Check unique constraint const existing = await database_1.prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: data.userEmail } }, }); if (existing) { if (existing.status === client_1.SignupStatus.CANCELLED) { // Re-activate cancelled signup const [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: existing.id }, data: { status: client_1.SignupStatus.CONFIRMED, signupSource: client_1.SignupSource.ADMIN }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined, }, }), ]); return signup; } throw new error_handler_1.AppError(409, 'Volunteer already signed up', 'DUPLICATE_SIGNUP'); } // Look up user const user = await database_1.prisma.user.findUnique({ where: { email: data.userEmail } }); const [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.create({ data: { shiftId, shiftTitle: shift.title, userId: user?.id, userEmail: data.userEmail, userName: data.userName || user?.name, signupSource: client_1.SignupSource.ADMIN, }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined, }, }), ]); // Listmonk event sync listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({ email: data.userEmail, name: data.userName || data.userEmail, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], }).catch(() => { }); // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { }); // Achievement check (fire-and-forget) if (user?.id) achievements_service_1.achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => { }); return signup; }, async removeSignup(signupId) { const signup = await database_1.prisma.shiftSignup.findUnique({ where: { id: signupId } }); if (!signup) { throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); } if (signup.status === client_1.SignupStatus.CANCELLED) { throw new error_handler_1.AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED'); } await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: signupId }, data: { status: client_1.SignupStatus.CANCELLED }, }), database_1.prisma.shift.update({ where: { id: signup.shiftId }, data: { currentVolunteers: { decrement: 1 }, status: client_1.ShiftStatus.OPEN, }, }), ]); // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(signup.shiftId).catch(() => { }); }, async publicSignup(shiftId, data) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) { throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); } if (!shift.isPublic) { throw new error_handler_1.AppError(403, 'This shift is not open for public signup', 'NOT_PUBLIC'); } if (shift.status !== client_1.ShiftStatus.OPEN) { throw new error_handler_1.AppError(400, 'This shift is not accepting signups', 'NOT_OPEN'); } if (shift.date < new Date(new Date().toISOString().split('T')[0])) { throw new error_handler_1.AppError(400, 'This shift has already passed', 'SHIFT_PAST'); } if (shift.currentVolunteers >= shift.maxVolunteers) { throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL'); } // Check unique constraint const existingSignup = await database_1.prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: data.email } }, }); if (existingSignup && existingSignup.status === client_1.SignupStatus.CONFIRMED) { throw new error_handler_1.AppError(409, 'You are already signed up for this shift', 'DUPLICATE_SIGNUP'); } // Look up existing user let user = await database_1.prisma.user.findUnique({ where: { email: data.email } }); let isNewUser = false; let tempPassword; if (!user) { // Create temp user tempPassword = generateReadablePassword(); const hashedPassword = await bcryptjs_1.default.hash(tempPassword, 12); const shiftDate = new Date(shift.date); shiftDate.setDate(shiftDate.getDate() + 1); user = await database_1.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; } // Create signup (or re-activate cancelled one) let signup; if (existingSignup && existingSignup.status === client_1.SignupStatus.CANCELLED) { [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: existingSignup.id }, data: { status: client_1.SignupStatus.CONFIRMED, signupSource: user ? client_1.SignupSource.AUTHENTICATED : client_1.SignupSource.PUBLIC, userName: data.name, userPhone: data.phone, userId: user.id, }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined, }, }), ]); } else { [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.create({ data: { shiftId, shiftTitle: shift.title, userId: user.id, userEmail: data.email, userName: data.name, userPhone: data.phone, signupSource: isNewUser ? client_1.SignupSource.PUBLIC : client_1.SignupSource.AUTHENTICATED, }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.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 email_service_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`, }); } catch (err) { logger_1.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', }); sms_notification_service_1.smsNotificationService.sendShiftSignupConfirmation(data.phone, { name: data.name, shiftTitle: shift.title, shiftDate: smsDateStr, shiftTime: `${shift.startTime} — ${shift.endTime}`, }).catch(err => logger_1.logger.error('SMS signup confirmation failed:', err)); } // Notify Rocket.Chat const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); rocketchat_webhook_service_1.rocketchatWebhookService.onShiftSignup({ userName: data.name || data.email, shiftTitle: shift.title, shiftDate: shiftDateStr, }).catch(() => { }); // Notification: admin shift signup alert try { if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftSignup')) { const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env_1.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 notification_queue_service_1.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_1.logger.error('Failed to enqueue admin shift signup notification:', err); } // Notification: schedule 24h pre-shift reminder try { if (await (0, notification_helper_1.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 notification_queue_service_1.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_1.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); sms_notification_service_1.smsNotificationService.sendShiftReminder(data.phone, { name: data.name, shiftTitle: shift.title, shiftTime: shift.startTime, shiftLocation: shift.location || 'TBD', }, smsShiftDatetime).catch(err => logger_1.logger.error('SMS shift reminder failed:', err)); } // Notification: schedule post-shift thank-you (2h after end) try { if (await (0, notification_helper_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notification_queue_service_1.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_1.logger.error('Failed to schedule shift thank-you:', err); } (0, metrics_1.recordShiftSignup)(); // Listmonk event sync listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({ email: data.email, name: data.name, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], }).catch(() => { }); // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { }); // Achievement check (fire-and-forget) if (user?.id) achievements_service_1.achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => { }); return { signup, isNewUser }; }, async cancelPublicSignup(shiftId, userEmail) { const signup = await database_1.prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail } }, }); if (!signup) { throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); } if (signup.status === client_1.SignupStatus.CANCELLED) { throw new error_handler_1.AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED'); } const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: signup.id }, data: { status: client_1.SignupStatus.CANCELLED }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { decrement: 1 }, status: client_1.ShiftStatus.OPEN, }, }), ]); // Notification: cancellation acknowledgement + cancel reminder try { if (shift && await (0, notification_helper_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notification_queue_service_1.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 notification_queue_service_1.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 notification_queue_service_1.notificationQueueService.cancelShiftThankYou(userEmail, shiftEndDatetime); } } catch (err) { logger_1.logger.error('Failed to enqueue cancellation notification:', err); } // Notify Rocket.Chat of cancellation if (shift) { const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); rocketchat_webhook_service_1.rocketchatWebhookService.onShiftCancellation({ userName: signup.userName || userEmail, shiftTitle: shift.title, shiftDate: shiftDateStr, }).catch(() => { }); } // Notification: admin shift cancellation alert try { if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftCancellation')) { const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env_1.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 notification_queue_service_1.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_1.logger.error('Failed to enqueue admin shift cancellation notification:', err); } // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { }); }, async getUpcomingForVolunteer(userId) { const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user) throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND'); const shifts = await database_1.prisma.shift.findMany({ where: { isPublic: true, status: { not: client_1.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 database_1.prisma.shiftSignup.findMany({ where: { userEmail: user.email, shiftId: { in: shifts.map((s) => s.id) }, status: client_1.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, userId) { const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, phone: true }, }); if (!user) throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND'); const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); if (!shift) throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); if (!shift.isPublic) throw new error_handler_1.AppError(403, 'This shift is not open for signup', 'NOT_PUBLIC'); if (shift.status === client_1.ShiftStatus.CANCELLED) throw new error_handler_1.AppError(400, 'This shift is cancelled', 'SHIFT_CANCELLED'); if (shift.date < new Date(new Date().toISOString().split('T')[0])) throw new error_handler_1.AppError(400, 'This shift has already passed', 'SHIFT_PAST'); if (shift.currentVolunteers >= shift.maxVolunteers) throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL'); const existing = await database_1.prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: user.email } }, }); if (existing && existing.status === client_1.SignupStatus.CONFIRMED) { throw new error_handler_1.AppError(409, 'Already signed up for this shift', 'DUPLICATE_SIGNUP'); } let signup; if (existing && existing.status === client_1.SignupStatus.CANCELLED) { [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: existing.id }, data: { status: client_1.SignupStatus.CONFIRMED, signupSource: client_1.SignupSource.AUTHENTICATED }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined, }, }), ]); } else { [signup] = await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.create({ data: { shiftId, shiftTitle: shift.title, userId: user.id, userEmail: user.email, userName: user.name, userPhone: user.phone, signupSource: client_1.SignupSource.AUTHENTICATED, }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { increment: 1 }, status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.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 email_service_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`, }); } catch (err) { logger_1.logger.error('Failed to send volunteer shift signup confirmation email:', err); } // Notify Rocket.Chat const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); rocketchat_webhook_service_1.rocketchatWebhookService.onShiftSignup({ userName: user.name || user.email, shiftTitle: shift.title, shiftDate: shiftDateStr, }).catch(() => { }); // Notification: admin shift signup alert try { if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftSignup')) { const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env_1.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 notification_queue_service_1.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_1.logger.error('Failed to enqueue admin shift signup notification:', err); } // Notification: schedule 24h pre-shift reminder try { if (await (0, notification_helper_1.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 notification_queue_service_1.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_1.logger.error('Failed to schedule shift reminder:', err); } // Notification: schedule post-shift thank-you (2h after end) try { if (await (0, notification_helper_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notification_queue_service_1.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_1.logger.error('Failed to schedule shift thank-you:', err); } // Listmonk event sync listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({ email: user.email, name: user.name || user.email, shiftTitle: shift.title, shiftDate: new Date(shift.date).toISOString().split('T')[0], }).catch(() => { }); // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { }); // Achievement check (fire-and-forget) achievements_service_1.achievementsService.checkAndUnlock(userId, ['shifts']).catch(() => { }); return signup; }, async cancelVolunteerSignup(shiftId, userId) { const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }); if (!user) throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND'); const signup = await database_1.prisma.shiftSignup.findUnique({ where: { shiftId_userEmail: { shiftId, userEmail: user.email } }, }); if (!signup) throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND'); if (signup.status === client_1.SignupStatus.CANCELLED) throw new error_handler_1.AppError(400, 'Already cancelled', 'ALREADY_CANCELLED'); const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } }); await database_1.prisma.$transaction([ database_1.prisma.shiftSignup.update({ where: { id: signup.id }, data: { status: client_1.SignupStatus.CANCELLED }, }), database_1.prisma.shift.update({ where: { id: shiftId }, data: { currentVolunteers: { decrement: 1 }, status: client_1.ShiftStatus.OPEN, }, }), ]); // Notification: cancellation acknowledgement + cancel reminder try { if (shift && await (0, notification_helper_1.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_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; await notification_queue_service_1.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 notification_queue_service_1.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 notification_queue_service_1.notificationQueueService.cancelShiftThankYou(user.email, shiftEndDatetime); } } catch (err) { logger_1.logger.error('Failed to enqueue cancellation notification:', err); } // Notify Rocket.Chat of cancellation if (shift) { const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); rocketchat_webhook_service_1.rocketchatWebhookService.onShiftCancellation({ userName: user.name || user.email, shiftTitle: shift.title, shiftDate: shiftDateStr, }).catch(() => { }); } // Notification: admin shift cancellation alert try { if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftCancellation')) { const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]); if (adminEmails.length > 0) { const adminUrl = `${env_1.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 notification_queue_service_1.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_1.logger.error('Failed to enqueue admin shift cancellation notification:', err); } // Social group sync (fire-and-forget) group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { }); }, async getMySignups(userId) { const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user) throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND'); const signups = await database_1.prisma.shiftSignup.findMany({ where: { userEmail: user.email, status: client_1.SignupStatus.CONFIRMED, shift: { date: { gte: new Date(new Date().toISOString().split('T')[0]) }, status: { not: client_1.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() { const shifts = await database_1.prisma.shift.findMany({ where: { isPublic: true, status: { not: client_1.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' }], }); return shifts; }, async emailShiftDetails(shiftId) { const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId }, include: { signups: { where: { status: client_1.SignupStatus.CONFIRMED }, }, }, }); if (!shift) { throw new error_handler_1.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 email_service_1.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_1.logger.error(`Failed to send shift details to ${signup.userEmail}:`, err); failed++; } } return { sent, failed }; }, async getCalendarData(startDate, endDate) { const shifts = await database_1.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 = {}; 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 }; }, }; //# sourceMappingURL=shifts.service.js.map