Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions app-backend/src/controllers/shift.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -566,7 +597,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)) {
Expand Down Expand Up @@ -645,6 +676,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];
Expand Down
3 changes: 2 additions & 1 deletion app-backend/src/middleware/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
55 changes: 49 additions & 6 deletions app-backend/src/routes/shift.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
*/
Expand Down Expand Up @@ -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')
Expand Down
206 changes: 206 additions & 0 deletions app-backend/src/services/fatigue.service.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
Loading
Loading