From 05f088877b39b2d80a1e1326c2c13db9f8468776 Mon Sep 17 00:00:00 2001 From: aprylle-1 Date: Sat, 9 May 2026 04:22:58 +1000 Subject: [PATCH] feat: added guard score functionality --- .../src/controllers/user.controller.js | 22 ++++ app-backend/src/routes/user.routes.js | 34 ++++++ .../src/services/guardScore.service.js | 102 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 app-backend/src/services/guardScore.service.js diff --git a/app-backend/src/controllers/user.controller.js b/app-backend/src/controllers/user.controller.js index 11b779a94..79f1d8bb8 100644 --- a/app-backend/src/controllers/user.controller.js +++ b/app-backend/src/controllers/user.controller.js @@ -1,5 +1,7 @@ +import mongoose from 'mongoose'; import User from '../models/User.js'; import { ACTIONS } from "../middleware/logger.js"; +import { computeGuardScore } from '../services/guardScore.service.js'; /** * @desc View logged-in user's profile @@ -265,6 +267,26 @@ export const removeFavouriteGuard = async (req, res) => { }; +/** + * @desc Get guard performance score (0–100) + * @route GET /api/v1/users/guards/:id/score + * @access Private (self, employer, admin) + */ +export const getGuardScore = async (req, res) => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: 'Invalid guard ID' }); + } + + const result = await computeGuardScore(id); + return res.status(200).json({ success: true, data: result }); + } catch (err) { + return res.status(err.statusCode ?? 500).json({ message: err.message }); + } +}; + /** * @desc Get favourite guards list * @route GET /api/v1/users/favourites diff --git a/app-backend/src/routes/user.routes.js b/app-backend/src/routes/user.routes.js index b3e106bf5..1d1008f38 100644 --- a/app-backend/src/routes/user.routes.js +++ b/app-backend/src/routes/user.routes.js @@ -6,6 +6,7 @@ import { authorizeRoles, authorizePermissions, requireSameBranchAsTargetUser, + requireSelfOrRoles, ROLES, } from '../middleware/rbac.js'; import { @@ -16,6 +17,7 @@ import { adminGetUserProfile, adminUpdateUserProfile, getAllGuards, + getGuardScore, listUsers, deleteUser, addFavouriteGuard, @@ -273,6 +275,38 @@ router.get( getAllGuards ); +/** + * @swagger + * /api/v1/users/guards/{id}/score: + * get: + * summary: Get a guard's performance score (0–100) + * tags: [Users] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guard ID + * responses: + * 200: + * description: Guard score and breakdown + * 400: + * description: Invalid guard ID + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + */ +router.get( + '/guards/:id/score', + auth, + requireSelfOrRoles({ paramKey: 'id', roles: [ROLES.EMPLOYER, ROLES.ADMIN] }), + getGuardScore +); + /** * @swagger * /api/v1/users: diff --git a/app-backend/src/services/guardScore.service.js b/app-backend/src/services/guardScore.service.js new file mode 100644 index 000000000..77969fd6c --- /dev/null +++ b/app-backend/src/services/guardScore.service.js @@ -0,0 +1,102 @@ +import mongoose from 'mongoose'; +import Shift from '../models/Shift.js'; +import ShiftAttendance from '../models/ShiftAttendance.js'; +import Incident from '../models/Incident.js'; +import { ErrorResponse } from '../utils/errorResponse.js'; + +const GRACE_PERIOD_MS = 5 * 60 * 1000; + +function buildShiftStartDate(shiftDate, startTime) { + const [hour, minute] = String(startTime).split(':').map(Number); + const d = new Date(shiftDate); + d.setUTCHours(hour, minute, 0, 0); + return d; +} + +export async function computeGuardScore(guardId) { + if (!mongoose.Types.ObjectId.isValid(guardId)) { + throw new ErrorResponse('Invalid guard ID', 400); + } + + const guardObjectId = new mongoose.Types.ObjectId(guardId); + + const [assignedShifts, attendanceRecords, incidentCounts] = await Promise.all([ + Shift.find({ + acceptedBy: guardObjectId, + status: { $in: ['assigned', 'completed'] }, + }).lean(), + + ShiftAttendance.find({ + guardId: guardObjectId, + checkInTime: { $ne: null }, + }) + .populate('shiftId', 'date startTime') + .lean(), + + Incident.aggregate([ + { $match: { guardId: guardObjectId, isDeleted: { $ne: true } } }, + { $group: { _id: '$severity', count: { $sum: 1 } } }, + ]), + ]); + + const totalAssigned = assignedShifts.length; + + if (totalAssigned === 0) { + return { score: null, reason: 'Insufficient shift data' }; + } + + // Component 1 — Punctuality (35 pts) + const validAttendance = attendanceRecords.filter((r) => r.shiftId?.date && r.shiftId?.startTime); + const totalCheckins = validAttendance.length; + const onTimeCheckins = validAttendance.filter((r) => { + const cutoff = buildShiftStartDate(r.shiftId.date, r.shiftId.startTime).getTime() + GRACE_PERIOD_MS; + return new Date(r.checkInTime).getTime() <= cutoff; + }).length; + const punctualityScore = totalCheckins > 0 ? (onTimeCheckins / totalCheckins) * 35 : 0; + + // Component 2 — Shift Completion (35 pts) + const completedShifts = assignedShifts.filter((s) => s.status === 'completed').length; + const completionScore = (completedShifts / totalAssigned) * 35; + + // Component 3 — Incident Score (30 pts) + const severityCounts = incidentCounts.reduce((acc, { _id, count }) => { + acc[_id] = count; + return acc; + }, {}); + const highCount = severityCounts.high ?? 0; + const mediumCount = severityCounts.medium ?? 0; + const lowCount = severityCounts.low ?? 0; + const deduction = ((5 * highCount + 2 * mediumCount + 1 * lowCount) / Math.max(totalAssigned, 1)) * 10; + const incidentScore = Math.max(0, 30 - deduction); + + const score = Math.round( + Math.max(0, punctualityScore) + Math.max(0, completionScore) + incidentScore + ); + + return { + guardId, + score, + breakdown: { + punctuality: { + score: Math.round(Math.max(0, punctualityScore) * 100) / 100, + maxPoints: 35, + onTimeCheckins, + totalCheckins, + }, + shiftCompletion: { + score: Math.round(Math.max(0, completionScore) * 100) / 100, + maxPoints: 35, + completedShifts, + totalAssignedShifts: totalAssigned, + }, + incidents: { + score: Math.round(incidentScore * 100) / 100, + maxPoints: 30, + high: highCount, + medium: mediumCount, + low: lowCount, + deduction: Math.round(deduction * 100) / 100, + }, + }, + }; +}