diff --git a/app-backend/src/controllers/shift.controller.js b/app-backend/src/controllers/shift.controller.js index 41260d4f..43141998 100644 --- a/app-backend/src/controllers/shift.controller.js +++ b/app-backend/src/controllers/shift.controller.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; 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 { ACTIONS } from "../middleware/logger.js"; @@ -9,6 +12,42 @@ import { timeToMinutes, normalizeEnd } from '../utils/timeUtils.js'; const HHMM = /^([0-1]\d|2[0-3]):([0-5]\d)$/; const isValidHHMM = (s) => typeof s === 'string' && HHMM.test(s); +const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const getWeekdayName = (date) => { + return WEEKDAY_NAMES[new Date(date).getDay()]; +}; + +const shiftFitsTimeSlot = (startTime, endTime, slot) => { + if (typeof slot !== 'string' || !slot.includes('-')) return false; + + const [slotStart, slotEnd] = slot.split('-'); + const shiftStart = timeToMinutes(startTime); + const shiftEnd = normalizeEnd(startTime, endTime); + const slotStartMinutes = timeToMinutes(slotStart); + const slotEndMinutes = normalizeEnd(slotStart, slotEnd); + + return shiftStart >= slotStartMinutes && shiftEnd <= slotEndMinutes; +}; + +const getShiftDateRange = (date, startTime, endTime) => { + const start = new Date(date); + const [startHour, startMinute] = String(startTime).split(':').map(Number); + start.setHours(startHour, startMinute, 0, 0); + + const end = new Date(date); + const [endHour, endMinute] = String(endTime).split(':').map(Number); + end.setHours(endHour, endMinute, 0, 0); + + if (end <= start) end.setDate(end.getDate() + 1); // handle overnight shifts + + return { start, end }; +}; + +const rangesOverlap = (rangeA, rangeB) => { + return rangeA.start < rangeB.end && rangeB.start < rangeA.end; +}; + // Returns true if now is at/after the shift start datetime const isInPastOrStarted = (shift) => { try { @@ -26,17 +65,34 @@ const isInPastOrStarted = (shift) => { */ export const createShift = async (req, res) => { try { - const { title, date, startTime, endTime, location, urgency, field, payRate, description, requirements } = req.body; + const { + title, + date, + startTime, + endTime, + location, + urgency, + field, + payRate, + description, + requirements, + shiftType, + breakTime, + detailedInstructions, + guardIds = [], + siteId, + } = req.body; - if (!title || !date || !startTime || !endTime) { - return res.status(400).json({ message: 'title, date, startTime, endTime are required' }); + if (!title || !date || !startTime || !endTime || !location || payRate == null) { + return res.status(400).json({ + message: 'title, date, startTime, endTime, location, and payRate are required' + }); } if (payRate !== undefined && (isNaN(payRate) || Number(payRate) < 0)) { return res.status(400).json({ message: 'payRate must be a non-negative number' }); } - // pick up user id from either _id or id const creatorId = req.user?._id || req.user?.id; if (!creatorId) { return res.status(401).json({ message: 'Authenticated user id missing from context' }); @@ -51,17 +107,170 @@ export const createShift = async (req, res) => { return res.status(400).json({ message: 'startTime/endTime must be HH:MM (24h)' }); } + if (!['Day', 'Night'].includes(shiftType)) { + return res.status(400).json({ message: 'shiftType must be Day or Night' }); + } + + if (breakTime !== undefined) { + const btNum = Number(breakTime); + if (Number.isNaN(btNum) || btNum < 0) { + return res.status(400).json({ message: 'breakTime must be a non-negative number' }); + } + } + + if (!Array.isArray(guardIds)) { + return res.status(400).json({ message: 'guardIds must be an array' }); + } + + if (!siteId || !mongoose.isValidObjectId(siteId)) { + return res.status(400).json({ message: 'siteId must be a valid branch ID' }); + } + let loc; if (location && typeof location === 'object') { - const { street, suburb, state, postcode } = location; + const { street, suburb, state, postcode, latitude, longitude } = location; loc = { street: typeof street === 'string' ? street.trim() : undefined, suburb: typeof suburb === 'string' ? suburb.trim() : undefined, state: typeof state === 'string' ? state.trim() : undefined, postcode, + latitude: latitude !== undefined ? Number(latitude) : undefined, + longitude: longitude !== undefined ? Number(longitude) : undefined, }; } + if (!loc?.street || !loc?.suburb || !loc?.state || !loc?.postcode) { + return res.status(400).json({ + message: 'location must include street, suburb, state, and postcode' + }); + } + + if (!/^\d{4}$/.test(String(loc.postcode))) { + return res.status(400).json({ message: 'location.postcode must be a 4-digit string' }); + } + + if ( + loc.latitude !== undefined && + (Number.isNaN(loc.latitude) || loc.latitude < -90 || loc.latitude > 90) + ) { + return res.status(400).json({ + message: 'location.latitude must be a number between -90 and 90' + }); + } + + if ( + loc.longitude !== undefined && + (Number.isNaN(loc.longitude) || loc.longitude < -180 || loc.longitude > 180) + ) { + return res.status(400).json({ + message: 'location.longitude must be a number between -180 and 180' + }); + } + + const site = await Branch.findOne({ + _id: siteId, + employerId: creatorId, + isActive: true, + }).lean(); + + if (!site) { + return res.status(400).json({ + message: 'siteId does not exist or does not belong to you' + }); + } + + const normalizedGuardIds = [...new Set(guardIds)].map((id) => String(id)); + + if (!normalizedGuardIds.every((id) => mongoose.isValidObjectId(id))) { + return res.status(400).json({ + message: 'guardIds must contain only valid guard IDs' + }); + } + + const guards = await Guard.find({ + _id: { $in: normalizedGuardIds }, + isDeleted: { $ne: true } + }).select('_id name role').lean(); + + if (guards.length !== normalizedGuardIds.length) { + return res.status(400).json({ + message: 'One or more guardIds do not correspond to active guards' + }); + } + + if (normalizedGuardIds.length > 0) { + const shiftDay = getWeekdayName(d); + + const availabilities = await Availability.find({ + user: { $in: normalizedGuardIds } + }).lean(); + + // For each selected guard, ensure they have availability for the requested weekday and time slot. + for (const guardId of normalizedGuardIds) { + const availability = availabilities.find((a) => String(a.user) === guardId); + + if (!availability) { + return res.status(400).json({ + message: `Guard ${guardId} does not have availability set` + }); + } + + if (!availability.days.includes(shiftDay)) { + return res.status(400).json({ + message: `Guard ${guardId} is not available on ${shiftDay}` + }); + } + + const fitsTimeSlot = availability.timeSlots.some((slot) => + shiftFitsTimeSlot(startTime, endTime, slot) + ); + + if (!fitsTimeSlot) { + return res.status(400).json({ + message: `Guard ${guardId} is not available for the requested time` + }); + } + } + + // Prevent assigning/selecting guards who already have overlapping shifts through accepted, applied, or preselected guard links. + const newShiftRange = getShiftDateRange(d, startTime, endTime); + + const existingShifts = await Shift.find({ + $or: [ + { acceptedBy: { $in: normalizedGuardIds } }, + { applicants: { $in: normalizedGuardIds } }, + { guardIds: { $in: normalizedGuardIds } } + ], + status: { $ne: 'completed' } + }).select('_id title date startTime endTime acceptedBy applicants guardIds status').lean(); + + for (const existingShift of existingShifts) { + const existingRange = getShiftDateRange( + existingShift.date, + existingShift.startTime, + existingShift.endTime + ); + + if (!rangesOverlap(newShiftRange, existingRange)) { + continue; + } + + const conflictingGuardId = normalizedGuardIds.find((guardId) => { + return ( + String(existingShift.acceptedBy) === guardId || + (existingShift.applicants || []).some((id) => String(id) === guardId) || + (existingShift.guardIds || []).some((id) => String(id) === guardId) + ); + }); + + if (conflictingGuardId) { + return res.status(400).json({ + message: `Guard ${conflictingGuardId} has a conflicting shift` + }); + } + } + } + const shift = await Shift.create({ title, date: d, @@ -74,6 +283,11 @@ export const createShift = async (req, res) => { payRate, description, requirements, + shiftType, + breakTime: breakTime !== undefined ? Number(breakTime) : undefined, + detailedInstructions, + guardIds: normalizedGuardIds, + siteId, }); await req.audit.log(req.user._id, ACTIONS.SHIFT_CREATED, { @@ -82,7 +296,7 @@ export const createShift = async (req, res) => { date: shift.date, payRate: shift.payRate }); - + return res.status(201).json(shift); } catch (e) { return res.status(500).json({ message: e.message }); @@ -227,19 +441,19 @@ export const updateShift = async (req, res) => { export const listAvailableShifts = async (req, res) => { try { const role = req.user?.role; - const uid = req.user?._id || req.user?.id; + const uid = req.user?._id || req.user?.id; if (!role || !uid) return res.status(401).json({ message: 'Unauthorized' }); - const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20)); - const skip = (page - 1) * limit; + const skip = (page - 1) * limit; const { q, urgency } = req.query; const withApplicantsOnly = String(req.query.withApplicantsOnly) === 'true'; let query = {}; if (role === 'guard') { - const today = new Date(); today.setHours(0,0,0,0); + const today = new Date(); today.setHours(0, 0, 0, 0); query = { status: { $in: ['open', 'applied'] }, createdBy: { $ne: uid }, @@ -262,7 +476,7 @@ export const listAvailableShifts = async (req, res) => { { field: { $regex: q, $options: 'i' } }, ]; } - if (urgency && ['normal','priority','last-minute'].includes(urgency)) { + if (urgency && ['normal', 'priority', 'last-minute'].includes(urgency)) { query.urgency = urgency; } @@ -279,10 +493,10 @@ export const listAvailableShifts = async (req, res) => { const items = (role === 'employer' || role === 'admin') ? docs.map(d => ({ - ...d, - applicantCount: Array.isArray(d.applicants) ? d.applicants.length : 0, - hasApplicants: Array.isArray(d.applicants) && d.applicants.length > 0, - })) + ...d, + applicantCount: Array.isArray(d.applicants) ? d.applicants.length : 0, + hasApplicants: Array.isArray(d.applicants) && d.applicants.length > 0, + })) : docs; res.json({ page, limit, total, items }); @@ -340,7 +554,7 @@ export const applyForShift = async (req, res) => { 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'; @@ -421,7 +635,7 @@ export const completeShift = async (req, res) => { shift.status = 'completed'; await shift.save(); await req.audit.log(req.user._id, ACTIONS.SHIFT_COMPLETED, { - shiftId: shift._id + shiftId: shift._id }); return res.json({ message: 'Shift completed', shift }); @@ -439,7 +653,7 @@ export const completeShift = async (req, res) => { export const getMyShifts = async (req, res) => { try { const role = req.user.role; - const uid = req.user._id; + const uid = req.user._id; const pastOnly = req.query.status === 'past'; let query = {}; @@ -526,7 +740,7 @@ export const rateShift = async (req, res) => { export const getShiftHistory = async (req, res) => { try { const role = req.user.role; - const uid = req.user._id; + const uid = req.user._id; let query = {}; if (role === 'guard') { diff --git a/app-backend/src/models/Shift.js b/app-backend/src/models/Shift.js index f5c3d8c3..48bb7691 100644 --- a/app-backend/src/models/Shift.js +++ b/app-backend/src/models/Shift.js @@ -12,10 +12,12 @@ const hhmmToMinutes = (s) => { const locationSchema = new Schema( { - street: { type: String, trim: true }, - suburb: { type: String, trim: true }, - state: { type: String, trim: true }, + street: { type: String, trim: true }, + suburb: { type: String, trim: true }, + state: { type: String, trim: true }, postcode: { type: String, match: /^\d{4}$/ }, // 4 digits (AU) + latitude: { type: Number, min: -90, max: 90 }, + longitude: { type: Number, min: -180, max: 180 }, }, { _id: false } ); @@ -59,7 +61,7 @@ const shiftSchema = new Schema( validate: { validator: function (v) { const start = hhmmToMinutes(this.startTime); - const end = hhmmToMinutes(v); + const end = hhmmToMinutes(v); if (Number.isNaN(start) || Number.isNaN(end)) return false; const duration = (end - start + 1440) % 1440; return duration > 0; @@ -71,6 +73,48 @@ const shiftSchema = new Schema( // Computed flag spansMidnight: { type: Boolean, default: false }, + shiftType: { + type: String, + enum: ['Day', 'Night'], + }, + + // Break time in minutes + breakTime: { + type: Number, + min: [0, 'Break time cannot be negative'], + validate: { + validator: function (v) { + if (v == null) return true; + const start = hhmmToMinutes(this.startTime); + const end = hhmmToMinutes(this.endTime); + if (Number.isNaN(start) || Number.isNaN(end)) return true; + const duration = (end - start + 1440) % 1440; + return v < duration; // break must be less than total shift duration + }, + message: 'Break time must be less than total shift duration', + }, + }, + + // Optional instructions + detailedInstructions: { type: String, trim: true, maxlength: 4000 }, + + // Branch ID for multi-branch support + siteId: { + type: Schema.Types.ObjectId, + ref: 'Branch', + index: true, + }, + + // Guard assignments + guardIds: { + type: [{ type: Schema.Types.ObjectId, ref: 'User' }], + default: [], + validate: { + validator: (arr) => Array.isArray(arr) && arr.every(Boolean), + message: 'Guard IDs cannot contain null/undefined', + }, + }, + // Location location: locationSchema, diff --git a/app-backend/src/routes/shift.routes.js b/app-backend/src/routes/shift.routes.js index 0d6623c2..0da97e0e 100644 --- a/app-backend/src/routes/shift.routes.js +++ b/app-backend/src/routes/shift.routes.js @@ -18,7 +18,10 @@ const router = express.Router(); * Inline role guards (same pattern as authorizeAdmin in users.route) */ const authorizeRole = (...allowed) => (req, res, next) => { - if (!req.user || !allowed.includes(req.user.role)) { + if (!req.user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + if (!allowed.includes(req.user.role)) { return res.status(403).json({ message: 'Forbidden: insufficient permissions' }); } next(); @@ -89,7 +92,7 @@ const authorizeRole = (...allowed) => (req, res, next) => { * application/json: * schema: * type: object - * required: [title, date, startTime, endTime] + * required: [title, date, startTime, endTime, shiftType, location, payRate, siteId] * properties: * title: * type: string @@ -97,7 +100,7 @@ const authorizeRole = (...allowed) => (req, res, next) => { * date: * type: string * format: date - * example: "2025-08-25" + * example: "2026-04-07" * startTime: * type: string * example: "20:00" @@ -105,14 +108,51 @@ const authorizeRole = (...allowed) => (req, res, next) => { * endTime: * type: string * example: "23:00" - * description: "HH:MM (24h), must be after startTime (same day)" + * description: "HH:MM (24h). Overnight shifts are supported when endTime is earlier than or equal to startTime." + * shiftType: + * type: string + * enum: [Day, Night] + * example: "Night" + * breakTime: + * type: number + * example: 30 + * description: "Break duration in minutes" + * detailedInstructions: + * type: string + * example: "Patrol the warehouse perimeter and check all entrances." + * guardIds: + * type: array + * description: "Optional pre-selected guards to assign/check during shift creation" + * items: + * type: string + * example: "69c8be49d6bdc8e196c35011" + * siteId: + * type: string + * example: "69c7c741658cabe0d099e4a0" + * description: "Branch/site ID that must belong to the employer" * location: * type: object + * required: [street, suburb, state, postcode] * properties: - * street: { type: string, example: "10 Dock Rd" } - * suburb: { type: string, example: "Port Melbourne" } - * state: { type: string, example: "VIC" } - * postcode: { type: string, example: "3207", description: "4 digits (AU)" } + * street: + * type: string + * example: "10 Dock Rd" + * suburb: + * type: string + * example: "Port Melbourne" + * state: + * type: string + * example: "VIC" + * postcode: + * type: string + * example: "3207" + * description: "4 digits (AU)" + * latitude: + * type: number + * example: -37.834 + * longitude: + * type: number + * example: 144.945 * urgency: * type: string * enum: [normal, priority, last-minute] @@ -124,9 +164,15 @@ const authorizeRole = (...allowed) => (req, res, next) => { * type: number * example: 30 * description: "Hourly pay rate in AUD" + * description: + * type: string + * example: "Night shift at the warehouse" + * requirements: + * type: string + * example: "Security experience preferred" * responses: * 201: { description: Shift created } - * 400: { description: Validation error } + * 400: { description: Validation error, unavailable guard, or shift clash } * 401: { description: Unauthorized } * 403: { description: Forbidden } */