Tonne of debugging - getting ready for the production builds

This commit is contained in:
2026-02-16 10:44:18 -07:00
parent a77306fac2
commit 7895ce683e
1367 changed files with 404191 additions and 2005 deletions

View File

@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=shift-series.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shift-series.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shift-series.routes.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2DxB,eAAe,MAAM,CAAC"}

View File

@@ -0,0 +1,54 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const auth_middleware_1 = require("../../../middleware/auth.middleware");
const rbac_middleware_1 = require("../../../middleware/rbac.middleware");
const validate_1 = require("../../../middleware/validate");
const client_1 = require("@prisma/client");
const shift_series_service_1 = require("./shift-series.service");
const shift_series_schemas_1 = require("./shift-series.schemas");
const router = (0, express_1.Router)();
// All routes require admin role
router.use(auth_middleware_1.authenticate, (0, rbac_middleware_1.requireRole)(client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN));
// Create series
router.post('/', (0, validate_1.validate)(shift_series_schemas_1.createShiftSeriesSchema), async (req, res, next) => {
try {
const result = await shift_series_service_1.ShiftSeriesService.createSeries(req.body, req.user?.id);
res.json(result);
}
catch (error) {
next(error);
}
});
// Get series
router.get('/:id', async (req, res, next) => {
try {
const series = await shift_series_service_1.ShiftSeriesService.getSeries(req.params.id);
res.json({ series });
}
catch (error) {
next(error);
}
});
// Update series
router.put('/:id', (0, validate_1.validate)(shift_series_schemas_1.updateShiftSeriesSchema), async (req, res, next) => {
try {
const series = await shift_series_service_1.ShiftSeriesService.updateSeries(req.params.id, req.body);
res.json({ series });
}
catch (error) {
next(error);
}
});
// Delete series
router.delete('/:id', async (req, res, next) => {
try {
await shift_series_service_1.ShiftSeriesService.deleteSeries(req.params.id);
res.json({ success: true });
}
catch (error) {
next(error);
}
});
exports.default = router;
//# sourceMappingURL=shift-series.routes.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shift-series.routes.js","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shift-series.routes.ts"],"names":[],"mappings":";;AAAA,qCAAiC;AACjC,yEAAmE;AACnE,yEAAkE;AAClE,2DAAwD;AACxD,2CAA0C;AAC1C,iEAA4D;AAC5D,iEAA0F;AAE1F,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,gCAAgC;AAChC,MAAM,CAAC,GAAG,CAAC,8BAAY,EAAE,IAAA,6BAAW,EAAC,iBAAQ,CAAC,WAAW,EAAE,iBAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;AAEhF,gBAAgB;AAChB,MAAM,CAAC,IAAI,CACT,GAAG,EACH,IAAA,mBAAQ,EAAC,8CAAuB,CAAC,EACjC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACvB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,yCAAkB,CAAC,YAAY,CAClD,GAAG,CAAC,IAAI,EACR,GAAG,CAAC,IAAI,EAAE,EAAE,CACb,CAAC;QACF,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACnB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,CAAC;IACd,CAAC;AACH,CAAC,CACF,CAAC;AAEF,aAAa;AACb,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAC1C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,yCAAkB,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAY,CAAC,CAAC;QAC3E,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;IACvB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,CAAC;IACd,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,gBAAgB;AAChB,MAAM,CAAC,GAAG,CACR,MAAM,EACN,IAAA,mBAAQ,EAAC,8CAAuB,CAAC,EACjC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IACvB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,yCAAkB,CAAC,YAAY,CAClD,GAAG,CAAC,MAAM,CAAC,EAAY,EACvB,GAAG,CAAC,IAAI,CACT,CAAC;QACF,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;IACvB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,CAAC;IACd,CAAC;AACH,CAAC,CACF,CAAC;AAEF,gBAAgB;AAChB,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAC7C,IAAI,CAAC;QACH,MAAM,yCAAkB,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,EAAY,CAAC,CAAC;QAC/D,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,CAAC;IACd,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}

View File

