From 41c5c8724e5b078d8015fac34695a747f414d0e1 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Sat, 2 May 2026 14:46:02 +0930 Subject: [PATCH] refactor(shifts): extract apply approve workflow service --- .../src/controllers/shift.controller.js | 108 +++----------- .../src/services/shiftApplication.service.js | 132 ++++++++++++++++++ 2 files changed, 151 insertions(+), 89 deletions(-) create mode 100644 app-backend/src/services/shiftApplication.service.js diff --git a/app-backend/src/controllers/shift.controller.js b/app-backend/src/controllers/shift.controller.js index 02716c9ef..b8638d447 100644 --- a/app-backend/src/controllers/shift.controller.js +++ b/app-backend/src/controllers/shift.controller.js @@ -3,12 +3,16 @@ 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 { ACTIONS } from "../middleware/logger.js"; import { timeToMinutes, normalizeEnd } from '../utils/timeUtils.js'; +import { + applyForShiftService, + approveShiftService, +} from '../services/shiftApplication.service.js'; + // Helpers const HHMM = /^([0-1]\d|2[0-3]):([0-5]\d)$/; const isValidHHMM = (s) => typeof s === 'string' && HHMM.test(s); @@ -555,64 +559,15 @@ export const listAvailableShifts = async (req, res) => { */ export const applyForShift = async (req, res) => { try { - const { id } = req.params; - if (!mongoose.isValidObjectId(id)) return res.status(400).json({ message: 'Invalid id' }); - - const userId = req.user?._id || req.user?.id; - if (!userId || !mongoose.isValidObjectId(String(userId))) { - return res.status(401).json({ message: 'Authenticated user id missing from context' }); - } - - 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' }); - } - - if (['assigned', 'completed'].includes(shift.status)) { - return res.status(400).json({ message: `Cannot apply; shift is ${shift.status}` }); - } - if (isInPastOrStarted(shift)) { - return res.status(400).json({ message: 'Cannot apply; shift already started or in the past' }); - } - if (String(shift.createdBy) === String(userId)) { - return res.status(400).json({ message: 'Employer cannot apply to own shift' }); - } - - // sanitize & dedupe - shift.applicants = (shift.applicants || []).filter(Boolean); - if (shift.applicants.some(a => String(a) === String(userId))) { - return res.status(400).json({ message: 'Already applied' }); - } - - const userShifts = await Shift.find({ - date: shift.date, - _id: { $ne: id }, - applicants: userId, - }); - - const hasOverlap = userShifts.some(existing => { - const newStart = timeToMinutes(shift.startTime); - const newEnd = normalizeEnd(shift.startTime, shift.endTime); - const exStart = timeToMinutes(existing.startTime); - const exEnd = normalizeEnd(existing.startTime, existing.endTime); - return newStart < exEnd && newEnd > exStart; + const result = await applyForShiftService({ + shiftId: req.params.id, + userId: req.user?._id || req.user?.id, + audit: req.audit, }); - if (hasOverlap) { - return res.status(400).json({ message: 'Cannot apply; shift overlaps with existing applied shift/s' }); - } - - shift.applicants.push(userId); - if (shift.status === 'open') shift.status = 'applied'; - - await shift.save(); - await req.audit.log(req.user._id, ACTIONS.SHIFT_APPLIED, { - shiftId: shift._id - }); - return res.json({ message: 'Application submitted', shift }); + return res.json(result); } catch (e) { - return res.status(500).json({ message: e.message }); + return res.status(e.statusCode || 500).json({ message: e.message }); } }; @@ -623,42 +578,17 @@ export const applyForShift = async (req, res) => { */ export const approveShift = async (req, res) => { try { - const { id } = req.params; - const { guardId, keepOthers = false } = req.body; - if (!mongoose.isValidObjectId(id) || !mongoose.isValidObjectId(guardId)) - return res.status(400).json({ message: 'Invalid id(s)' }); - - const shift = await Shift.findById(id); - if (!shift) return res.status(404).json({ message: 'Shift not found' }); - - const isOwner = String(shift.createdBy) === String(req.user._id); - const isAdmin = req.user.role === 'admin'; - if (!isOwner && !isAdmin) return res.status(403).json({ message: 'Not allowed' }); - - if (['assigned', 'completed'].includes(shift.status)) { - return res.status(400).json({ message: `Already ${shift.status}` }); - } - if (isInPastOrStarted(shift)) { - return res.status(400).json({ message: 'Cannot approve; shift already started or in the past' }); - } - if (!shift.applicants.some(a => String(a) === String(guardId))) { - return res.status(400).json({ message: 'Guard did not apply for this shift' }); - } - - shift.assignedGuard = guardId; // virtual -> acceptedBy - shift.status = 'assigned'; - if (!keepOthers) shift.applicants = [guardId]; - - await shift.save(); - await req.audit.log(req.user._id, ACTIONS.SHIFT_APPROVED, { - shiftId: shift._id, - approvedGuardId: guardId, - keepOthers + const result = await approveShiftService({ + shiftId: req.params.id, + guardId: req.body.guardId, + keepOthers: req.body.keepOthers ?? false, + user: req.user, + audit: req.audit, }); - return res.json({ message: 'Guard approved', shift }); + return res.json(result); } catch (e) { - return res.status(500).json({ message: e.message }); + return res.status(e.statusCode || 500).json({ message: e.message }); } }; diff --git a/app-backend/src/services/shiftApplication.service.js b/app-backend/src/services/shiftApplication.service.js new file mode 100644 index 000000000..a2c5a6e4c --- /dev/null +++ b/app-backend/src/services/shiftApplication.service.js @@ -0,0 +1,132 @@ +import mongoose from 'mongoose'; +import Shift from '../models/Shift.js'; +import { ACTIONS } from '../middleware/logger.js'; +import { timeToMinutes, normalizeEnd } from '../utils/timeUtils.js'; + +// Returns true if now is at/after the shift start datetime +const isInPastOrStarted = (shift) => { + try { + const [sh, sm] = String(shift.startTime).split(':').map(Number); + const start = new Date(shift.date); + start.setHours(sh, sm, 0, 0); + return new Date() >= start; + } catch { + return false; + } +}; + +const serviceError = (statusCode, message) => { + const error = new Error(message); + error.statusCode = statusCode; + return error; +}; + +export const applyForShiftService = async ({ shiftId, userId, audit }) => { + if (!mongoose.isValidObjectId(shiftId)) { + throw serviceError(400, 'Invalid id'); + } + + if (!userId || !mongoose.isValidObjectId(String(userId))) { + throw serviceError(401, 'Authenticated user id missing from context'); + } + + const shift = await Shift.findById(shiftId); + if (!shift) { + throw serviceError(404, 'Shift not found'); + } + + if (shift.status !== 'open') { + throw serviceError(400, 'Can only apply to open shifts'); + } + + if (isInPastOrStarted(shift)) { + throw serviceError(400, 'Cannot apply; shift already started or in the past'); + } + + if (String(shift.createdBy) === String(userId)) { + throw serviceError(400, 'Employer cannot apply to own shift'); + } + + // sanitize & dedupe + shift.applicants = (shift.applicants || []).filter(Boolean); + if (shift.applicants.some(a => String(a) === String(userId))) { + throw serviceError(400, 'Already applied'); + } + + const userShifts = await Shift.find({ + date: shift.date, + _id: { $ne: shiftId }, + applicants: userId, + }); + + const hasOverlap = userShifts.some(existing => { + const newStart = timeToMinutes(shift.startTime); + const newEnd = normalizeEnd(shift.startTime, shift.endTime); + const exStart = timeToMinutes(existing.startTime); + const exEnd = normalizeEnd(existing.startTime, existing.endTime); + return newStart < exEnd && newEnd > exStart; + }); + + if (hasOverlap) { + throw serviceError(400, 'Cannot apply; shift overlaps with existing applied shift/s'); + } + + shift.applicants.push(userId); + shift.status = 'applied'; + + await shift.save(); + + await audit.log(userId, ACTIONS.SHIFT_APPLIED, { + shiftId: shift._id + }); + + return { message: 'Application submitted', shift }; +}; + +export const approveShiftService = async ({ shiftId, guardId, keepOthers = false, user, audit }) => { + if (!mongoose.isValidObjectId(shiftId) || !mongoose.isValidObjectId(guardId)) { + throw serviceError(400, 'Invalid id(s)'); + } + + if (!user?._id || !mongoose.isValidObjectId(String(user._id))) { + throw serviceError(401, 'Authenticated user id missing from context'); + } + + const shift = await Shift.findById(shiftId); + if (!shift) { + throw serviceError(404, 'Shift not found'); + } + + const isOwner = String(shift.createdBy) === String(user._id); + const isAdmin = user.role === 'admin'; + + if (!isOwner && !isAdmin) throw serviceError(403, 'Not allowed'); + + if (['assigned', 'completed'].includes(shift.status)) { + throw serviceError(400, `Already ${shift.status}`); + } + + if (isInPastOrStarted(shift)) { + throw serviceError(400, 'Cannot approve; shift already started or in the past'); + } + + if (!shift.applicants.some(a => String(a) === String(guardId))) { + throw serviceError(400, 'Guard did not apply for this shift'); + } + + shift.assignedGuard = guardId; // virtual -> acceptedBy + shift.status = 'assigned'; + + if (!keepOthers) shift.applicants = [guardId]; + + await shift.save(); + + await audit.log(user._id, ACTIONS.SHIFT_APPROVED, { + shiftId: shift._id, + approvedGuardId: guardId, + keepOthers + }); + + return { message: 'Guard approved', shift }; +}; +