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