@@ -0,0 +1,108 @@
import { z } from 'zod';
export declare const createShiftSeriesSchema: z.ZodEffects<z.ZodObject<{
title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
startTime: z.ZodString;
endTime: z.ZodString;
location: z.ZodOptional<z.ZodString>;
maxVolunteers: z.ZodNumber;
isPublic: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
cutId: z.ZodOptional<z.ZodString>;
frequency: z.ZodNativeEnum<{
DAILY: "DAILY";
WEEKLY: "WEEKLY";
MONTHLY: "MONTHLY";
}>;
daysOfWeek: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
startDate: z.ZodString;
endDate: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
title: string;
startDate: string;
isPublic: boolean;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}, {
title: string;
startDate: string;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
isPublic?: boolean | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}>, {
title: string;
startDate: string;
isPublic: boolean;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}, {
title: string;
startDate: string;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
isPublic?: boolean | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}>;
export declare const updateShiftSeriesSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
startTime: z.ZodOptional<z.ZodString>;
endTime: z.ZodOptional<z.ZodString>;
location: z.ZodOptional<z.ZodNullable<z.ZodString>>;
maxVolunteers: z.ZodOptional<z.ZodNumber>;
isPublic: z.ZodOptional<z.ZodBoolean>;
cutId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
editMode: z.ZodEnum<["THIS", "FUTURE", "ALL"]>;
fromDate: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
editMode: "THIS" | "FUTURE" | "ALL";
location?: string | null | undefined;
title?: string | undefined;
description?: string | null | undefined;
isPublic?: boolean | undefined;
cutId?: string | null | undefined;
startTime?: string | undefined;
endTime?: string | undefined;
maxVolunteers?: number | undefined;
fromDate?: string | undefined;
}, {
editMode: "THIS" | "FUTURE" | "ALL";
location?: string | null | undefined;
title?: string | undefined;
description?: string | null | undefined;
isPublic?: boolean | undefined;
cutId?: string | null | undefined;
startTime?: string | undefined;
endTime?: string | undefined;
maxVolunteers?: number | undefined;
fromDate?: string | undefined;
}>;
export type CreateShiftSeriesInput = z.infer<typeof createShiftSeriesSchema>;
export type UpdateShiftSeriesInput = z.infer<typeof updateShiftSeriesSchema>;
//# sourceMappingURL=shift-series.schemas.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shift-series.schemas.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shift-series.schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwBnC,CAAC;AAEF,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAWlC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAC7E,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC"}

View File

