From 95eb4e225d5d138c6b7b49b8e8f3eba8ee4cca34 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Tue, 14 Jul 2026 14:25:27 +0930 Subject: [PATCH 1/2] Add core shift request API --- .../controllers/shiftrequest.controller.js | 79 +++++ app-backend/src/models/ShiftRequest.js | 124 +++++++ app-backend/src/routes/index.js | 4 +- app-backend/src/routes/shiftrequest.routes.js | 26 ++ .../src/services/shiftrequest.service.js | 326 ++++++++++++++++++ .../tests/shiftrequest.controller.test.js | 91 +++++ .../tests/shiftrequest.service.test.js | 250 ++++++++++++++ 7 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 app-backend/src/controllers/shiftrequest.controller.js create mode 100644 app-backend/src/models/ShiftRequest.js create mode 100644 app-backend/src/routes/shiftrequest.routes.js create mode 100644 app-backend/src/services/shiftrequest.service.js create mode 100644 app-backend/tests/shiftrequest.controller.test.js create mode 100644 app-backend/tests/shiftrequest.service.test.js diff --git a/app-backend/src/controllers/shiftrequest.controller.js b/app-backend/src/controllers/shiftrequest.controller.js new file mode 100644 index 000000000..1147c8067 --- /dev/null +++ b/app-backend/src/controllers/shiftrequest.controller.js @@ -0,0 +1,79 @@ +import { + createShiftRequest as createShiftRequestService, + getShiftRequestForUser, + listShiftRequestsForUser, + reviewShiftRequest, +} from '../services/shiftrequest.service.js'; + +const handleError = (res, error) => { + const statusCode = error.statusCode || 500; + return res.status(statusCode).json({ message: error.message || 'Shift request operation failed' }); +}; + +export const createShiftRequest = async (req, res) => { + try { + const shiftRequest = await createShiftRequestService({ + user: req.user, + payload: req.body, + }); + + return res.status(201).json({ + success: true, + data: shiftRequest, + message: 'Shift request created', + }); + } catch (error) { + return handleError(res, error); + } +}; + +export const getShiftRequests = async (req, res) => { + try { + const result = await listShiftRequestsForUser({ + user: req.user, + query: req.query, + }); + + return res.json({ + success: true, + ...result, + }); + } catch (error) { + return handleError(res, error); + } +}; + +export const getShiftRequestById = async (req, res) => { + try { + const shiftRequest = await getShiftRequestForUser({ + user: req.user, + requestId: req.params.id, + }); + + return res.json({ + success: true, + data: shiftRequest, + }); + } catch (error) { + return handleError(res, error); + } +}; + +export const updateShiftRequest = async (req, res) => { + try { + const shiftRequest = await reviewShiftRequest({ + user: req.user, + requestId: req.params.id, + status: req.body.status, + rejectionReason: req.body.rejectionReason, + }); + + return res.json({ + success: true, + data: shiftRequest, + message: `Shift request ${shiftRequest.status.toLowerCase()}`, + }); + } catch (error) { + return handleError(res, error); + } +}; diff --git a/app-backend/src/models/ShiftRequest.js b/app-backend/src/models/ShiftRequest.js new file mode 100644 index 000000000..9379b9f21 --- /dev/null +++ b/app-backend/src/models/ShiftRequest.js @@ -0,0 +1,124 @@ +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +export const SHIFT_REQUEST_TYPES = ['SWAP', 'LEAVE']; +export const SHIFT_REQUEST_STATUSES = ['PENDING', 'APPROVED', 'REJECTED']; + +const shiftRequestSchema = new Schema( + { + type: { + type: String, + enum: SHIFT_REQUEST_TYPES, + required: true, + index: true, + }, + status: { + type: String, + enum: SHIFT_REQUEST_STATUSES, + default: 'PENDING', + required: true, + index: true, + }, + requestingGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + targetGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, + validate: { + validator(value) { + return this.type !== 'SWAP' || Boolean(value); + }, + message: 'Target guard is required for shift swap requests', + }, + }, + originalShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + replacementShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + default: null, + }, + leaveStartDate: { + type: Date, + default: null, + validate: { + validator(value) { + return this.type !== 'LEAVE' || Boolean(value); + }, + message: 'Leave start date is required for leave requests', + }, + }, + leaveEndDate: { + type: Date, + default: null, + validate: { + validator(value) { + if (this.type === 'LEAVE' && !value) return false; + return !value || !this.leaveStartDate || value >= this.leaveStartDate; + }, + message: 'Leave end date must be on or after leave start date', + }, + }, + reason: { + type: String, + required: true, + trim: true, + minlength: 3, + maxlength: 1000, + }, + rejectionReason: { + type: String, + trim: true, + maxlength: 500, + default: null, + }, + reviewedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + reviewedAt: { + type: Date, + default: null, + }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } +); + +shiftRequestSchema.index({ requestingGuardId: 1, status: 1, createdAt: -1 }); +shiftRequestSchema.index({ originalShiftId: 1, status: 1 }); +shiftRequestSchema.index({ targetGuardId: 1, status: 1 }); + +shiftRequestSchema.virtual('isActionable').get(function () { + return this.status === 'PENDING'; +}); + +shiftRequestSchema.pre('validate', function (next) { + if (this.type === 'LEAVE' && this.leaveStartDate) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (this.leaveStartDate < today) { + return next(new Error('Leave start date cannot be in the past')); + } + } + + return next(); +}); + +const ShiftRequest = model('ShiftRequest', shiftRequestSchema); +export default ShiftRequest; diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index 575d8a897..b75badaea 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -16,6 +16,7 @@ import equipmentRoutes from './equipment.routes.js'; import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; import emergencyRoutes from "./emergency.routes.js"; +import shiftRequestRoutes from './shiftrequest.routes.js'; const router = express.Router(); router.use('/documents', documentRoutes); router.use('/health', healthRoutes); @@ -31,6 +32,7 @@ router.use('/attendance', shiftAttendanceRoutes); router.use("/incidents", incidentRoutes); router.use('/notifications', notificationRoutes); router.use('/payroll', payrollRoutes); +router.use('/shifts', shiftRequestRoutes); router.use('/equipment', equipmentRoutes); router.use("/emergency", emergencyRoutes); -export default router; \ No newline at end of file +export default router; diff --git a/app-backend/src/routes/shiftrequest.routes.js b/app-backend/src/routes/shiftrequest.routes.js new file mode 100644 index 000000000..83e7bf6b0 --- /dev/null +++ b/app-backend/src/routes/shiftrequest.routes.js @@ -0,0 +1,26 @@ +import express from 'express'; +import protect from '../middleware/auth.js'; +import { authorizeRoles } from '../middleware/rbac.js'; +import { + createShiftRequest, + getShiftRequestById, + getShiftRequests, + updateShiftRequest, +} from '../controllers/shiftrequest.controller.js'; + +const router = express.Router(); + +router + .route('/request') + .post(protect, authorizeRoles('guard'), createShiftRequest); + +router + .route('/requests') + .get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequests); + +router + .route('/request/:id') + .get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequestById) + .patch(protect, authorizeRoles('employer', 'admin'), updateShiftRequest); + +export default router; diff --git a/app-backend/src/services/shiftrequest.service.js b/app-backend/src/services/shiftrequest.service.js new file mode 100644 index 000000000..b102f035a --- /dev/null +++ b/app-backend/src/services/shiftrequest.service.js @@ -0,0 +1,326 @@ +import mongoose from 'mongoose'; +import ShiftRequest, { + SHIFT_REQUEST_STATUSES, + SHIFT_REQUEST_TYPES, +} from '../models/ShiftRequest.js'; +import Shift from '../models/Shift.js'; +import User from '../models/User.js'; +import Branch from '../models/Branch.js'; + +const TERMINAL_STATUSES = ['APPROVED', 'REJECTED']; + +class ServiceError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } +} + +const actorId = (user) => user?._id || user?.id; + +const assertObjectId = (value, field) => { + if (!mongoose.isValidObjectId(value)) { + throw new ServiceError(400, `${field} must be a valid ObjectId`); + } +}; + +const startOfToday = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +}; + +const isPastShift = (shift) => new Date(shift.date) < startOfToday(); + +const objectIdEquals = (a, b) => String(a) === String(b); + +const refId = (value) => value?._id || value; + +const includesObjectId = (values = [], id) => ( + Array.isArray(values) && values.some((value) => objectIdEquals(value, id)) +); + +const isGuardAssignedToShift = (shift, guardId) => ( + includesObjectId(shift.guardIds, guardId) || objectIdEquals(shift.acceptedBy, guardId) +); + +const assertGuardUser = async (guardId, message = 'Guard not found') => { + const guard = await User.findOne({ + _id: guardId, + role: 'guard', + isDeleted: { $ne: true }, + }).lean(); + + if (!guard) { + throw new ServiceError(404, message); + } + + return guard; +}; + +const employerShiftScopeFilter = async (employerId) => { + const branchIds = await Branch.find({ employerId, isActive: true }).distinct('_id'); + + return { + $or: [ + { createdBy: employerId }, + { siteId: { $in: branchIds } }, + ], + }; +}; + +const ensureEmployerCanReviewShift = async (shiftId, user) => { + const uid = actorId(user); + + if (user.role === 'admin') { + return Shift.findById(shiftId); + } + + const scopeFilter = await employerShiftScopeFilter(uid); + return Shift.findOne({ + _id: shiftId, + ...scopeFilter, + }); +}; + +export const createShiftRequest = async ({ user, payload }) => { + const requestingGuardId = actorId(user); + + if (!requestingGuardId) { + throw new ServiceError(401, 'Authenticated user id missing from context'); + } + + if (user.role !== 'guard') { + throw new ServiceError(403, 'Only guards can create shift requests'); + } + + const { + type, + targetGuardId, + originalShiftId, + replacementShiftId, + leaveStartDate, + leaveEndDate, + reason, + } = payload; + + if (!SHIFT_REQUEST_TYPES.includes(type)) { + throw new ServiceError(400, 'type must be SWAP or LEAVE'); + } + assertObjectId(originalShiftId, 'originalShiftId'); + + if (!reason || typeof reason !== 'string' || reason.trim().length < 3) { + throw new ServiceError(400, 'reason must be at least 3 characters'); + } + + const originalShift = await Shift.findById(originalShiftId); + if (!originalShift) { + throw new ServiceError(404, 'Original shift not found'); + } + + if (!isGuardAssignedToShift(originalShift, requestingGuardId)) { + throw new ServiceError(403, 'You are not assigned to this shift'); + } + + if (isPastShift(originalShift)) { + throw new ServiceError(400, 'Cannot request changes for past shifts'); + } + + const existingRequest = await ShiftRequest.findOne({ + originalShiftId, + requestingGuardId, + status: 'PENDING', + }).lean(); + + if (existingRequest) { + throw new ServiceError(400, 'You already have a pending request for this shift'); + } + + const requestData = { + type, + requestingGuardId, + originalShiftId, + reason: reason.trim(), + }; + + if (type === 'SWAP') { + if (!targetGuardId) { + throw new ServiceError(400, 'targetGuardId is required for swap requests'); + } + assertObjectId(targetGuardId, 'targetGuardId'); + + if (objectIdEquals(targetGuardId, requestingGuardId)) { + throw new ServiceError(400, 'Cannot swap a shift with yourself'); + } + + await assertGuardUser(targetGuardId, 'Target guard not found'); + requestData.targetGuardId = targetGuardId; + + if (replacementShiftId) { + assertObjectId(replacementShiftId, 'replacementShiftId'); + const replacementShift = await Shift.findById(replacementShiftId); + + if (!replacementShift) { + throw new ServiceError(404, 'Replacement shift not found'); + } + + if (!isGuardAssignedToShift(replacementShift, targetGuardId)) { + throw new ServiceError(403, 'Replacement shift must belong to the target guard'); + } + + if (isPastShift(replacementShift)) { + throw new ServiceError(400, 'Cannot swap with past shifts'); + } + + requestData.replacementShiftId = replacementShiftId; + } + } + + if (type === 'LEAVE') { + if (!leaveStartDate || !leaveEndDate) { + throw new ServiceError(400, 'leaveStartDate and leaveEndDate are required for leave requests'); + } + + requestData.leaveStartDate = new Date(leaveStartDate); + requestData.leaveEndDate = new Date(leaveEndDate); + + if ( + Number.isNaN(requestData.leaveStartDate.getTime()) || + Number.isNaN(requestData.leaveEndDate.getTime()) + ) { + throw new ServiceError(400, 'leaveStartDate and leaveEndDate must be valid dates'); + } + + if (requestData.leaveEndDate < requestData.leaveStartDate) { + throw new ServiceError(400, 'leaveEndDate must be on or after leaveStartDate'); + } + } + + return ShiftRequest.create(requestData); +}; + +export const listShiftRequestsForUser = async ({ user, query = {} }) => { + const uid = actorId(user); + const filter = {}; + + if (user.role === 'guard') { + filter.requestingGuardId = uid; + } else if (user.role === 'employer') { + const shiftScopeFilter = await employerShiftScopeFilter(uid); + const shiftIds = await Shift.find(shiftScopeFilter).distinct('_id'); + filter.originalShiftId = { $in: shiftIds }; + } else if (user.role !== 'admin') { + throw new ServiceError(403, 'Forbidden'); + } + + if (query.status) { + if (!SHIFT_REQUEST_STATUSES.includes(query.status)) { + throw new ServiceError(400, 'Invalid status filter'); + } + filter.status = query.status; + } + + if (query.type) { + if (!SHIFT_REQUEST_TYPES.includes(query.type)) { + throw new ServiceError(400, 'Invalid type filter'); + } + filter.type = query.type; + } + + const page = Math.max(1, parseInt(query.page, 10) || 1); + const limit = Math.min(50, Math.max(1, parseInt(query.limit, 10) || 20)); + const skip = (page - 1) * limit; + + const requestQuery = ShiftRequest.find(filter) + .populate('requestingGuardId', 'name email phone') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime location urgency status createdBy siteId') + .populate('replacementShiftId', 'title date startTime endTime') + .populate('reviewedBy', 'name email') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const [items, total] = await Promise.all([ + requestQuery.lean(), + ShiftRequest.countDocuments(filter), + ]); + + return { + page, + limit, + total, + pages: Math.ceil(total / limit), + items, + }; +}; + +export const getShiftRequestForUser = async ({ user, requestId }) => { + assertObjectId(requestId, 'requestId'); + + const request = await ShiftRequest.findById(requestId) + .populate('requestingGuardId', 'name email phone') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime location urgency status createdBy siteId') + .populate('replacementShiftId', 'title date startTime endTime') + .populate('reviewedBy', 'name email'); + + if (!request) { + throw new ServiceError(404, 'Shift request not found'); + } + + const uid = actorId(user); + + if (user.role === 'guard' && !objectIdEquals(refId(request.requestingGuardId), uid)) { + throw new ServiceError(403, 'Access denied'); + } + + if (user.role === 'employer') { + const shiftId = refId(request.originalShiftId); + const shift = await ensureEmployerCanReviewShift(shiftId, user); + + if (!shift) { + throw new ServiceError(403, 'Access denied'); + } + } + + return request; +}; + +export const reviewShiftRequest = async ({ user, requestId, status, rejectionReason }) => { + const uid = actorId(user); + + if (!['employer', 'admin'].includes(user.role)) { + throw new ServiceError(403, 'Only employers or admins can approve or reject requests'); + } + + assertObjectId(requestId, 'requestId'); + + if (!TERMINAL_STATUSES.includes(status)) { + throw new ServiceError(400, 'status must be APPROVED or REJECTED'); + } + + const request = await ShiftRequest.findById(requestId); + if (!request) { + throw new ServiceError(404, 'Shift request not found'); + } + + if (request.status !== 'PENDING') { + throw new ServiceError(400, `Cannot transition shift request from ${request.status} to ${status}`); + } + + const scopedShift = await ensureEmployerCanReviewShift(request.originalShiftId, user); + if (!scopedShift) { + throw new ServiceError(403, 'You can only review requests for shifts in your employment scope'); + } + + request.status = status; + request.reviewedBy = uid; + request.reviewedAt = new Date(); + request.rejectionReason = status === 'REJECTED' + ? (rejectionReason || 'No reason provided') + : null; + + await request.save(); + return request; +}; diff --git a/app-backend/tests/shiftrequest.controller.test.js b/app-backend/tests/shiftrequest.controller.test.js new file mode 100644 index 000000000..0580aca1d --- /dev/null +++ b/app-backend/tests/shiftrequest.controller.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + createShiftRequest, + getShiftRequests, + updateShiftRequest, +} from '../src/controllers/shiftrequest.controller.js'; +import { + createShiftRequest as createShiftRequestService, + listShiftRequestsForUser, + reviewShiftRequest, +} from '../src/services/shiftrequest.service.js'; + +jest.mock('../src/services/shiftrequest.service.js'); + +const response = () => { + const res = {}; + res.status = jest.fn(() => res); + res.json = jest.fn(() => res); + return res; +}; + +describe('shiftrequest.controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a shift request through the service', async () => { + const req = { + user: { _id: 'guard-1', role: 'guard' }, + body: { type: 'LEAVE' }, + }; + const res = response(); + const data = { _id: 'request-1', type: 'LEAVE' }; + + createShiftRequestService.mockResolvedValue(data); + + await createShiftRequest(req, res); + + expect(createShiftRequestService).toHaveBeenCalledWith({ + user: req.user, + payload: req.body, + }); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + data, + })); + }); + + it('returns scoped shift requests', async () => { + const req = { + user: { _id: 'employer-1', role: 'employer' }, + query: { status: 'PENDING' }, + }; + const res = response(); + const result = { page: 1, limit: 20, total: 0, pages: 0, items: [] }; + + listShiftRequestsForUser.mockResolvedValue(result); + + await getShiftRequests(req, res); + + expect(listShiftRequestsForUser).toHaveBeenCalledWith({ + user: req.user, + query: req.query, + }); + expect(res.json).toHaveBeenCalledWith({ + success: true, + ...result, + }); + }); + + it('maps service errors to HTTP responses', async () => { + const req = { + user: { _id: 'guard-1', role: 'guard' }, + params: { id: 'request-1' }, + body: { status: 'APPROVED' }, + }; + const res = response(); + const error = new Error('Only employers or admins can approve or reject requests'); + error.statusCode = 403; + + reviewShiftRequest.mockRejectedValue(error); + + await updateShiftRequest(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + message: 'Only employers or admins can approve or reject requests', + }); + }); +}); diff --git a/app-backend/tests/shiftrequest.service.test.js b/app-backend/tests/shiftrequest.service.test.js new file mode 100644 index 000000000..5aafd10c6 --- /dev/null +++ b/app-backend/tests/shiftrequest.service.test.js @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import mongoose from 'mongoose'; +import { + createShiftRequest, + listShiftRequestsForUser, + reviewShiftRequest, +} from '../src/services/shiftrequest.service.js'; +import ShiftRequest from '../src/models/ShiftRequest.js'; +import Shift from '../src/models/Shift.js'; +import User from '../src/models/User.js'; +import Branch from '../src/models/Branch.js'; + +jest.mock('../src/models/ShiftRequest.js', () => ({ + __esModule: true, + default: { + find: jest.fn(), + findById: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + countDocuments: jest.fn(), + }, + SHIFT_REQUEST_TYPES: ['SWAP', 'LEAVE'], + SHIFT_REQUEST_STATUSES: ['PENDING', 'APPROVED', 'REJECTED'], +})); +jest.mock('../src/models/Shift.js'); +jest.mock('../src/models/User.js'); +jest.mock('../src/models/Branch.js'); + +const objectId = () => new mongoose.Types.ObjectId().toString(); + +const leanResult = (value) => ({ + lean: jest.fn().mockResolvedValue(value), +}); + +const distinctResult = (value) => ({ + distinct: jest.fn().mockResolvedValue(value), +}); + +const requestFindChain = (items) => { + const chain = { + populate: jest.fn(() => chain), + sort: jest.fn(() => chain), + skip: jest.fn(() => chain), + limit: jest.fn(() => chain), + lean: jest.fn().mockResolvedValue(items), + }; + return chain; +}; + +describe('shiftrequest.service', () => { + let guardId; + let targetGuardId; + let employerId; + let shiftId; + + beforeEach(() => { + jest.clearAllMocks(); + + guardId = objectId(); + targetGuardId = objectId(); + employerId = objectId(); + shiftId = objectId(); + }); + + describe('createShiftRequest', () => { + it('creates a leave request for an assigned guard', async () => { + const shift = { + _id: shiftId, + date: new Date(Date.now() + 86400000), + guardIds: [guardId], + acceptedBy: guardId, + }; + const createdRequest = { _id: objectId(), type: 'LEAVE' }; + + Shift.findById.mockResolvedValue(shift); + ShiftRequest.findOne.mockReturnValue(leanResult(null)); + ShiftRequest.create.mockResolvedValue(createdRequest); + + const result = await createShiftRequest({ + user: { _id: guardId, role: 'guard' }, + payload: { + type: 'LEAVE', + originalShiftId: shiftId, + leaveStartDate: '2026-12-01', + leaveEndDate: '2026-12-02', + reason: 'Family commitment', + }, + }); + + expect(result).toBe(createdRequest); + expect(ShiftRequest.create).toHaveBeenCalledWith(expect.objectContaining({ + type: 'LEAVE', + requestingGuardId: guardId, + originalShiftId: shiftId, + reason: 'Family commitment', + })); + }); + + it('blocks duplicate pending requests for the same guard and shift', async () => { + Shift.findById.mockResolvedValue({ + _id: shiftId, + date: new Date(Date.now() + 86400000), + guardIds: [guardId], + }); + ShiftRequest.findOne.mockReturnValue(leanResult({ _id: objectId() })); + + await expect(createShiftRequest({ + user: { _id: guardId, role: 'guard' }, + payload: { + type: 'LEAVE', + originalShiftId: shiftId, + leaveStartDate: '2026-12-01', + leaveEndDate: '2026-12-02', + reason: 'Family commitment', + }, + })).rejects.toMatchObject({ + statusCode: 400, + message: 'You already have a pending request for this shift', + }); + }); + + it('validates swap target guards', async () => { + Shift.findById.mockResolvedValue({ + _id: shiftId, + date: new Date(Date.now() + 86400000), + guardIds: [guardId], + }); + ShiftRequest.findOne.mockReturnValue(leanResult(null)); + User.findOne.mockReturnValue(leanResult(null)); + + await expect(createShiftRequest({ + user: { _id: guardId, role: 'guard' }, + payload: { + type: 'SWAP', + targetGuardId, + originalShiftId: shiftId, + reason: 'Need coverage', + }, + })).rejects.toMatchObject({ + statusCode: 404, + message: 'Target guard not found', + }); + }); + }); + + describe('listShiftRequestsForUser', () => { + it('lists only the guard’s own requests', async () => { + const chain = requestFindChain([{ _id: objectId() }]); + ShiftRequest.find.mockReturnValue(chain); + ShiftRequest.countDocuments.mockResolvedValue(1); + + const result = await listShiftRequestsForUser({ + user: { _id: guardId, role: 'guard' }, + query: { status: 'PENDING' }, + }); + + expect(ShiftRequest.find).toHaveBeenCalledWith({ + requestingGuardId: guardId, + status: 'PENDING', + }); + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + }); + + it('scopes employer requests to shifts they created or active branches they own', async () => { + const scopedShiftIds = [shiftId]; + const chain = requestFindChain([]); + + Branch.find.mockReturnValue(distinctResult([objectId()])); + Shift.find.mockReturnValue(distinctResult(scopedShiftIds)); + ShiftRequest.find.mockReturnValue(chain); + ShiftRequest.countDocuments.mockResolvedValue(0); + + await listShiftRequestsForUser({ + user: { _id: employerId, role: 'employer' }, + query: {}, + }); + + expect(Shift.find).toHaveBeenCalledWith({ + $or: [ + { createdBy: employerId }, + { siteId: { $in: expect.any(Array) } }, + ], + }); + expect(ShiftRequest.find).toHaveBeenCalledWith({ + originalShiftId: { $in: scopedShiftIds }, + }); + }); + }); + + describe('reviewShiftRequest', () => { + it('approves a pending leave request without mutating the original shift', async () => { + const request = { + _id: objectId(), + type: 'LEAVE', + status: 'PENDING', + originalShiftId: shiftId, + requestingGuardId: guardId, + save: jest.fn().mockResolvedValue(true), + }; + const shift = { + _id: shiftId, + status: 'assigned', + acceptedBy: guardId, + guardIds: [guardId], + applicants: [guardId], + save: jest.fn().mockResolvedValue(true), + }; + + ShiftRequest.findById.mockResolvedValue(request); + Branch.find.mockReturnValue(distinctResult([])); + Shift.findOne.mockResolvedValue(shift); + + const result = await reviewShiftRequest({ + user: { _id: employerId, role: 'employer' }, + requestId: request._id, + status: 'APPROVED', + }); + + expect(result.status).toBe('APPROVED'); + expect(result.reviewedBy).toBe(employerId); + expect(shift.status).toBe('assigned'); + expect(shift.acceptedBy).toBe(guardId); + expect(shift.guardIds).toEqual([guardId]); + expect(shift.save).not.toHaveBeenCalled(); + expect(request.save).toHaveBeenCalled(); + }); + + it('rejects terminal status transitions', async () => { + const request = { + _id: objectId(), + type: 'LEAVE', + status: 'APPROVED', + originalShiftId: shiftId, + requestingGuardId: guardId, + }; + + ShiftRequest.findById.mockResolvedValue(request); + + await expect(reviewShiftRequest({ + user: { _id: employerId, role: 'employer' }, + requestId: request._id, + status: 'REJECTED', + })).rejects.toMatchObject({ + statusCode: 400, + message: 'Cannot transition shift request from APPROVED to REJECTED', + }); + }); + }); +}); From 791ab70ce06c88b62dc01edcbdeeee81bf97fa6d Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Tue, 14 Jul 2026 17:07:12 +0930 Subject: [PATCH 2/2] Address shift request review feedback --- app-backend/README.md | 31 +++++++++++++++++++ app-backend/src/routes/index.js | 2 +- app-backend/src/routes/shiftrequest.routes.js | 9 ++---- .../src/services/shiftrequest.service.js | 2 ++ .../tests/shiftrequest.controller.test.js | 24 ++++++++++++++ .../tests/shiftrequest.service.test.js | 10 ++++-- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app-backend/README.md b/app-backend/README.md index 8816ee781..66107cacd 100644 --- a/app-backend/README.md +++ b/app-backend/README.md @@ -124,6 +124,37 @@ You can explore, test, and understand the structure of all API endpoints there. - API docs with Swagger UI - Fully containerized backend with Docker +## Shift Request API + +Base path: `/api/v1/shift-requests` + +| Method | Endpoint | Roles | Description | +| --- | --- | --- | --- | +| `POST` | `/` | Guard | Create a `SWAP` or `LEAVE` request for a shift assigned to the authenticated guard. | +| `GET` | `/` | Guard, Employer, Admin | List shift requests scoped to the authenticated user. Supports `status`, `type`, `page`, and `limit` query parameters. | +| `GET` | `/:id` | Guard, Employer, Admin | Fetch one shift request when it is in the authenticated user scope. | +| `PATCH` | `/:id` | Employer, Admin | Approve or reject a pending shift request with `{ "status": "APPROVED" }` or `{ "status": "REJECTED", "rejectionReason": "..." }`. | + +### Roles and scoping + +- Guards can create requests only for shifts they are assigned to through `guardIds` or `acceptedBy`. +- Guards can list and view only their own submitted shift requests. +- Employers can list, view, approve, or reject requests for shifts they created or shifts attached to active branches they own. +- Admins can list, view, approve, or reject shift requests across the system. + +### Status transitions + +Shift requests start as `PENDING`. The only allowed terminal transitions are: + +- `PENDING -> APPROVED` +- `PENDING -> REJECTED` + +Terminal requests cannot transition again. + +### Approval limitation + +Approving or rejecting a shift request currently records the decision on the `ShiftRequest` only. It does not alter shift assignment, guard rosters, availability, notifications, replacement-shift ownership, or target-guard state. Any roster-changing workflow requires separate product sign-off and implementation. + --- ## đŸ§ª Testing diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index b75badaea..891997233 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -32,7 +32,7 @@ router.use('/attendance', shiftAttendanceRoutes); router.use("/incidents", incidentRoutes); router.use('/notifications', notificationRoutes); router.use('/payroll', payrollRoutes); -router.use('/shifts', shiftRequestRoutes); +router.use('/shift-requests', shiftRequestRoutes); router.use('/equipment', equipmentRoutes); router.use("/emergency", emergencyRoutes); export default router; diff --git a/app-backend/src/routes/shiftrequest.routes.js b/app-backend/src/routes/shiftrequest.routes.js index 83e7bf6b0..70aa46a57 100644 --- a/app-backend/src/routes/shiftrequest.routes.js +++ b/app-backend/src/routes/shiftrequest.routes.js @@ -11,15 +11,12 @@ import { const router = express.Router(); router - .route('/request') - .post(protect, authorizeRoles('guard'), createShiftRequest); - -router - .route('/requests') + .route('/') + .post(protect, authorizeRoles('guard'), createShiftRequest) .get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequests); router - .route('/request/:id') + .route('/:id') .get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequestById) .patch(protect, authorizeRoles('employer', 'admin'), updateShiftRequest); diff --git a/app-backend/src/services/shiftrequest.service.js b/app-backend/src/services/shiftrequest.service.js index b102f035a..1739e6801 100644 --- a/app-backend/src/services/shiftrequest.service.js +++ b/app-backend/src/services/shiftrequest.service.js @@ -314,6 +314,8 @@ export const reviewShiftRequest = async ({ user, requestId, status, rejectionRea throw new ServiceError(403, 'You can only review requests for shifts in your employment scope'); } + // Product-approved narrow behavior: record the decision only. + // Do not mutate shift assignment, roster, availability, notifications, or target-guard state here. request.status = status; request.reviewedBy = uid; request.reviewedAt = new Date(); diff --git a/app-backend/tests/shiftrequest.controller.test.js b/app-backend/tests/shiftrequest.controller.test.js index 0580aca1d..acffff63b 100644 --- a/app-backend/tests/shiftrequest.controller.test.js +++ b/app-backend/tests/shiftrequest.controller.test.js @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { createShiftRequest, + getShiftRequestById, getShiftRequests, updateShiftRequest, } from '../src/controllers/shiftrequest.controller.js'; import { createShiftRequest as createShiftRequestService, + getShiftRequestForUser, listShiftRequestsForUser, reviewShiftRequest, } from '../src/services/shiftrequest.service.js'; @@ -69,6 +71,28 @@ describe('shiftrequest.controller', () => { }); }); + it('returns one shift request by resource id', async () => { + const req = { + user: { _id: 'guard-1', role: 'guard' }, + params: { id: 'request-1' }, + }; + const res = response(); + const data = { _id: 'request-1', type: 'LEAVE' }; + + getShiftRequestForUser.mockResolvedValue(data); + + await getShiftRequestById(req, res); + + expect(getShiftRequestForUser).toHaveBeenCalledWith({ + user: req.user, + requestId: 'request-1', + }); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data, + }); + }); + it('maps service errors to HTTP responses', async () => { const req = { user: { _id: 'guard-1', role: 'guard' }, diff --git a/app-backend/tests/shiftrequest.service.test.js b/app-backend/tests/shiftrequest.service.test.js index 5aafd10c6..b0c98a39d 100644 --- a/app-backend/tests/shiftrequest.service.test.js +++ b/app-backend/tests/shiftrequest.service.test.js @@ -206,6 +206,12 @@ describe('shiftrequest.service', () => { applicants: [guardId], save: jest.fn().mockResolvedValue(true), }; + const originalShiftState = { + status: shift.status, + acceptedBy: shift.acceptedBy, + guardIds: [...shift.guardIds], + applicants: [...shift.applicants], + }; ShiftRequest.findById.mockResolvedValue(request); Branch.find.mockReturnValue(distinctResult([])); @@ -219,9 +225,7 @@ describe('shiftrequest.service', () => { expect(result.status).toBe('APPROVED'); expect(result.reviewedBy).toBe(employerId); - expect(shift.status).toBe('assigned'); - expect(shift.acceptedBy).toBe(guardId); - expect(shift.guardIds).toEqual([guardId]); + expect(shift).toEqual(expect.objectContaining(originalShiftState)); expect(shift.save).not.toHaveBeenCalled(); expect(request.save).toHaveBeenCalled(); });