bunker-admin 96ff2a85d6 Expose ShiftKind in admin panel with Dropdown.Button picker
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
2026-04-11 11:09:23 -06:00

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 };
},
};