@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.updateShiftSeriesSchema = exports.createShiftSeriesSchema = void 0;
const zod_1 = require("zod");
const client_1 = require("@prisma/client");
exports.createShiftSeriesSchema = zod_1.z.object({
title: zod_1.z.string().min(1, 'Title is required'),
description: zod_1.z.string().optional(),
startTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
location: zod_1.z.string().optional(),
maxVolunteers: zod_1.z.number().int().min(1, 'Must have at least 1 volunteer spot'),
isPublic: zod_1.z.boolean().optional().default(false),
cutId: zod_1.z.string().optional(),
// Recurrence
frequency: zod_1.z.nativeEnum(client_1.RecurrenceFrequency),
daysOfWeek: zod_1.z.array(zod_1.z.number().int().min(0).max(6)).optional(), // 0=Sun, 6=Sat
startDate: zod_1.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Start date must be YYYY-MM-DD'),
endDate: zod_1.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'End date must be YYYY-MM-DD').optional(),
}).refine((data) => {
// Weekly requires daysOfWeek
if (data.frequency === client_1.RecurrenceFrequency.WEEKLY && !data.daysOfWeek?.length) {
return false;
}
return true;
}, { message: 'Weekly recurrence requires at least one day of week', path: ['daysOfWeek'] });
exports.updateShiftSeriesSchema = zod_1.z.object({
title: zod_1.z.string().min(1).optional(),
description: zod_1.z.string().nullable().optional(),
startTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/).optional(),
endTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/).optional(),
location: zod_1.z.string().nullable().optional(),
maxVolunteers: zod_1.z.number().int().min(1).optional(),
isPublic: zod_1.z.boolean().optional(),
cutId: zod_1.z.string().nullable().optional(),
editMode: zod_1.z.enum(['THIS', 'FUTURE', 'ALL']),
fromDate: zod_1.z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // Required for FUTURE
});
//# sourceMappingURL=shift-series.schemas.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shift-series.schemas.js","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shift-series.schemas.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,186 @@
import type { CreateShiftSeriesInput, UpdateShiftSeriesInput } from './shift-series.schemas';
export declare class ShiftSeriesService {
/**
* Generate occurrence dates based on recurrence rules
*/
static generateOccurrences(input: CreateShiftSeriesInput): Date[];
/**
* Create a shift series and generate all individual shifts
*/
static createSeries(input: CreateShiftSeriesInput, createdBy?: string): Promise<{
series: {
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: import(".prisma/client").$Enums.RecurrenceFrequency;
daysOfWeek: import("@prisma/client/runtime/library").JsonValue | null;
};
generatedShiftsCount: number;
}>;
/**
* Get series with all its shifts
*/
static getSeries(seriesId: string): Promise<{
cut: {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
category: import(".prisma/client").$Enums.CutCategory | null;
description: string | null;
createdByUserId: string | null;
color: string;
opacity: import("@prisma/client/runtime/library").Decimal;
isPublic: boolean;
isOfficial: boolean;
geojson: string;
bounds: string | null;
showLocations: boolean;
exportEnabled: boolean;
assignedTo: string | null;
filterSettings: import("@prisma/client/runtime/library").JsonValue | null;
lastCanvassed: Date | null;
completionPercentage: number;
} | null;
shifts: ({
signups: {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
}[];
} & {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
})[];
} & {
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: import(".prisma/client").$Enums.RecurrenceFrequency;
daysOfWeek: import("@prisma/client/runtime/library").JsonValue | null;
}>;
/**
* Update series shifts based on edit mode
*/
static updateSeries(seriesId: string, input: UpdateShiftSeriesInput): Promise<{
cut: {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
category: import(".prisma/client").$Enums.CutCategory | null;
description: string | null;
createdByUserId: string | null;
color: string;
opacity: import("@prisma/client/runtime/library").Decimal;
isPublic: boolean;
isOfficial: boolean;
geojson: string;
bounds: string | null;
showLocations: boolean;
exportEnabled: boolean;
assignedTo: string | null;
filterSettings: import("@prisma/client/runtime/library").JsonValue | null;
lastCanvassed: Date | null;
completionPercentage: number;
} | null;
shifts: ({
signups: {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
}[];
} & {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
})[];
} & {
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
frequency: import(".prisma/client").$Enums.RecurrenceFrequency;
daysOfWeek: import("@prisma/client/runtime/library").JsonValue | null;
}>;
/**
* Delete series (cascades to shifts via onDelete: SetNull)
*/
static deleteSeries(seriesId: string): Promise<void>;
}
//# sourceMappingURL=shift-series.service.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shift-series.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shift-series.service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAK7F,qBAAa,kBAAkB;IAC7B;;OAEG;IACH,MAAM,CAAC,mBAAmB,CAAC,KAAK,EAAE,sBAAsB,GAAG,IAAI,EAAE;IA8CjE;;OAEG;WACU,YAAY,CAAC,KAAK,EAAE,sBAAsB,EAAE,SAAS,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;IA2D3E;;OAEG;WACU,SAAS,CAAC,QAAQ,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAmBvC;;OAEG;WACU,YAAY,CACvB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA0C/B;;OAEG;WACU,YAAY,CAAC,QAAQ,EAAE,MAAM;CAW3C"}

View File

