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
108 changes: 19 additions & 89 deletions app-backend/src/controllers/shift.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
};

Expand All @@ -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 });
}
};

Expand Down
132 changes: 132 additions & 0 deletions app-backend/src/services/shiftApplication.service.js
Original file line number Diff line number Diff line change
@@ -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 };
};

Loading