Upgrade system finished

This commit is contained in:
2026-03-22 21:47:09 -06:00
parent a71ba20176
commit bb1935027d
299 changed files with 8067 additions and 2758 deletions

View File

@@ -1 +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"}
{"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;AAqExB,eAAe,MAAM,CAAC"}

View File

@@ -4,12 +4,12 @@ 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 roles_1 = require("../../../utils/roles");
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));
router.use(auth_middleware_1.authenticate, (0, rbac_middleware_1.requireRole)(...roles_1.SCHEDULING_ROLES));
// Create series
router.post('/', (0, validate_1.validate)(shift_series_schemas_1.createShiftSeriesSchema), async (req, res, next) => {
try {
@@ -20,6 +20,16 @@ router.post('/', (0, validate_1.validate)(shift_series_schemas_1.createShiftSeri
next(error);
}
});
// Get series shift count
router.get('/:id/count', async (req, res, next) => {
try {
const count = await shift_series_service_1.ShiftSeriesService.getShiftCount(req.params.id);
res.json({ count });
}
catch (error) {
next(error);
}
});
// Get series
router.get('/:id', async (req, res, next) => {
try {

View File

@@ -1 +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"}
{"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,iEAA4D;AAC5D,iEAA0F;AAC1F,gDAAwD;AAExD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,gCAAgC;AAChC,MAAM,CAAC,GAAG,CAAC,8BAAY,EAAE,IAAA,6BAAW,EAAC,GAAG,wBAAgB,CAAC,CAAC,CAAC;AAE3D,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,yBAAyB;AACzB,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;IAChD,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,yCAAkB,CAAC,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,EAAY,CAAC,CAAC;QAC9E,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,KAAK,CAAC,CAAC;IACd,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,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

@@ -25,8 +25,8 @@ export declare const createShiftSeriesSchema: z.ZodEffects<z.ZodObject<{
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
description?: string | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}, {
@@ -37,8 +37,8 @@ export declare const createShiftSeriesSchema: z.ZodEffects<z.ZodObject<{
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
description?: string | undefined;
isPublic?: boolean | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
@@ -51,8 +51,8 @@ export declare const createShiftSeriesSchema: z.ZodEffects<z.ZodObject<{
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
description?: string | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;
}, {
@@ -63,8 +63,8 @@ export declare const createShiftSeriesSchema: z.ZodEffects<z.ZodObject<{
maxVolunteers: number;
frequency: "DAILY" | "WEEKLY" | "MONTHLY";
location?: string | undefined;
description?: string | undefined;
endDate?: string | undefined;
description?: string | undefined;
isPublic?: boolean | undefined;
cutId?: string | undefined;
daysOfWeek?: number[] | undefined;

View File

@@ -14,12 +14,12 @@ export declare class ShiftSeriesService {
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
@@ -28,43 +28,26 @@ export declare class ShiftSeriesService {
};
generatedShiftsCount: number;
}>;
/**
* Get count of non-exception shifts in a series
*/
static getShiftCount(seriesId: string): Promise<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;
shiftId: 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;
@@ -75,28 +58,51 @@ export declare class ShiftSeriesService {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
})[];
cut: {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
category: import(".prisma/client").$Enums.CutCategory | null;
description: string | null;
isPublic: boolean;
createdByUserId: string | null;
color: string;
opacity: import("@prisma/client/runtime/library").Decimal;
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;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
@@ -107,39 +113,18 @@ export declare class ShiftSeriesService {
* 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;
shiftId: 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;
@@ -150,28 +135,51 @@ export declare class ShiftSeriesService {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
})[];
cut: {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
category: import(".prisma/client").$Enums.CutCategory | null;
description: string | null;
isPublic: boolean;
createdByUserId: string | null;
color: string;
opacity: import("@prisma/client/runtime/library").Decimal;
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;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
location: string | null;
title: string;
description: string | null;
startDate: Date;
endDate: Date | null;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;

View File

@@ -1 +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"}
{"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,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAM7D;;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

@@ -104,6 +104,14 @@ class ShiftSeriesService {
generatedShiftsCount: result.shifts.length,
};
}
/**
* Get count of non-exception shifts in a series
*/
static async getShiftCount(seriesId) {
return database_1.prisma.shift.count({
where: { seriesId, isException: false },
});
}
/**
* Get series with all its shifts
*/

File diff suppressed because one or more lines are too long

View File

@@ -1 +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"}
{"version":3,"file":"shifts.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.routes.ts"],"names":[],"mappings":"AAkBA,QAAA,MAAM,WAAW,4CAAW,CAAC;AAuL7B,QAAA,MAAM,eAAe,4CAAW,CAAC;AA2DjC,QAAA,MAAM,YAAY,4CAAW,CAAC;AA0E9B,OAAO,EAAE,WAAW,IAAI,iBAAiB,EAAE,YAAY,IAAI,kBAAkB,EAAE,eAAe,IAAI,qBAAqB,EAAE,CAAC"}

View File

@@ -2,19 +2,20 @@
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];
const database_1 = require("../../../config/database");
const redis_1 = require("../../../config/redis");
const roles_1 = require("../../../utils/roles");
// --- 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));
adminRouter.use((0, rbac_middleware_1.requireRole)(...roles_1.SCHEDULING_ROLES));
// GET /api/map/shifts — list paginated
adminRouter.get('/', (0, validate_1.validate)(shifts_schemas_1.listShiftsSchema, 'query'), async (req, res, next) => {
try {
@@ -114,6 +115,28 @@ adminRouter.delete('/:id/signups/:signupId', async (req, res, next) => {
next(err);
}
});
// POST /api/map/shifts/:id/meeting — create and link a video briefing meeting
adminRouter.post('/:id/meeting', async (req, res, next) => {
try {
const id = req.params.id;
const meeting = await shifts_service_1.shiftsService.createMeetingForShift(id, req.user.id);
res.status(201).json(meeting);
}
catch (err) {
next(err);
}
});
// DELETE /api/map/shifts/:id/meeting — remove video briefing from shift
adminRouter.delete('/:id/meeting', async (req, res, next) => {
try {
const id = req.params.id;
await shifts_service_1.shiftsService.removeMeetingFromShift(id);
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 {
@@ -195,4 +218,47 @@ publicRouter.post('/public/:id/signup', rate_limit_1.shiftSignupRateLimit, (0, v
next(err);
}
});
// GET /api/map/shifts/public/related — related active campaigns
publicRouter.get('/public/related', async (_req, res, next) => {
try {
const cacheKey = 'shifts:related:campaigns';
try {
const cached = await redis_1.redis.get(cacheKey);
if (cached) {
res.json(JSON.parse(cached));
return;
}
}
catch { /* cache miss */ }
const campaigns = await database_1.prisma.campaign.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
slug: true,
title: true,
description: true,
_count: { select: { emails: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
});
const result = {
campaigns: campaigns.map(c => ({
id: c.id,
slug: c.slug,
title: c.title,
description: c.description?.slice(0, 150) ?? null,
emailCount: c._count.emails,
})),
};
try {
await redis_1.redis.setex(cacheKey, 300, JSON.stringify(result));
}
catch { /* non-critical */ }
res.json(result);
}
catch (err) {
next(err);
}
});
//# sourceMappingURL=shifts.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -46,7 +46,7 @@ export declare const updateShiftSchema: z.ZodObject<{
}>>;
cutId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
status?: "CANCELLED" | "OPEN" | "FULL" | undefined;
date?: string | undefined;
location?: string | null | undefined;
title?: string | undefined;
@@ -57,7 +57,7 @@ export declare const updateShiftSchema: z.ZodObject<{
endTime?: string | undefined;
maxVolunteers?: number | undefined;
}, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
status?: "CANCELLED" | "OPEN" | "FULL" | undefined;
date?: string | undefined;
location?: string | null | undefined;
title?: string | undefined;
@@ -85,11 +85,11 @@ export declare const listShiftsSchema: z.ZodObject<{
page: number;
sortBy: "date" | "createdAt" | "title";
sortOrder: "asc" | "desc";
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
status?: "CANCELLED" | "OPEN" | "FULL" | undefined;
search?: string | undefined;
upcoming?: boolean | undefined;
}, {
status?: "OPEN" | "FULL" | "CANCELLED" | undefined;
status?: "CANCELLED" | "OPEN" | "FULL" | undefined;
search?: string | undefined;
limit?: number | undefined;
page?: number | undefined;

View File

@@ -9,6 +9,13 @@ export declare const shiftsService: {
id: string;
name: string;
} | null;
meeting: {
id: string;
title: string;
isActive: boolean;
slug: string;
jitsiRoom: string;
} | null;
} & {
status: import(".prisma/client").$Enums.ShiftStatus;
date: Date;
@@ -18,15 +25,17 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
})[];
pagination: {
page: number;
@@ -43,6 +52,13 @@ export declare const shiftsService: {
id: string;
name: string;
} | null;
meeting: {
id: string;
title: string;
isActive: boolean;
slug: string;
jitsiRoom: string;
} | null;
signups: ({
user: {
id: string;
@@ -55,12 +71,12 @@ export declare const shiftsService: {
id: string;
userId: string | null;
userEmail: string;
shiftId: 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;
@@ -71,15 +87,17 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
}>;
create(data: CreateShiftInput, userId: string): Promise<{
status: import(".prisma/client").$Enums.ShiftStatus;
@@ -90,15 +108,17 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
}>;
update(id: string, data: UpdateShiftInput): Promise<{
status: import(".prisma/client").$Enums.ShiftStatus;
@@ -109,17 +129,33 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
createdBy: string | null;
isPublic: boolean;
cutId: string | null;
createdBy: string | null;
startTime: string;
endTime: string;
maxVolunteers: number;
currentVolunteers: number;
gancioEventId: number | null;
meetingId: string | null;
seriesId: string | null;
isException: boolean;
maxVolunteers: number;
currentVolunteers: number;
}>;
delete(id: string): Promise<void>;
createMeetingForShift(shiftId: string, userId: string): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
title: string;
isActive: boolean;
description: string | null;
slug: string;
createdByUserId: string;
jitsiRoom: string;
startTime: Date | null;
endTime: Date | null;
}>;
removeMeetingFromShift(shiftId: string): Promise<void>;
getStats(): Promise<{
total: number;
open: number;
@@ -140,24 +176,24 @@ export declare const shiftsService: {
id: string;
userId: string | null;
userEmail: string;
shiftId: 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;
shiftId: 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<{
@@ -166,12 +202,12 @@ export declare const shiftsService: {
id: string;
userId: string | null;
userEmail: string;
shiftId: string;
userName: string | null;
shiftTitle: string | null;
userPhone: string | null;
signupDate: Date;
signupSource: import(".prisma/client").$Enums.SignupSource;
shiftId: string;
};
isNewUser: boolean;
}>;
@@ -184,6 +220,11 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
meeting: {
id: string;
isActive: boolean;
slug: string;
} | null;
startTime: string;
endTime: string;
maxVolunteers: number;
@@ -194,12 +235,12 @@ export declare const shiftsService: {
id: string;
userId: string | null;
userEmail: string;
shiftId: 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<({
@@ -210,6 +251,11 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
meeting: {
id: string;
isActive: boolean;
slug: string;
} | null;
startTime: string;
endTime: string;
maxVolunteers: number;
@@ -220,12 +266,12 @@ export declare const shiftsService: {
id: string;
userId: string | null;
userEmail: string;
shiftId: 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;
@@ -234,6 +280,11 @@ export declare const shiftsService: {
location: string | null;
title: string;
description: string | null;
meeting: {
id: string;
isActive: boolean;
slug: string;
} | null;
startTime: string;
endTime: string;
maxVolunteers: number;

View File

@@ -1 +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"}
{"version":3,"file":"shifts.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/shifts/shifts.service.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,iBAAiB,EAClB,MAAM,kBAAkB,CAAC;AAoB1B,eAAO,MAAM,aAAa;qBACD,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwDnB,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA0BN,gBAAgB,UAAU,MAAM;;;;;;;;;;;;;;;;;;;;;eA2ClC,MAAM,QAAQ,gBAAgB;;;;;;;;;;;;;;;;;;;;;eA8C9B,MAAM;mCAwBc,MAAM,UAAU,MAAM;;;;;;;;;;;;;oCAyBrB,MAAM;;;;;;;;;wBA4BlB,MAAM;;;;;;;;;;;;;;;;;;;uBAaP,MAAM,QAAQ,cAAc;;;;;;;;;;;;2BA2ExB,MAAM;0BA4BP,MAAM,QAAQ,iBAAiB;;;;;;;;;;;;;;;gCAuQzB,MAAM,aAAa,MAAM;oCAuGrB,MAAM;;;;;;;;;;;;;;;;;;6BA4Cb,MAAM,UAAU,MAAM;;;;;;;;;;;;mCAsLhB,MAAM,UAAU,MAAM;yBAqGhC,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA8DA,MAAM;;;;+BAuDN,MAAM,WAAW,MAAM;;mBAiBf,MAAM;oBAAU,GAAG,EAAE;;;CAe/D,CAAC"}

View File

@@ -9,10 +9,21 @@ 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 notification_queue_service_1 = require("../../../services/notification-queue.service");
const notification_helper_1 = require("../../../services/notification.helper");
const env_1 = require("../../../config/env");
const logger_1 = require("../../../utils/logger");
const metrics_1 = require("../../../utils/metrics");
const rocketchat_webhook_service_1 = require("../../../services/rocketchat-webhook.service");
const listmonk_event_sync_service_1 = require("../../../services/listmonk-event-sync.service");
const gancio_client_1 = require("../../../services/gancio.client");
const unified_calendar_service_1 = require("../../events/unified-calendar.service");
const group_service_1 = require("../../social/group.service");
const achievements_service_1 = require("../../social/achievements.service");
const slug_1 = require("../../../utils/slug");
const settings_service_1 = require("../../settings/settings.service");
const sms_notification_service_1 = require("../../../services/sms-notification.service");
const crypto_1 = __importDefault(require("crypto"));
const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
function generateReadablePassword() {
@@ -21,6 +32,13 @@ function generateReadablePassword() {
const num = Math.floor(Math.random() * 90) + 10;
return `${adj}${noun}${num}`;
}
const meetingSelect = {
id: true,
slug: true,
title: true,
isActive: true,
jitsiRoom: true,
};
exports.shiftsService = {
async findAll(filters) {
const { page, limit, search, status, upcoming, sortBy, sortOrder } = filters;
@@ -50,6 +68,7 @@ exports.shiftsService = {
orderBy,
include: {
cut: { select: { id: true, name: true } },
meeting: { select: meetingSelect },
_count: {
select: {
signups: { where: { status: client_1.SignupStatus.CONFIRMED } },
@@ -74,6 +93,7 @@ exports.shiftsService = {
where: { id },
include: {
cut: { select: { id: true, name: true } },
meeting: { select: meetingSelect },
signups: {
where: { status: client_1.SignupStatus.CONFIRMED },
include: { user: { select: { id: true, email: true, name: true, phone: true } } },
@@ -106,6 +126,28 @@ exports.shiftsService = {
createdBy: userId,
},
});
// Gancio event sync (fire-and-forget)
if (gancio_client_1.gancioClient.enabled) {
gancio_client_1.gancioClient.createEvent({
title: shift.title,
description: shift.description,
location: shift.location,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
}).then(async (eventId) => {
if (eventId) {
await database_1.prisma.shift.update({
where: { id: shift.id },
data: { gancioEventId: eventId },
});
}
}).catch((err) => {
logger_1.logger.warn('Gancio sync on shift create failed:', err);
});
}
// Bust unified calendar cache
unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { });
return shift;
},
async update(id, data) {
@@ -130,6 +172,21 @@ exports.shiftsService = {
where: { id },
data: updateData,
});
// Gancio event sync (fire-and-forget)
if (gancio_client_1.gancioClient.enabled && shift.gancioEventId) {
gancio_client_1.gancioClient.updateEvent(shift.gancioEventId, {
title: shift.title,
description: shift.description,
location: shift.location,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
}).catch((err) => {
logger_1.logger.warn('Gancio sync on shift update failed:', err);
});
}
// Bust unified calendar cache
unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { });
return shift;
},
async delete(id) {
@@ -137,7 +194,56 @@ exports.shiftsService = {
if (!existing) {
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
// Delete Gancio event before deleting shift (fire-and-forget)
if (gancio_client_1.gancioClient.enabled && existing.gancioEventId) {
gancio_client_1.gancioClient.deleteEvent(existing.gancioEventId).catch((err) => {
logger_1.logger.warn('Gancio sync on shift delete failed:', err);
});
}
// Delete associated meeting if exists
if (existing.meetingId) {
await database_1.prisma.meeting.delete({ where: { id: existing.meetingId } }).catch(() => { });
}
await database_1.prisma.shift.delete({ where: { id } });
// Bust unified calendar cache
unified_calendar_service_1.unifiedCalendarService.bustCache().catch(() => { });
},
async createMeetingForShift(shiftId, userId) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift)
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
if (shift.meetingId)
throw new error_handler_1.AppError(400, 'Shift already has a meeting', 'MEETING_EXISTS');
const settings = await settings_service_1.siteSettingsService.get();
if (!settings.enableMeet)
throw new error_handler_1.AppError(400, 'Video meetings are not enabled', 'MEET_DISABLED');
const meeting = await database_1.prisma.meeting.create({
data: {
slug: (0, slug_1.generateSlug)(shift.title),
title: `${shift.title} — Video Briefing`,
jitsiRoom: crypto_1.default.randomUUID(),
createdByUserId: userId,
},
});
await database_1.prisma.shift.update({
where: { id: shiftId },
data: { meetingId: meeting.id },
});
return meeting;
},
async removeMeetingFromShift(shiftId) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift)
throw new error_handler_1.AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
if (!shift.meetingId)
throw new error_handler_1.AppError(400, 'Shift has no meeting', 'NO_MEETING');
const meetingId = shift.meetingId;
await database_1.prisma.shift.update({
where: { id: shiftId },
data: { meetingId: null },
});
// Delete the meeting record
await database_1.prisma.meeting.delete({ where: { id: meetingId } }).catch(() => { });
},
async getStats() {
const [total, open, full, cancelled, upcoming, totalSignups] = await Promise.all([
@@ -214,6 +320,18 @@ exports.shiftsService = {
},
}),
]);
// Listmonk event sync
listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({
email: data.userEmail,
name: data.userName || data.userEmail,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => { });
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { });
// Achievement check (fire-and-forget)
if (user?.id)
achievements_service_1.achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => { });
return signup;
},
async removeSignup(signupId) {
@@ -237,6 +355,8 @@ exports.shiftsService = {
},
}),
]);
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(signup.shiftId).catch(() => { });
},
async publicSignup(shiftId, data) {
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
@@ -279,6 +399,7 @@ exports.shiftsService = {
name: data.name,
phone: data.phone,
role: 'TEMP',
roles: JSON.parse(JSON.stringify(['TEMP'])),
createdVia: 'PUBLIC_SHIFT_SIGNUP',
expiresAt: shiftDate,
},
@@ -339,38 +460,142 @@ exports.shiftsService = {
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,
await email_service_1.emailService.sendShiftSignupConfirmation({
recipientEmail: data.email,
recipientName: data.name,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser,
tempPassword,
loginUrl: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
}
catch (err) {
logger_1.logger.error('Failed to send shift signup confirmation email:', err);
}
// SMS signup confirmation (fire-and-forget)
if (data.phone) {
const shiftDate = new Date(shift.date);
const smsDateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
sms_notification_service_1.smsNotificationService.sendShiftSignupConfirmation(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftDate: smsDateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
}).catch(err => logger_1.logger.error('SMS signup confirmation failed:', err));
}
// Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchat_webhook_service_1.rocketchatWebhookService.onShiftSignup({
userName: data.name || data.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => { });
// Notification: admin shift signup alert
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftSignup')) {
const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'admin-shift-signup',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: data.name,
volunteerEmail: data.email,
signupSource: 'Public Form',
adminUrl,
});
}
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue admin shift signup notification:', err);
}
// Notification: schedule 24h pre-shift reminder
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerShiftReminder')) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.scheduleShiftReminder({
type: 'volunteer-shift-reminder',
recipientEmail: data.email,
recipientName: data.name,
shiftTitle: shift.title,
shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers + 1,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
}, shiftDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to schedule shift reminder:', err);
}
// SMS shift reminder (fire-and-forget, delay calculated by notification service)
if (data.phone) {
const smsShiftDatetime = new Date(shift.date);
const [smsH, smsM] = shift.startTime.split(':').map(Number);
smsShiftDatetime.setHours(smsH || 0, smsM || 0, 0, 0);
sms_notification_service_1.smsNotificationService.sendShiftReminder(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftTime: shift.startTime,
shiftLocation: shift.location || 'TBD',
}, smsShiftDatetime).catch(err => logger_1.logger.error('SMS shift reminder failed:', err));
}
// Notification: schedule post-shift thank-you (2h after end)
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerShiftThankYou')) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notification_queue_service_1.notificationQueueService.scheduleShiftThankYou({
type: 'volunteer-shift-thank-you',
volunteerEmail: data.email,
volunteerName: data.name,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
signupUrl,
}, shiftEndDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to schedule shift thank-you:', err);
}
(0, metrics_1.recordShiftSignup)();
// Listmonk event sync
listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({
email: data.email,
name: data.name,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => { });
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { });
// Achievement check (fire-and-forget)
if (user?.id)
achievements_service_1.achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => { });
return { signup, isNewUser };
},
async cancelPublicSignup(shiftId, userEmail) {
@@ -383,6 +608,7 @@ exports.shiftsService = {
if (signup.status === client_1.SignupStatus.CANCELLED) {
throw new error_handler_1.AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED');
}
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: signup.id },
@@ -396,6 +622,75 @@ exports.shiftsService = {
},
}),
]);
// Notification: cancellation acknowledgement + cancel reminder
try {
if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerCancellation')) {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'volunteer-cancellation',
volunteerEmail: userEmail,
volunteerName: signup.userName || userEmail,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
signupUrl,
});
}
// Cancel the pending shift reminder
if (shift) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.cancelShiftReminder(userEmail, shiftDatetime);
}
// Cancel the pending shift thank-you
if (shift) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.cancelShiftThankYou(userEmail, shiftEndDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue cancellation notification:', err);
}
// Notify Rocket.Chat of cancellation
if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchat_webhook_service_1.rocketchatWebhookService.onShiftCancellation({
userName: signup.userName || userEmail,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => { });
}
// Notification: admin shift cancellation alert
try {
if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftCancellation')) {
const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'admin-shift-cancellation',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: signup.userName || userEmail,
volunteerEmail: userEmail,
cancellationSource: 'Public Form',
adminUrl,
});
}
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { });
},
async getUpcomingForVolunteer(userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
@@ -418,6 +713,7 @@ exports.shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
@@ -504,41 +800,115 @@ exports.shiftsService = {
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,
await email_service_1.emailService.sendShiftSignupConfirmation({
recipientEmail: user.email,
recipientName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser: false,
loginUrl: `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
}
catch (err) {
logger_1.logger.error('Failed to send volunteer shift signup confirmation email:', err);
}
// Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchat_webhook_service_1.rocketchatWebhookService.onShiftSignup({
userName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => { });
// Notification: admin shift signup alert
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftSignup')) {
const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'admin-shift-signup',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: user.name || user.email,
volunteerEmail: user.email,
signupSource: 'Authenticated Volunteer',
adminUrl,
});
}
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue admin shift signup notification:', err);
}
// Notification: schedule 24h pre-shift reminder
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerShiftReminder')) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.scheduleShiftReminder({
type: 'volunteer-shift-reminder',
recipientEmail: user.email,
recipientName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers + 1,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
}, shiftDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to schedule shift reminder:', err);
}
// Notification: schedule post-shift thank-you (2h after end)
try {
if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerShiftThankYou')) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notification_queue_service_1.notificationQueueService.scheduleShiftThankYou({
type: 'volunteer-shift-thank-you',
volunteerEmail: user.email,
volunteerName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
signupUrl,
}, shiftEndDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to schedule shift thank-you:', err);
}
// Listmonk event sync
listmonk_event_sync_service_1.listmonkEventSyncService.onShiftSignup({
email: user.email,
name: user.name || user.email,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => { });
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { });
// Achievement check (fire-and-forget)
achievements_service_1.achievementsService.checkAndUnlock(userId, ['shifts']).catch(() => { });
return signup;
},
async cancelVolunteerSignup(shiftId, userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
if (!user)
throw new error_handler_1.AppError(404, 'User not found', 'USER_NOT_FOUND');
const signup = await database_1.prisma.shiftSignup.findUnique({
@@ -548,6 +918,7 @@ exports.shiftsService = {
throw new error_handler_1.AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND');
if (signup.status === client_1.SignupStatus.CANCELLED)
throw new error_handler_1.AppError(400, 'Already cancelled', 'ALREADY_CANCELLED');
const shift = await database_1.prisma.shift.findUnique({ where: { id: shiftId } });
await database_1.prisma.$transaction([
database_1.prisma.shiftSignup.update({
where: { id: signup.id },
@@ -561,6 +932,75 @@ exports.shiftsService = {
},
}),
]);
// Notification: cancellation acknowledgement + cancel reminder
try {
if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerCancellation')) {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env_1.env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'volunteer-cancellation',
volunteerEmail: user.email,
volunteerName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
signupUrl,
});
}
// Cancel the pending shift reminder
if (shift) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.cancelShiftReminder(user.email, shiftDatetime);
}
// Cancel the pending shift thank-you
if (shift) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
await notification_queue_service_1.notificationQueueService.cancelShiftThankYou(user.email, shiftEndDatetime);
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue cancellation notification:', err);
}
// Notify Rocket.Chat of cancellation
if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchat_webhook_service_1.rocketchatWebhookService.onShiftCancellation({
userName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => { });
}
// Notification: admin shift cancellation alert
try {
if (shift && await (0, notification_helper_1.isNotificationEnabled)('notifyAdminShiftCancellation')) {
const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notification_queue_service_1.notificationQueueService.enqueue({
type: 'admin-shift-cancellation',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: user.name || user.email,
volunteerEmail: user.email,
cancellationSource: 'Volunteer Portal',
adminUrl,
});
}
}
}
catch (err) {
logger_1.logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
// Social group sync (fire-and-forget)
group_service_1.groupService.syncShiftTeam(shiftId).catch(() => { });
},
async getMySignups(userId) {
const user = await database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
@@ -588,6 +1028,7 @@ exports.shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
},
},
@@ -613,6 +1054,7 @@ exports.shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
@@ -637,37 +1079,22 @@ exports.shiftsService = {
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,
const result = await email_service_1.emailService.sendShiftDetailsEmail({
recipientEmail: signup.userEmail,
recipientName: signup.userName || signup.userEmail,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
});
if (result.success) {
sent++;

File diff suppressed because one or more lines are too long