@@ -0,0 +1,180 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ShiftSeriesService = void 0;
const database_1 = require("../../../config/database");
const client_1 = require("@prisma/client");
const MAX_OCCURRENCES = 100;
const MAX_DAYS = 365;
class ShiftSeriesService {
/**
* Generate occurrence dates based on recurrence rules
*/
static generateOccurrences(input) {
const dates = [];
const startDate = new Date(input.startDate);
const endDate = input.endDate ? new Date(input.endDate) : null;
let currentDate = new Date(startDate);
let count = 0;
while (count < MAX_OCCURRENCES) {
// Check end date
if (endDate && currentDate > endDate)
break;
// Check max days
const daysDiff = Math.floor((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > MAX_DAYS)
break;
// Check if date matches recurrence pattern
let shouldInclude = false;
if (input.frequency === client_1.RecurrenceFrequency.DAILY) {
shouldInclude = true;
}
else if (input.frequency === client_1.RecurrenceFrequency.WEEKLY) {
const dayOfWeek = currentDate.getDay(); // 0=Sun, 6=Sat
shouldInclude = input.daysOfWeek?.includes(dayOfWeek) ?? false;
}
else if (input.frequency === client_1.RecurrenceFrequency.MONTHLY) {
shouldInclude = currentDate.getDate() === startDate.getDate();
}
if (shouldInclude) {
dates.push(new Date(currentDate));
count++;
}
// Increment date
if (input.frequency === client_1.RecurrenceFrequency.DAILY) {
currentDate.setDate(currentDate.getDate() + 1);
}
else if (input.frequency === client_1.RecurrenceFrequency.WEEKLY) {
currentDate.setDate(currentDate.getDate() + 1); // Check each day
}
else if (input.frequency === client_1.RecurrenceFrequency.MONTHLY) {
currentDate.setMonth(currentDate.getMonth() + 1);
}
}
return dates;
}
/**
* Create a shift series and generate all individual shifts
*/
static async createSeries(input, createdBy) {
// Generate occurrence dates
const dates = this.generateOccurrences(input);
if (dates.length === 0) {
throw new Error('No valid dates generated from recurrence rules');
}
// Create series and shifts in transaction
const result = await database_1.prisma.$transaction(async (tx) => {
// Create series
const series = await tx.shiftSeries.create({
data: {
title: input.title,
description: input.description,
startTime: input.startTime,
endTime: input.endTime,
location: input.location,
maxVolunteers: input.maxVolunteers,
isPublic: input.isPublic ?? false,
cutId: input.cutId,
frequency: input.frequency,
daysOfWeek: input.daysOfWeek,
startDate: new Date(input.startDate),
endDate: input.endDate ? new Date(input.endDate) : null,
createdBy,
},
});
// Create individual shifts
const shifts = await Promise.all(dates.map((date) => tx.shift.create({
data: {
title: input.title,
description: input.description,
date,
startTime: input.startTime,
endTime: input.endTime,
location: input.location,
maxVolunteers: input.maxVolunteers,
isPublic: input.isPublic ?? false,
cutId: input.cutId,
seriesId: series.id,
createdBy,
},
})));
return { series, shifts };
});
return {
series: result.series,
generatedShiftsCount: result.shifts.length,
};
}
/**
* Get series with all its shifts
*/
static async getSeries(seriesId) {
const series = await database_1.prisma.shiftSeries.findUnique({
where: { id: seriesId },
include: {
cut: true,
shifts: {
orderBy: { date: 'asc' },
include: { signups: true },
},
},
});
if (!series) {
throw new Error('Shift series not found');
}
return series;
}
/**
* Update series shifts based on edit mode
*/
static async updateSeries(seriesId, input) {
const series = await database_1.prisma.shiftSeries.findUnique({
where: { id: seriesId },
include: { shifts: true },
});
if (!series) {
throw new Error('Shift series not found');
}
const { editMode, fromDate, ...updates } = input;
if (editMode === 'ALL') {
// Update series + all shifts
await database_1.prisma.$transaction(async (tx) => {
await tx.shiftSeries.update({
where: { id: seriesId },
data: updates,
});
await tx.shift.updateMany({
where: { seriesId, isException: false },
data: updates,
});
});
}
else if (editMode === 'FUTURE' && fromDate) {
// Update shifts from date onwards
await database_1.prisma.shift.updateMany({
where: {
seriesId,
date: { gte: new Date(fromDate) },
isException: false,
},
data: updates,
});
}
// Note: THIS mode is handled by regular shift update with isException flag
return this.getSeries(seriesId);
}
/**
* Delete series (cascades to shifts via onDelete: SetNull)
*/
static async deleteSeries(seriesId) {
// Delete all shifts in series first
await database_1.prisma.shift.deleteMany({
where: { seriesId },
});
// Then delete series
await database_1.prisma.shiftSeries.delete({
where: { id: seriesId },
});
}
}
exports.ShiftSeriesService = ShiftSeriesService;
//# sourceMappingURL=shift-series.service.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
declare const adminRouter: import("express-serve-static-core").Router;
declare const volunteerRouter: import("express-serve-static-core").Router;
declare const publicRouter: import("express-serve-static-core").Router;
export { adminRouter as shiftsAdminRouter, publicRouter as shiftsPublicRouter, volunteerRouter as shiftsVolunteerRouter };
//# sourceMappingURL=shifts.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shifts.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.routes.ts"],"names":[],"mappings":"AAkBA,QAAA,MAAM,WAAW,4CAAW,CAAC;AA2J7B,QAAA,MAAM,eAAe,4CAAW,CAAC;AA2DjC,QAAA,MAAM,YAAY,4CAAW,CAAC;AA+B9B,OAAO,EAAE,WAAW,IAAI,iBAAiB,EAAE,YAAY,IAAI,kBAAkB,EAAE,eAAe,IAAI,qBAAqB,EAAE,CAAC"}

View File

@@ -0,0 +1,198 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.shiftsVolunteerRouter = exports.shiftsPublicRouter = exports.shiftsAdminRouter = void 0;
const express_1 = require("express");
const client_1 = require("@prisma/client");
const shifts_service_1 = require("./shifts.service");
const shifts_schemas_1 = require("./shifts.schemas");
const validate_1 = require("../../../middleware/validate");
const auth_middleware_1 = require("../../../middleware/auth.middleware");
const rbac_middleware_1 = require("../../../middleware/rbac.middleware");
const rate_limit_1 = require("../../../middleware/rate-limit");
const MAP_ADMIN_ROLES = [client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN];
// --- Admin Router ---
const adminRouter = (0, express_1.Router)();
exports.shiftsAdminRouter = adminRouter;
adminRouter.use(auth_middleware_1.authenticate);
adminRouter.use((0, rbac_middleware_1.requireRole)(...MAP_ADMIN_ROLES));
// GET /api/map/shifts — list paginated
adminRouter.get('/', (0, validate_1.validate)(shifts_schemas_1.listShiftsSchema, 'query'), async (req, res, next) => {
try {
const result = await shifts_service_1.shiftsService.findAll(req.query);
res.json(result);
}
catch (err) {
next(err);
}
});
// GET /api/map/shifts/stats — statistics
adminRouter.get('/stats', async (_req, res, next) => {
try {
const stats = await shifts_service_1.shiftsService.getStats();
res.json(stats);
}
catch (err) {
next(err);
}
});
// GET /api/map/shifts/calendar — calendar data
adminRouter.get('/calendar', async (req, res, next) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate required' });
}
const data = await shifts_service_1.shiftsService.getCalendarData(startDate, endDate);
res.json(data);
}
catch (err) {
next(err);
}
});
// GET /api/map/shifts/:id — single shift with signups
adminRouter.get('/:id', async (req, res, next) => {
try {
const id = req.params.id;
const shift = await shifts_service_1.shiftsService.findById(id);
res.json(shift);
}
catch (err) {
next(err);
}
});
// POST /api/map/shifts — create
adminRouter.post('/', (0, validate_1.validate)(shifts_schemas_1.createShiftSchema), async (req, res, next) => {
try {
const shift = await shifts_service_1.shiftsService.create(req.body, req.user.id);
res.status(201).json(shift);
}
catch (err) {
next(err);
}
});
// PUT /api/map/shifts/:id — update
adminRouter.put('/:id', (0, validate_1.validate)(shifts_schemas_1.updateShiftSchema), async (req, res, next) => {
try {
const id = req.params.id;
const shift = await shifts_service_1.shiftsService.update(id, req.body);
res.json(shift);
}
catch (err) {
next(err);
}
});
// DELETE /api/map/shifts/:id — delete
adminRouter.delete('/:id', async (req, res, next) => {
try {
const id = req.params.id;
await shifts_service_1.shiftsService.delete(id);
res.status(204).send();
}
catch (err) {
next(err);
}
});
// POST /api/map/shifts/:id/signups — admin add volunteer
adminRouter.post('/:id/signups', (0, validate_1.validate)(shifts_schemas_1.addSignupSchema), async (req, res, next) => {
try {
const id = req.params.id;
const signup = await shifts_service_1.shiftsService.addSignup(id, req.body);
res.status(201).json(signup);
}
catch (err) {
next(err);
}
});
// DELETE /api/map/shifts/:id/signups/:signupId — admin remove volunteer
adminRouter.delete('/:id/signups/:signupId', async (req, res, next) => {
try {
const signupId = req.params.signupId;
await shifts_service_1.shiftsService.removeSignup(signupId);
res.status(204).send();
}
catch (err) {
next(err);
}
});
// POST /api/map/shifts/:id/email-details — email all volunteers
adminRouter.post('/:id/email-details', async (req, res, next) => {
try {
const id = req.params.id;
const result = await shifts_service_1.shiftsService.emailShiftDetails(id);
res.json(result);
}
catch (err) {
next(err);
}
});
// --- Volunteer Router ---
const volunteerRouter = (0, express_1.Router)();
exports.shiftsVolunteerRouter = volunteerRouter;
volunteerRouter.use(auth_middleware_1.authenticate);
// GET /api/map/shifts/volunteer/upcoming — upcoming shifts with signup status
volunteerRouter.get('/volunteer/upcoming', async (req, res, next) => {
try {
const shifts = await shifts_service_1.shiftsService.getUpcomingForVolunteer(req.user.id);
res.json(shifts);
}
catch (err) {
next(err);
}
});
// GET /api/map/shifts/volunteer/my-signups — own confirmed signups
volunteerRouter.get('/volunteer/my-signups', async (req, res, next) => {
try {
const signups = await shifts_service_1.shiftsService.getMySignups(req.user.id);
res.json(signups);
}
catch (err) {
next(err);
}
});
// POST /api/map/shifts/volunteer/:id/signup — sign up for shift
volunteerRouter.post('/volunteer/:id/signup', rate_limit_1.shiftSignupRateLimit, async (req, res, next) => {
try {
const id = req.params.id;
const signup = await shifts_service_1.shiftsService.volunteerSignup(id, req.user.id);
res.status(201).json(signup);
}
catch (err) {
next(err);
}
});
// DELETE /api/map/shifts/volunteer/:id/signup — cancel own signup
volunteerRouter.delete('/volunteer/:id/signup', async (req, res, next) => {
try {
const id = req.params.id;
await shifts_service_1.shiftsService.cancelVolunteerSignup(id, req.user.id);
res.status(204).send();
}
catch (err) {
next(err);
}
});
// --- Public Router ---
const publicRouter = (0, express_1.Router)();
exports.shiftsPublicRouter = publicRouter;
// GET /api/map/shifts/public — list upcoming public shifts
publicRouter.get('/public', async (_req, res, next) => {
try {
const shifts = await shifts_service_1.shiftsService.getPublicShifts();
res.json(shifts);
}
catch (err) {
next(err);
}
});
// POST /api/map/shifts/public/:id/signup — public signup
publicRouter.post('/public/:id/signup', rate_limit_1.shiftSignupRateLimit, (0, validate_1.validate)(shifts_schemas_1.publicSignupSchema), async (req, res, next) => {
try {
const id = req.params.id;
const result = await shifts_service_1.shiftsService.publicSignup(id, req.body);
res.status(201).json(result);
}
catch (err) {
next(err);
}
});
//# sourceMappingURL=shifts.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,128 @@
import { z } from 'zod';
export declare const createShiftSchema: z.ZodObject<{
title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
date: z.ZodString;
startTime: z.ZodString;
endTime: z.ZodString;
location: z.ZodOptional<z.ZodString>;
maxVolunteers: z.ZodNumber;
isPublic: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
cutId: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
date: string;
title: string;
isPublic: boolean;
startTime: string;
endTime: string;
maxVolunteers: number;
location?: string | undefined;
description?: string | undefined;
cutId?: string | undefined;
}, {
date: string;
title: string;
startTime: string;
endTime: string;
maxVolunteers: number;
location?: string | undefined;
description?: string | undefined;
isPublic?: boolean | undefined;
cutId?: string | undefined;
}>;
export declare const updateShiftSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
date: z.ZodOptional<z.ZodString>;
startTime: z.ZodOptional<z.ZodString>;
endTime: z.ZodOptional<z.ZodString>;
location: z.ZodOptional<z.ZodNullable<z.ZodString>>;
maxVolunteers: z.ZodOptional<z.ZodNumber>;
isPublic: z.ZodOptional<z.ZodBoolean>;
status: z.ZodOptional<z.ZodNativeEnum<{
OPEN: "OPEN";
FULL: "FULL";
CANCELLED: "CANCELLED";
}>>;
cutId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
date?: string | undefined;
location?: string | null | undefined;
title?: string | undefined;
description?: string | null | undefined;
isPublic?: boolean | undefined;
cutId?: string | null | undefined;
startTime?: string | undefined;
endTime?: string | undefined;
maxVolunteers?: number | undefined;
}, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
date?: string | undefined;
location?: string | null | undefined;
title?: string | undefined;
description?: string | null | undefined;
isPublic?: boolean | undefined;
cutId?: string | null | undefined;
startTime?: string | undefined;
endTime?: string | undefined;
maxVolunteers?: number | undefined;
}>;
export declare const listShiftsSchema: z.ZodObject<{
page: z.ZodDefault<z.ZodNumber>;
limit: z.ZodDefault<z.ZodNumber>;
search: z.ZodOptional<z.ZodString>;
status: z.ZodOptional<z.ZodNativeEnum<{
OPEN: "OPEN";
FULL: "FULL";
CANCELLED: "CANCELLED";
}>>;
upcoming: z.ZodOptional<z.ZodBoolean>;
sortBy: z.ZodDefault<z.ZodOptional<z.ZodEnum<["date", "createdAt", "title"]>>>;
sortOrder: z.ZodDefault<z.ZodOptional<z.ZodEnum<["asc", "desc"]>>>;
}, "strip", z.ZodTypeAny, {
limit: number;
page: number;
sortBy: "date" | "createdAt" | "title";
sortOrder: "asc" | "desc";
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
search?: string | undefined;
upcoming?: boolean | undefined;
}, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
search?: string | undefined;
limit?: number | undefined;
page?: number | undefined;
sortBy?: "date" | "createdAt" | "title" | undefined;
sortOrder?: "asc" | "desc" | undefined;
upcoming?: boolean | undefined;
}>;
export declare const addSignupSchema: z.ZodObject<{
userEmail: z.ZodString;
userName: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
userEmail: string;
userName?: string | undefined;
}, {
userEmail: string;
userName?: string | undefined;
}>;
export declare const publicSignupSchema: z.ZodObject<{
email: z.ZodString;
name: z.ZodString;
phone: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
email: string;
name: string;
phone?: string | undefined;
}, {
email: string;
name: string;
phone?: string | undefined;
}>;
export type CreateShiftInput = z.infer<typeof createShiftSchema>;
export type UpdateShiftInput = z.infer<typeof updateShiftSchema>;
export type ListShiftsInput = z.infer<typeof listShiftsSchema>;
export type AddSignupInput = z.infer<typeof addSignupSchema>;
export type PublicSignupInput = z.infer<typeof publicSignupSchema>;
//# sourceMappingURL=shifts.schemas.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shifts.schemas.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU5B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW5B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQ3B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;EAG1B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;EAI7B,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACjE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACjE,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC/D,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAC7D,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC"}

