"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 settings_service_1 = require("../../settings/settings.service"); const env_1 = require("../../../config/env"); const logger_1 = require("../../../utils/logger"); const metrics_1 = require("../../../utils/metrics"); 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}`; } 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 } }, _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 } }, 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, }, }); 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, }); 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'); } await database_1.prisma.shift.delete({ where: { id } }); }, 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, }, }), ]); 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, }, }), ]); }, 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', 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', }); const htmlTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'html'); const txtTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'txt'); let orgName = 'Changemaker Lite'; try { orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ } const vars = { USER_NAME: data.name, USER_EMAIL: data.email, SHIFT_TITLE: shift.title, SHIFT_DATE: dateStr, SHIFT_TIME: `${shift.startTime} — ${shift.endTime}`, SHIFT_LOCATION: shift.location || 'TBD', IS_NEW_USER: isNewUser ? 'true' : '', TEMP_PASSWORD: tempPassword || '', LOGIN_URL: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`, ORGANIZATION_NAME: orgName, }; const html = email_service_1.emailService.processTemplate(htmlTemplate, vars); const text = email_service_1.emailService.processTemplate(txtTemplate, vars); await email_service_1.emailService.sendEmail({ to: data.email, subject: `Signup Confirmed — ${shift.title}`, html, text, }); } catch (err) { logger_1.logger.error('Failed to send shift signup confirmation email:', err); } (0, metrics_1.recordShiftSignup)(); 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'); } 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, }, }), ]); }, 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, }, 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', }); const htmlTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'html'); const txtTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'txt'); let orgName = 'Changemaker Lite'; try { orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName; } catch { /* default */ } const vars = { USER_NAME: user.name || user.email, USER_EMAIL: user.email, SHIFT_TITLE: shift.title, SHIFT_DATE: dateStr, SHIFT_TIME: `${shift.startTime} — ${shift.endTime}`, SHIFT_LOCATION: shift.location || 'TBD', IS_NEW_USER: '', TEMP_PASSWORD: '', LOGIN_URL: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`, ORGANIZATION_NAME: orgName, }; const html = email_service_1.emailService.processTemplate(htmlTemplate, vars); const text = email_service_1.emailService.processTemplate(txtTemplate, vars); await email_service_1.emailService.sendEmail({ to: user.email, subject: `Signup Confirmed — ${shift.title}`, html, text, }); } catch (err) { logger_1.logger.error('Failed to send volunteer shift signup confirmation email:', err); } return signup; }, async cancelVolunteerSignup(shiftId, 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 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'); 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, }, }), ]); }, 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, }, }, }, 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, }, 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', }); const htmlTemplate = email_service_1.emailService.loadTemplate('shift-details', 'html'); const txtTemplate = email_service_1.emailService.loadTemplate('shift-details', 'txt'); let orgName = 'Changemaker Lite'; try { orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ } let sent = 0; let failed = 0; for (const signup of shift.signups) { try { const vars = { USER_NAME: signup.userName || signup.userEmail, SHIFT_TITLE: shift.title, SHIFT_DATE: dateStr, SHIFT_START_TIME: shift.startTime, SHIFT_END_TIME: shift.endTime, SHIFT_LOCATION: shift.location || 'TBD', SHIFT_DESCRIPTION: shift.description || '', CURRENT_VOLUNTEERS: shift.currentVolunteers.toString(), MAX_VOLUNTEERS: shift.maxVolunteers.toString(), SHIFT_STATUS: shift.status, ORGANIZATION_NAME: orgName, }; const html = email_service_1.emailService.processTemplate(htmlTemplate, vars); const text = email_service_1.emailService.processTemplate(txtTemplate, vars); const result = await email_service_1.emailService.sendEmail({ to: signup.userEmail, subject: `Shift Details — ${shift.title}`, html, text, }); 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