714 lines
30 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 settings_service_1 = require("../../settings/settings.service");
const env_1 = require("../../../config/env");
const logger_1 = require("../../../utils/logger");
const metrics_1 = require("../../../utils/metrics");
const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
function generateReadablePassword() {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const num = Math.floor(Math.random() * 90) + 10;
return `${adj}${noun}${num}`;
}
exports.shiftsService = {
async findAll(filters) {
const { page, limit, search, status, upcoming, sortBy, sortOrder } = filters;
const skip = (page - 1) * limit;
const where = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ location: { contains: search, mode: 'insensitive' } },
];
}
if (status)
where.status = status;
if (upcoming) {
where.date = { gte: new Date() };
}
const orderBy = sortBy === 'title'
? { title: sortOrder }
: sortBy === 'createdAt'
? { createdAt: sortOrder }
: { date: sortOrder };
const [shifts, total] = await Promise.all([
database_1.prisma.shift.findMany({
where,
skip,
take: limit,
orderBy,
include: {
cut: { select: { id: true, name: true } },
_count: {
select: {
signups: { where: { status: client_1.SignupStatus.CONFIRMED } },
},
},
},
}),
database_1.prisma.shift.count({ where }),
]);
return {
shifts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id) {
const shift = await database_1.prisma.shift.findUnique({
where: { id },
include: {
cut: { select: { id: true, name: true } },
signups: {
where: { status: client_1.SignupStatus.CONFIRMED },
include: { user: { select: { id: true, email: true, name: true, phone: true } } },
orderBy: { signupDate: 'desc' },
},
_count: {
select: {
signups: { where: { status: client_1.SignupStatus.CONFIRMED } },
},
},
},
});
if (!shift) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
return shift;
},
async create(data, userId) {
const shift = await database_1.prisma.shift.create({
data: {
title: data.title,
description: data.description,
date: new Date(data.date),
startTime: data.startTime,
endTime: data.endTime,
location: data.location,
maxVolunteers: data.maxVolunteers,
isPublic: data.isPublic,
cutId: data.cutId,
createdBy: userId,
},
});
return shift;
},
async update(id, data) {
const existing = await database_1.prisma.shift.findUnique({ where: { id } });
if (!existing) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
const updateData = { ...data };
if (data.date) {
updateData.date = new Date(data.date);
}
// Auto-update status when capacity changes
if (data.maxVolunteers !== undefined) {
if (existing.currentVolunteers >= data.maxVolunteers && existing.status === client_1.ShiftStatus.OPEN) {
updateData.status = client_1.ShiftStatus.FULL;
}
else if (existing.currentVolunteers < data.maxVolunteers && existing.status === client_1.ShiftStatus.FULL) {
updateData.status = client_1.ShiftStatus.OPEN;
}
}
const shift = await database_1.prisma.shift.update({
where: { id },
data: updateData,
});
return shift;
},
async delete(id) {
const existing = await database_1.prisma.shift.findUnique({ where: { id } });
if (!existing) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
await database_1.prisma.shift.delete({ where: { id } });
},
async getStats() {
const [total, open, full, cancelled, upcoming, totalSignups] = await Promise.all([
database_1.prisma.shift.count(),
database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.OPEN } }),
database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.FULL } }),
database_1.prisma.shift.count({ where: { status: client_1.ShiftStatus.CANCELLED } }),
database_1.prisma.shift.count({ where: { date: { gte: new Date() }, status: { not: client_1.ShiftStatus.CANCELLED } } }),
database_1.prisma.shiftSignup.count({ where: { status: client_1.SignupStatus.CONFIRMED } }),
]);
return { total, open, full, cancelled, upcoming, totalSignups };
},
async getSignups(shiftId) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
return database_1.prisma.shiftSignup.findMany({
where: { shiftId },
include: { user: { select: { id: true, email: true, name: true, phone: true } } },
orderBy: { signupDate: 'desc' },
});
},
async addSignup(shiftId, data) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL');
}
// Check unique constraint
const existing = await database_1.prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail: data.userEmail } },
});
if (existing) {
if (existing.status === client_1.SignupStatus.CANCELLED) {
// Re-activate cancelled signup
const [signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: existing.id },
data: { status: client_1.SignupStatus.CONFIRMED, signupSource: client_1.SignupSource.ADMIN },
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
return signup;
}
throw new error_handler_1.AppError(409, 'Volunteer already signed up', 'DUPLICATE_SIGNUP');
}
// Look up user
const user = await database_1.prisma.user.findUnique({ where: { email: data.userEmail } });
const [signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.create({
data: {
shiftId,
shiftTitle: shift.title,
userId: user?.id,
userEmail: data.userEmail,
userName: data.userName || user?.name,
signupSource: client_1.SignupSource.ADMIN,
},
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
return signup;
},
async removeSignup(signupId) {
const signup = await database_1.prisma.shiftSignup.findUnique({ where: { id: signupId } });
if (!signup) {
throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND');
}
if (signup.status === client_1.SignupStatus.CANCELLED) {
throw new error_handler_1.AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED');
}
await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: signupId },
data: { status: client_1.SignupStatus.CANCELLED },
}),
database_1.prisma.shift.update({
where: { id: signup.shiftId },
data: {
currentVolunteers: { decrement: 1 },
status: client_1.ShiftStatus.OPEN,
},
}),
]);
},
async publicSignup(shiftId, data) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
if (!shift.isPublic) {
throw new error_handler_1.AppError(403, 'This shift is not open for public signup', 'NOT_PUBLIC');
}
if (shift.status !== client_1.ShiftStatus.OPEN) {
throw new error_handler_1.AppError(400, 'This shift is not accepting signups', 'NOT_OPEN');
}
if (shift.date < new Date(new Date().toISOString().split('T')[0])) {
throw new error_handler_1.AppError(400, 'This shift has already passed', 'SHIFT_PAST');
}
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL');
}
// Check unique constraint
const existingSignup = await database_1.prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail: data.email } },
});
if (existingSignup && existingSignup.status === client_1.SignupStatus.CONFIRMED) {
throw new error_handler_1.AppError(409, 'You are already signed up for this shift', 'DUPLICATE_SIGNUP');
}
// Look up existing user
let user = await database_1.prisma.user.findUnique({ where: { email: data.email } });
let isNewUser = false;
let tempPassword;
if (!user) {
// Create temp user
tempPassword = generateReadablePassword();
const hashedPassword = await bcryptjs_1.default.hash(tempPassword, 12);
const shiftDate = new Date(shift.date);
shiftDate.setDate(shiftDate.getDate() + 1);
user = await database_1.prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone,
role: 'TEMP',
createdVia: 'PUBLIC_SHIFT_SIGNUP',
expiresAt: shiftDate,
},
});
isNewUser = true;
}
// Create signup (or re-activate cancelled one)
let signup;
if (existingSignup && existingSignup.status === client_1.SignupStatus.CANCELLED) {
[signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: existingSignup.id },
data: {
status: client_1.SignupStatus.CONFIRMED,
signupSource: user ? client_1.SignupSource.AUTHENTICATED : client_1.SignupSource.PUBLIC,
userName: data.name,
userPhone: data.phone,
userId: user.id,
},
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
}
else {
[signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.create({
data: {
shiftId,
shiftTitle: shift.title,
userId: user.id,
userEmail: data.email,
userName: data.name,
userPhone: data.phone,
signupSource: isNewUser ? client_1.SignupSource.PUBLIC : client_1.SignupSource.AUTHENTICATED,
},
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
}
// Send confirmation email
try {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const htmlTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try {
orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName;
}
catch { /* use default */ }
const vars = {
USER_NAME: data.name,
USER_EMAIL: data.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: isNewUser ? 'true' : '',
TEMP_PASSWORD: tempPassword || '',
LOGIN_URL: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = email_service_1.emailService.processTemplate(htmlTemplate, vars);
const text = email_service_1.emailService.processTemplate(txtTemplate, vars);
await email_service_1.emailService.sendEmail({
to: data.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
});
}
catch (err) {
logger_1.logger.error('Failed to send shift signup confirmation email:', err);
}
(0, metrics_1.recordShiftSignup)();
return { signup, isNewUser };
},
async cancelPublicSignup(shiftId, userEmail) {
const signup = await database_1.prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail } },
});
if (!signup) {
throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND');
}
if (signup.status === client_1.SignupStatus.CANCELLED) {
throw new error_handler_1.AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED');
}
await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: signup.id },
data: { status: client_1.SignupStatus.CANCELLED },
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { decrement: 1 },
status: client_1.ShiftStatus.OPEN,
},
}),
]);
},
async getUpcomingForVolunteer(userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user)
throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND');
const shifts = await database_1.prisma.shift.findMany({
where: {
isPublic: true,
status: { not: client_1.ShiftStatus.CANCELLED },
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
},
select: {
id: true,
title: true,
description: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
// Check signup status for each shift
const signups = await database_1.prisma.shiftSignup.findMany({
where: {
userEmail: user.email,
shiftId: { in: shifts.map((s) => s.id) },
status: client_1.SignupStatus.CONFIRMED,
},
select: { shiftId: true },
});
const signedUpSet = new Set(signups.map((s) => s.shiftId));
return shifts.map((s) => ({
...s,
isSignedUp: signedUpSet.has(s.id),
}));
},
async volunteerSignup(shiftId, userId) {
const user = await database_1.prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true, phone: true },
});
if (!user)
throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND');
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift)
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
if (!shift.isPublic)
throw new error_handler_1.AppError(403, 'This shift is not open for signup', 'NOT_PUBLIC');
if (shift.status === client_1.ShiftStatus.CANCELLED)
throw new error_handler_1.AppError(400, 'This shift is cancelled', 'SHIFT_CANCELLED');
if (shift.date < new Date(new Date().toISOString().split('T')[0]))
throw new error_handler_1.AppError(400, 'This shift has already passed', 'SHIFT_PAST');
if (shift.currentVolunteers >= shift.maxVolunteers)
throw new error_handler_1.AppError(400, 'Shift is full', 'SHIFT_FULL');
const existing = await database_1.prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail: user.email } },
});
if (existing && existing.status === client_1.SignupStatus.CONFIRMED) {
throw new error_handler_1.AppError(409, 'Already signed up for this shift', 'DUPLICATE_SIGNUP');
}
let signup;
if (existing && existing.status === client_1.SignupStatus.CANCELLED) {
[signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: existing.id },
data: { status: client_1.SignupStatus.CONFIRMED, signupSource: client_1.SignupSource.AUTHENTICATED },
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
}
else {
[signup] = await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.create({
data: {
shiftId,
shiftTitle: shift.title,
userId: user.id,
userEmail: user.email,
userName: user.name,
userPhone: user.phone,
signupSource: client_1.SignupSource.AUTHENTICATED,
},
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? client_1.ShiftStatus.FULL : undefined,
},
}),
]);
}
// Send confirmation email
try {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
const htmlTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = email_service_1.emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try {
orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName;
}
catch { /* default */ }
const vars = {
USER_NAME: user.name || user.email,
USER_EMAIL: user.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: '',
TEMP_PASSWORD: '',
LOGIN_URL: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = email_service_1.emailService.processTemplate(htmlTemplate, vars);
const text = email_service_1.emailService.processTemplate(txtTemplate, vars);
await email_service_1.emailService.sendEmail({
to: user.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
});
}
catch (err) {
logger_1.logger.error('Failed to send volunteer shift signup confirmation email:', err);
}
return signup;
},
async cancelVolunteerSignup(shiftId, userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user)
throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND');
const signup = await database_1.prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail: user.email } },
});
if (!signup)
throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND');
if (signup.status === client_1.SignupStatus.CANCELLED)
throw new error_handler_1.AppError(400, 'Already cancelled', 'ALREADY_CANCELLED');
await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: signup.id },
data: { status: client_1.SignupStatus.CANCELLED },
}),
database_1.prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { decrement: 1 },
status: client_1.ShiftStatus.OPEN,
},
}),
]);
},
async getMySignups(userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user)
throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND');
const signups = await database_1.prisma.shiftSignup.findMany({
where: {
userEmail: user.email,
status: client_1.SignupStatus.CONFIRMED,
shift: {
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
status: { not: client_1.ShiftStatus.CANCELLED },
},
},
include: {
shift: {
select: {
id: true,
title: true,
description: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
},
},
},
orderBy: { shift: { date: 'asc' } },
});
return signups;
},
async getPublicShifts() {
const shifts = await database_1.prisma.shift.findMany({
where: {
isPublic: true,
status: { not: client_1.ShiftStatus.CANCELLED },
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
},
select: {
id: true,
title: true,
description: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
status: true,
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
return shifts;
},
async emailShiftDetails(shiftId) {
const shift = await database_1.prisma.shift.findUnique({
where: { id: shiftId },
include: {
signups: {
where: { status: client_1.SignupStatus.CONFIRMED },
},
},
});
if (!shift) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const htmlTemplate = email_service_1.emailService.loadTemplate('shift-details', 'html');
const txtTemplate = email_service_1.emailService.loadTemplate('shift-details', 'txt');
let orgName = 'Changemaker Lite';
try {
orgName = (await settings_service_1.siteSettingsService.get()).organizationName || orgName;
}
catch { /* use default */ }
let sent = 0;
let failed = 0;
for (const signup of shift.signups) {
try {
const vars = {
USER_NAME: signup.userName || signup.userEmail,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_START_TIME: shift.startTime,
SHIFT_END_TIME: shift.endTime,
SHIFT_LOCATION: shift.location || 'TBD',
SHIFT_DESCRIPTION: shift.description || '',
CURRENT_VOLUNTEERS: shift.currentVolunteers.toString(),
MAX_VOLUNTEERS: shift.maxVolunteers.toString(),
SHIFT_STATUS: shift.status,
ORGANIZATION_NAME: orgName,
};
const html = email_service_1.emailService.processTemplate(htmlTemplate, vars);
const text = email_service_1.emailService.processTemplate(txtTemplate, vars);
const result = await email_service_1.emailService.sendEmail({
to: signup.userEmail,
subject: `Shift Details — ${shift.title}`,
html,
text,
});
if (result.success) {
sent++;
}
else {
failed++;
}
}
catch (err) {
logger_1.logger.error(`Failed to send shift details to ${signup.userEmail}:`, err);
failed++;
}
}
return { sent, failed };
},
async getCalendarData(startDate, endDate) {
const shifts = await database_1.prisma.shift.findMany({
where: {
date: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
include: {
cut: { select: { id: true, name: true } },
signups: true,
series: { select: { id: true, frequency: true } },
},
orderBy: { startTime: 'asc' },
});
// Group by date
const dateMap = {};
for (const shift of shifts) {
const dateKey = shift.date.toISOString().split('T')[0];
if (!dateMap[dateKey]) {
dateMap[dateKey] = { count: 0, shifts: [] };
}
dateMap[dateKey].count++;
dateMap[dateKey].shifts.push(shift);
}
return { dates: dateMap };
},
};
//# sourceMappingURL=shifts.service.js.map