From af6a050e744d3c0a9d8f7a15642292c69f25fb9d Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Thu, 7 May 2026 19:18:27 +0930 Subject: [PATCH 1/4] feat(shifts): add fatigue monitoring for guard approval --- .../src/controllers/shift.controller.js | 19 +- app-backend/src/middleware/logger.js | 3 +- app-backend/src/services/fatigue.service.js | 206 ++++++++++++++++++ .../tests/services/fatigue.service.test.js | 137 ++++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 app-backend/src/services/fatigue.service.js create mode 100644 app-backend/src/tests/services/fatigue.service.test.js diff --git a/app-backend/src/controllers/shift.controller.js b/app-backend/src/controllers/shift.controller.js index 02716c9ef..7b8b90e7d 100644 --- a/app-backend/src/controllers/shift.controller.js +++ b/app-backend/src/controllers/shift.controller.js @@ -3,7 +3,7 @@ import Shift from '../models/Shift.js'; import Branch from '../models/Branch.js'; import Guard from '../models/Guard.js'; import Availability from '../models/Availability.js'; -import ShiftAttendance from '../models/ShiftAttendance.js'; +import { assessGuardFatigue } from '../services/fatigue.service.js'; import { ACTIONS } from "../middleware/logger.js"; @@ -566,7 +566,7 @@ export const applyForShift = async (req, res) => { const shift = await Shift.findById(id); if (!shift) return res.status(404).json({ message: 'Shift not found' }); if (shift.status !== 'open') { - return res.status(400).json({ message: 'Can only apply to open shifts' }); + return res.status(400).json({ message: 'Can only apply to open shifts' }); } if (['assigned', 'completed'].includes(shift.status)) { @@ -645,6 +645,21 @@ export const approveShift = async (req, res) => { return res.status(400).json({ message: 'Guard did not apply for this shift' }); } + const fatigueAssessment = await assessGuardFatigue(guardId, shift); + + if (fatigueAssessment.isFatigued) { + await req.audit.log(req.user._id, ACTIONS.SHIFT_FATIGUE_BLOCKED, { + shiftId: shift._id, + guardId, + fatigueAssessment, + }); + + return res.status(400).json({ + message: 'Shift approval blocked due to guard fatigue rules', + fatigueAssessment, + }); + } + shift.assignedGuard = guardId; // virtual -> acceptedBy shift.status = 'assigned'; if (!keepOthers) shift.applicants = [guardId]; diff --git a/app-backend/src/middleware/logger.js b/app-backend/src/middleware/logger.js index 90d3caf5d..79980374f 100644 --- a/app-backend/src/middleware/logger.js +++ b/app-backend/src/middleware/logger.js @@ -14,12 +14,13 @@ export const ACTIONS = { SHIFT_APPLIED: 'SHIFT_APPLIED', SHIFT_APPROVED: 'SHIFT_APPROVED', SHIFT_COMPLETED: 'SHIFT_COMPLETED', + SHIFT_FATIGUE_BLOCKED: 'SHIFT_FATIGUE_BLOCKED', VIEW_USERS: 'VIEW_USERS', VIEW_SHIFTS: 'VIEW_SHIFTS', MESSAGE_SENT: 'MESSAGE_SENT', - MESSAGE_READ: 'MESSAGE_READ', + MESSAGE_READ: 'MESSAGE_READ', MESSAGE_SOFT_DELETED: 'MESSAGE_SOFT_DELETED', USER_SOFT_DELETED: 'USER_SOFT_DELETED', diff --git a/app-backend/src/services/fatigue.service.js b/app-backend/src/services/fatigue.service.js new file mode 100644 index 000000000..3006c46dc --- /dev/null +++ b/app-backend/src/services/fatigue.service.js @@ -0,0 +1,206 @@ +import Shift from '../models/Shift.js'; + +export const FATIGUE_RULES = { + maxShiftsPerWeek: 5, + maxHoursPerDay: 10, + maxHoursPerWeek: 40, +}; + +const MINUTES_PER_DAY = 24 * 60; + +export const timeToMinutes = (time) => { + if (typeof time !== 'string') { + throw new Error('Time must be a string in HH:MM format'); + } + + const match = time.match(/^([0-1]\d|2[0-3]):([0-5]\d)$/); + if (!match) { + throw new Error('Time must be in HH:MM format'); + } + + const hours = Number(match[1]); + const minutes = Number(match[2]); + + return hours * 60 + minutes; +}; + +export const calculateShiftHours = (startTime, endTime) => { + const startMinutes = timeToMinutes(startTime); + const endMinutes = timeToMinutes(endTime); + + const durationMinutes = + endMinutes > startMinutes + ? endMinutes - startMinutes + : endMinutes - startMinutes + MINUTES_PER_DAY; + + if (durationMinutes <= 0) { + throw new Error('Shift duration must be greater than zero'); + } + + return durationMinutes / 60; +}; + +const getStartOfWeek = (date) => { + const start = new Date(date); + const day = start.getDay(); + const diffToMonday = day === 0 ? -6 : 1 - day; + + start.setDate(start.getDate() + diffToMonday); + start.setHours(0, 0, 0, 0); + + return start; +}; + +const getEndOfWeek = (date) => { + const end = getStartOfWeek(date); + + end.setDate(end.getDate() + 6); + end.setHours(23, 59, 59, 999); + + return end; +}; + +const isSameShiftDate = (shiftDate, targetDate) => { + const first = new Date(shiftDate); + const second = new Date(targetDate); + + return ( + first.getFullYear() === second.getFullYear() && + first.getMonth() === second.getMonth() && + first.getDate() === second.getDate() + ); +}; + +export const calculateFatigueScore = ({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, +}) => { + const shiftLoad = Math.min( + shiftsThisWeek / FATIGUE_RULES.maxShiftsPerWeek, + 1 + ); + + const dailyLoad = Math.min( + hoursThisDay / FATIGUE_RULES.maxHoursPerDay, + 1 + ); + + const weeklyLoad = Math.min( + hoursThisWeek / FATIGUE_RULES.maxHoursPerWeek, + 1 + ); + + return Math.round(((shiftLoad + dailyLoad + weeklyLoad) / 3) * 100); +}; + +export const buildFatigueWarnings = ({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, +}) => { + const warnings = []; + + if (shiftsThisWeek > FATIGUE_RULES.maxShiftsPerWeek) { + warnings.push( + `Guard exceeds recommended weekly shift limit of ${FATIGUE_RULES.maxShiftsPerWeek} shifts` + ); + } + + if (hoursThisDay > FATIGUE_RULES.maxHoursPerDay) { + warnings.push( + `Guard exceeds recommended daily hour limit of ${FATIGUE_RULES.maxHoursPerDay} hours` + ); + } + + if (hoursThisWeek > FATIGUE_RULES.maxHoursPerWeek) { + warnings.push( + `Guard exceeds recommended weekly hour limit of ${FATIGUE_RULES.maxHoursPerWeek} hours` + ); + } + + return warnings; +}; + +export const assessFatigueFromMetrics = ({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, +}) => { + const fatigueScore = calculateFatigueScore({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, + }); + + const warnings = buildFatigueWarnings({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, + }); + + return { + fatigueScore, + warnings, + isFatigued: warnings.length > 0, + metrics: { + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, + }, + }; +}; + +export const assessGuardFatigue = async (guardId, proposedShift) => { + if (!guardId) { + throw new Error('Guard ID is required for fatigue assessment'); + } + + if (!proposedShift?.date || !proposedShift?.startTime || !proposedShift?.endTime) { + throw new Error( + 'Shift date, start time, and end time are required for fatigue assessment' + ); + } + + const proposedDate = new Date(proposedShift.date); + if (Number.isNaN(proposedDate.getTime())) { + throw new Error('Shift date must be valid for fatigue assessment'); + } + + const weekStart = getStartOfWeek(proposedDate); + const weekEnd = getEndOfWeek(proposedDate); + + const existingShifts = await Shift.find({ + acceptedBy: guardId, + status: { $in: ['assigned', 'completed'] }, + date: { + $gte: weekStart, + $lte: weekEnd, + }, + }).select('date startTime endTime status acceptedBy'); + + const proposedShiftHours = calculateShiftHours( + proposedShift.startTime, + proposedShift.endTime + ); + + const existingWeeklyHours = existingShifts.reduce((total, shift) => { + return total + calculateShiftHours(shift.startTime, shift.endTime); + }, 0); + + const existingDailyHours = existingShifts + .filter((shift) => isSameShiftDate(shift.date, proposedDate)) + .reduce((total, shift) => { + return total + calculateShiftHours(shift.startTime, shift.endTime); + }, 0); + + const shiftsThisWeek = existingShifts.length + 1; + const hoursThisDay = existingDailyHours + proposedShiftHours; + const hoursThisWeek = existingWeeklyHours + proposedShiftHours; + + return assessFatigueFromMetrics({ + shiftsThisWeek, + hoursThisDay, + hoursThisWeek, + }); +}; diff --git a/app-backend/src/tests/services/fatigue.service.test.js b/app-backend/src/tests/services/fatigue.service.test.js new file mode 100644 index 000000000..cf4b9a59f --- /dev/null +++ b/app-backend/src/tests/services/fatigue.service.test.js @@ -0,0 +1,137 @@ +/* global describe, it, expect */ + +import { + assessFatigueFromMetrics, + buildFatigueWarnings, + calculateFatigueScore, + calculateShiftHours, +} from '../../services/fatigue.service.js'; + +describe('fatigue.service', () => { + describe('calculateShiftHours', () => { + it('calculates same-day shift duration', () => { + expect(calculateShiftHours('08:00', '16:00')).toBe(8); + }); + + it('calculates overnight shift duration', () => { + expect(calculateShiftHours('22:00', '06:00')).toBe(8); + }); + + it('throws when time format is invalid', () => { + expect(() => calculateShiftHours('8am', '16:00')).toThrow( + 'Time must be in HH:MM format' + ); + }); + }); + + describe('calculateFatigueScore', () => { + it('calculates a low fatigue score for a light workload', () => { + const score = calculateFatigueScore({ + shiftsThisWeek: 2, + hoursThisDay: 4, + hoursThisWeek: 12, + }); + + expect(score).toBeLessThan(50); + }); + + it('returns 100 when all fatigue limits are reached', () => { + const score = calculateFatigueScore({ + shiftsThisWeek: 5, + hoursThisDay: 10, + hoursThisWeek: 40, + }); + + expect(score).toBe(100); + }); + + it('caps load contribution at 100 percent', () => { + const score = calculateFatigueScore({ + shiftsThisWeek: 10, + hoursThisDay: 20, + hoursThisWeek: 80, + }); + + expect(score).toBe(100); + }); + }); + + describe('buildFatigueWarnings', () => { + it('returns no warnings when workload is within limits', () => { + const warnings = buildFatigueWarnings({ + shiftsThisWeek: 5, + hoursThisDay: 10, + hoursThisWeek: 40, + }); + + expect(warnings).toEqual([]); + }); + + it('warns when weekly shift limit is exceeded', () => { + const warnings = buildFatigueWarnings({ + shiftsThisWeek: 6, + hoursThisDay: 8, + hoursThisWeek: 32, + }); + + expect(warnings).toContain( + 'Guard exceeds recommended weekly shift limit of 5 shifts' + ); + }); + + it('warns when daily hour limit is exceeded', () => { + const warnings = buildFatigueWarnings({ + shiftsThisWeek: 4, + hoursThisDay: 12, + hoursThisWeek: 32, + }); + + expect(warnings).toContain( + 'Guard exceeds recommended daily hour limit of 10 hours' + ); + }); + + it('warns when weekly hour limit is exceeded', () => { + const warnings = buildFatigueWarnings({ + shiftsThisWeek: 4, + hoursThisDay: 8, + hoursThisWeek: 44, + }); + + expect(warnings).toContain( + 'Guard exceeds recommended weekly hour limit of 40 hours' + ); + }); + }); + + describe('assessFatigueFromMetrics', () => { + it('returns a non-fatigued assessment when no limits are exceeded', () => { + const assessment = assessFatigueFromMetrics({ + shiftsThisWeek: 3, + hoursThisDay: 8, + hoursThisWeek: 24, + }); + + expect(assessment.isFatigued).toBe(false); + expect(assessment.warnings).toEqual([]); + expect(assessment.fatigueScore).toBeLessThan(100); + }); + + it('returns a fatigued assessment when limits are exceeded', () => { + const assessment = assessFatigueFromMetrics({ + shiftsThisWeek: 6, + hoursThisDay: 12, + hoursThisWeek: 48, + }); + + expect(assessment.isFatigued).toBe(true); + expect(assessment.warnings).toHaveLength(3); + expect(assessment.fatigueScore).toBe(100); + expect(assessment.metrics).toEqual({ + shiftsThisWeek: 6, + hoursThisDay: 12, + hoursThisWeek: 48, + }); + }); + }); +}); From 348c0b54f51db33e688dd01e194d2a439aa5310c Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Mon, 11 May 2026 14:05:20 +0930 Subject: [PATCH 2/4] docs(swagger): document shift fatigue approval response --- app-backend/src/routes/shift.routes.js | 53 +++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/app-backend/src/routes/shift.routes.js b/app-backend/src/routes/shift.routes.js index 57f5be738..0c2fbceae 100644 --- a/app-backend/src/routes/shift.routes.js +++ b/app-backend/src/routes/shift.routes.js @@ -324,11 +324,54 @@ router * type: boolean * default: false * responses: - * 200: { description: Guard approved and shift assigned } - * 400: { description: Guard not in applicants or invalid state } - * 401: { description: Unauthorized } - * 403: { description: Forbidden } - * 404: { description: Shift not found } + * 200: + * description: Guard approved and shift assigned + * 400: + * description: Invalid request, invalid shift state, guard not in applicants, or fatigue rule breach + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Shift approval blocked due to guard fatigue rules + * fatigueAssessment: + * type: object + * description: Returned when approval is blocked by fatigue monitoring rules + * properties: + * fatigueScore: + * type: number + * example: 100 + * warnings: + * type: array + * items: + * type: string + * example: + * - Guard exceeds recommended weekly shift limit of 5 shifts + * - Guard exceeds recommended daily hour limit of 10 hours + * - Guard exceeds recommended weekly hour limit of 40 hours + * isFatigued: + * type: boolean + * example: true + * metrics: + * type: object + * properties: + * shiftsThisWeek: + * type: number + * example: 6 + * hoursThisDay: + * type: number + * example: 12 + * hoursThisWeek: + * type: number + * example: 48 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Shift not found */ router .route('/:id/approve') From 13db189c66a152fb0e79d92d85e1600654a42436 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Mon, 11 May 2026 14:39:37 +0930 Subject: [PATCH 3/4] feat(shifts): enforce fatigue rules during shift creation --- .../src/controllers/shift.controller.js | 31 +++++++++++++++++++ app-backend/src/routes/shift.routes.js | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app-backend/src/controllers/shift.controller.js b/app-backend/src/controllers/shift.controller.js index 7b8b90e7d..6c8d632e7 100644 --- a/app-backend/src/controllers/shift.controller.js +++ b/app-backend/src/controllers/shift.controller.js @@ -284,6 +284,37 @@ export const createShift = async (req, res) => { } finalStatus = status; } + // Enforce fatigue rules for pre-selected guards during shift creation. + const fatigueAssessments = await Promise.all( + normalizedGuardIds.map(async (guardId) => { + const fatigueAssessment = await assessGuardFatigue(guardId, { + date: d, + startTime, + endTime, + }); + + return { + guardId, + ...fatigueAssessment, + }; + }) + ); + + const fatiguedGuards = fatigueAssessments.filter( + (assessment) => assessment.isFatigued + ); + + if (fatiguedGuards.length > 0) { + await req.audit.log(req.user._id, ACTIONS.SHIFT_FATIGUE_BLOCKED, { + guardIds: fatiguedGuards.map((assessment) => assessment.guardId), + fatigueAssessments: fatiguedGuards, + }); + + return res.status(400).json({ + message: 'Shift creation blocked due to guard fatigue rules', + fatigueAssessments: fatiguedGuards, + }); + } const shift = await Shift.create({ title, date: d, diff --git a/app-backend/src/routes/shift.routes.js b/app-backend/src/routes/shift.routes.js index 0c2fbceae..9429e8f8d 100644 --- a/app-backend/src/routes/shift.routes.js +++ b/app-backend/src/routes/shift.routes.js @@ -467,8 +467,8 @@ router * responses: * 200: * description: Rating saved (guardRating or employerRating based on role) - * 400: - * description: Invalid state (not completed) or duplicate rating + * 400: + * description: Validation error, unavailable guard, shift clash, or fatigue rule breach * 401: * description: Unauthorized * 403: From 61d20eeee2a51994fbc2af9fcc9192cdd8e66b06 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Mon, 11 May 2026 14:50:52 +0930 Subject: [PATCH 4/4] docs(swagger): correct shift fatigue response documentation --- app-backend/src/routes/shift.routes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app-backend/src/routes/shift.routes.js b/app-backend/src/routes/shift.routes.js index 9429e8f8d..e8dec3042 100644 --- a/app-backend/src/routes/shift.routes.js +++ b/app-backend/src/routes/shift.routes.js @@ -177,7 +177,7 @@ const authorizeRole = (...allowed) => (req, res, next) => { * example: "Security experience preferred" * responses: * 201: { description: Shift created } - * 400: { description: Validation error, unavailable guard, or shift clash } + * 400: { description: Validation error, unavailable guard, shift clash, or fatigue rule breach } * 401: { description: Unauthorized } * 403: { description: Forbidden } */ @@ -467,8 +467,8 @@ router * responses: * 200: * description: Rating saved (guardRating or employerRating based on role) - * 400: - * description: Validation error, unavailable guard, shift clash, or fatigue rule breach + * 400: + * description: Invalid state (not completed) or duplicate rating * 401: * description: Unauthorized * 403: