1141 lines
53 KiB
JavaScript
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
|