View File

@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.publicSignupSchema = exports.addSignupSchema = exports.listShiftsSchema = exports.updateShiftSchema = exports.createShiftSchema = void 0;
const zod_1 = require("zod");
const client_1 = require("@prisma/client");
exports.createShiftSchema = zod_1.z.object({
title: zod_1.z.string().min(1, 'Title is required'),
description: zod_1.z.string().optional(),
date: zod_1.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
location: zod_1.z.string().optional(),
maxVolunteers: zod_1.z.number().int().min(1, 'Must have at least 1 volunteer spot'),
isPublic: zod_1.z.boolean().optional().default(false),
cutId: zod_1.z.string().optional(),
});
exports.updateShiftSchema = zod_1.z.object({
title: zod_1.z.string().min(1).optional(),
description: zod_1.z.string().nullable().optional(),
date: zod_1.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
startTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
endTime: zod_1.z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
location: zod_1.z.string().nullable().optional(),
maxVolunteers: zod_1.z.number().int().min(1).optional(),
isPublic: zod_1.z.boolean().optional(),
status: zod_1.z.nativeEnum(client_1.ShiftStatus).optional(),
cutId: zod_1.z.string().nullable().optional(),
});
exports.listShiftsSchema = zod_1.z.object({
page: zod_1.z.coerce.number().int().positive().default(1),
limit: zod_1.z.coerce.number().int().positive().max(100).default(20),
search: zod_1.z.string().optional(),
status: zod_1.z.nativeEnum(client_1.ShiftStatus).optional(),
upcoming: zod_1.z.coerce.boolean().optional(),
sortBy: zod_1.z.enum(['date', 'createdAt', 'title']).optional().default('date'),
sortOrder: zod_1.z.enum(['asc', 'desc']).optional().default('desc'),
});
exports.addSignupSchema = zod_1.z.object({
userEmail: zod_1.z.string().email('Valid email is required'),
userName: zod_1.z.string().optional(),
});
exports.publicSignupSchema = zod_1.z.object({
email: zod_1.z.string().email('Valid email is required'),
name: zod_1.z.string().min(1, 'Name is required'),
phone: zod_1.z.string().optional(),
});
//# sourceMappingURL=shifts.schemas.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shifts.schemas.js","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.schemas.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,253 @@
import type { CreateShiftInput, UpdateShiftInput, ListShiftsInput, AddSignupInput, PublicSignupInput } from './shifts.schemas';
export declare const shiftsService: {
findAll(filters: ListShiftsInput): Promise<{
shifts: ({
_count: {
signups: number;
};
cut: {
id: string;
name: string;
} | null;
} & {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
})[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}>;
findById(id: string): Promise<{
_count: {
signups: number;
};
cut: {
id: string;
name: string;
} | null;
signups: ({
user: {
id: string;
email: string;
name: string | null;
phone: string | null;
} | null;
} & {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
})[];
} & {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
}>;
create(data: CreateShiftInput, userId: string): Promise<{
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
}>;
update(id: string, data: UpdateShiftInput): Promise<{
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
seriesId: string | null;
isException: boolean;
}>;
delete(id: string): Promise<void>;
getStats(): Promise<{
total: number;
open: number;
full: number;
cancelled: number;
upcoming: number;
totalSignups: number;
}>;
getSignups(shiftId: string): Promise<({
user: {
id: string;
email: string;
name: string | null;
phone: string | null;
} | null;
} & {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
})[]>;
addSignup(shiftId: string, data: AddSignupInput): Promise<{
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
}>;
removeSignup(signupId: string): Promise<void>;
publicSignup(shiftId: string, data: PublicSignupInput): Promise<{
signup: {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
};
isNewUser: boolean;
}>;
cancelPublicSignup(shiftId: string, userEmail: string): Promise<void>;
getUpcomingForVolunteer(userId: string): Promise<{
isSignedUp: boolean;
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
location: string | null;
title: string;
description: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
}[]>;
volunteerSignup(shiftId: string, userId: string): Promise<{
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
}>;
cancelVolunteerSignup(shiftId: string, userId: string): Promise<void>;
getMySignups(userId: string): Promise<({
shift: {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
location: string | null;
title: string;
description: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
};
} & {
status: import(".prisma/client").$Enums.SignupStatus;
id: string;
userId: string | null;
userEmail: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
})[]>;
getPublicShifts(): Promise<{
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
id: string;
location: string | null;
title: string;
description: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
}[]>;
emailShiftDetails(shiftId: string): Promise<{
sent: number;
failed: number;
}>;
getCalendarData(startDate: string, endDate: string): Promise<{
dates: Record<string, {
count: number;
shifts: any[];
}>;
}>;
};
//# sourceMappingURL=shifts.service.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"shifts.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.service.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,iBAAiB,EAClB,MAAM,kBAAkB,CAAC;AAY1B,eAAO,MAAM,aAAa;qBACD,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuDnB,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyBN,gBAAgB,UAAU,MAAM;;;;;;;;;;;;;;;;;;;eAmBlC,MAAM,QAAQ,gBAAgB;;;;;;;;;;;;;;;;;;;eA6B9B,MAAM;;;;;;;;;wBAsBG,MAAM;;;;;;;;;;;;;;;;;;;uBAaP,MAAM,QAAQ,cAAc;;;;;;;;;;;;2BA6DxB,MAAM;0BAyBP,MAAM,QAAQ,iBAAiB;;;;;;;;;;;;;;;gCAqJzB,MAAM,aAAa,MAAM;oCA4BrB,MAAM;;;;;;;;;;;;;6BA2Cb,MAAM,UAAU,MAAM;;;;;;;;;;;;mCAoGhB,MAAM,UAAU,MAAM;yBA0BhC,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA4DA,MAAM;;;;+BAsEN,MAAM,WAAW,MAAM;;mBAiBf,MAAM;oBAAU,GAAG,EAAE;;;CAe/D,CAAC"}

View File

@@ -0,0 +1,714 @@
"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

File diff suppressed because one or more lines are too long