1141 lines
53 KiB
JavaScript

"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