diff --git a/app-backend/src/controllers/auth.controller.js b/app-backend/src/controllers/auth.controller.js index 249cfb39b..1f7a02f4b 100644 --- a/app-backend/src/controllers/auth.controller.js +++ b/app-backend/src/controllers/auth.controller.js @@ -15,7 +15,7 @@ const generateToken = (user) => { return jwt.sign( { id: user._id, role: user.role }, process.env.JWT_SECRET, - { expiresIn: '1h' } + { expiresIn: '12h' } ); }; diff --git a/app-backend/src/controllers/shiftMatching.controller.js b/app-backend/src/controllers/shiftMatching.controller.js new file mode 100644 index 000000000..3c33a40c2 --- /dev/null +++ b/app-backend/src/controllers/shiftMatching.controller.js @@ -0,0 +1,458 @@ +import mongoose from "mongoose"; +import Shift from "../models/Shift.js"; +import Guard from "../models/Guard.js"; +import Availability from "../models/Availability.js"; +import User from "../models/User.js"; +import { timeToMinutes, normalizeEnd } from "../utils/timeUtils.js"; +import GuardPreference from "../models/GuardPreference.js"; +import ShiftInvitation from "../models/ShiftInvitation.js"; + +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); + + return { start, end }; +}; + +const rangesOverlap = (rangeA, rangeB) => { + return rangeA.start < rangeB.end && rangeB.start < rangeA.end; +}; + +const getSuitabilityLevel = (score) => { + if (score >= 80) return "VERY_HIGH"; + if (score >= 60) return "HIGH"; + if (score >= 40) return "MEDIUM"; + if (score > 0) return "LOW"; + return "VERY_LOW"; +}; + +export const getShiftMatches = async (req, res) => { + try { + const { shiftId } = req.params; + + if (!mongoose.isValidObjectId(shiftId)) { + return res.status(400).json({ message: "Invalid shiftId" }); + } + + const requesterId = req.user?._id || req.user?.id; + + const shift = await Shift.findById(shiftId).lean(); + + if (!shift) { + return res.status(404).json({ message: "Shift not found" }); + } + + const isOwner = String(shift.createdBy) === String(requesterId); + const isAdmin = req.user?.role === "admin"; + + if (!isOwner && !isAdmin) { + return res.status(403).json({ + message: "Only the shift owner or admin can view shift matches", + }); + } + + const employer = await User.findById(requesterId) + .select("favourites") + .lean(); + + const favouriteIds = (employer?.favourites || []).map((id) => + String(id) + ); + + const guards = await Guard.find({ + role: "guard", + isDeleted: { $ne: true }, + }) + .select("name email role address license rating numberOfReviews") + .lean(); + + const guardIds = guards.map((guard) => guard._id); + + const availabilities = await Availability.find({ + user: { $in: guardIds }, + }).lean(); + + const shiftDay = getWeekdayName(shift.date); + const shiftRange = getShiftDateRange( + shift.date, + shift.startTime, + shift.endTime + ); + + const existingShifts = await Shift.find({ + _id: { $ne: shift._id }, + status: { $in: ["applied", "assigned"] }, + $or: [ + { acceptedBy: { $in: guardIds } }, + { applicants: { $in: guardIds } }, + { guardIds: { $in: guardIds } }, + ], + }) + .select( + "_id title date startTime endTime acceptedBy applicants guardIds status" + ) + .lean(); + + const matches = guards.map((guard) => { + let score = 0; + const reasons = []; + + const availability = availabilities.find( + (item) => String(item.user) === String(guard._id) + ); + + const hasAvailabilityDay = + availability?.days?.includes(shiftDay) || false; + + const hasAvailabilityTime = + availability?.timeSlots?.some((slot) => + shiftFitsTimeSlot(shift.startTime, shift.endTime, slot) + ) || false; + + if ( + availability?.status === "AVAILABLE" && + hasAvailabilityDay && + hasAvailabilityTime + ) { + score += 40; + reasons.push("Available for the shift day and time"); + } else if (hasAvailabilityDay && hasAvailabilityTime) { + score += 25; + reasons.push("Availability matches, but live status is not AVAILABLE"); + } else { + reasons.push("Availability does not fully match"); + } + + const hasConflict = existingShifts.some((existingShift) => { + const belongsToGuard = + String(existingShift.acceptedBy) === String(guard._id) || + (existingShift.applicants || []).some( + (id) => String(id) === String(guard._id) + ) || + (existingShift.guardIds || []).some( + (id) => String(id) === String(guard._id) + ); + + if (!belongsToGuard) return false; + + const existingRange = getShiftDateRange( + existingShift.date, + existingShift.startTime, + existingShift.endTime + ); + + return rangesOverlap(shiftRange, existingRange); + }); + + if (!hasConflict) { + score += 25; + reasons.push("No conflicting shift found"); + } else { + score -= 30; + reasons.push("Guard has a conflicting shift"); + } + + if (guard.license?.status === "verified") { + score += 15; + reasons.push("Verified licence"); + } else { + reasons.push("Licence is not verified"); + } + + if (typeof guard.rating === "number" && guard.rating > 0) { + const ratingScore = Math.min(10, guard.rating * 2); + score += ratingScore; + reasons.push(`Rating considered (${guard.rating}/5)`); + } + + if (favouriteIds.includes(String(guard._id))) { + score += 10; + reasons.push("Employer favourite guard"); + } + + if ( + shift.location?.suburb && + guard.address?.suburb && + shift.location.suburb.toLowerCase() === + guard.address.suburb.toLowerCase() + ) { + score += 5; + reasons.push("Same suburb as shift location"); + } + + const finalScore = Math.max(0, Math.round(score)); + const suitability = getSuitabilityLevel(finalScore); + + return { + guardId: guard._id, + name: guard.name, + email: guard.email, + rating: guard.rating || 0, + numberOfReviews: guard.numberOfReviews || 0, + licenseStatus: guard.license?.status || "none", + availabilityStatus: availability?.status || "NOT_SET", + score: finalScore, + suitability, + reasons, + }; + }); + + matches.sort((a, b) => b.score - a.score); + + return res.status(200).json({ + shiftId: shift._id, + shiftTitle: shift.title, + shiftStatus: shift.status, + totalGuardsChecked: guards.length, + totalMatches: matches.length, + matches, + }); + } catch (error) { + return res.status(500).json({ + message: "Failed to generate shift matches", + error: error.message, + }); + } +}; + +export const createOrUpdateGuardPreference = async (req, res) => { + try { + if (req.user.role !== "guard") { + return res.status(403).json({ message: "Only guards can set preferences" }); + } + + const guardId = req.user._id || req.user.id; + + const { + preferredShiftTypes = [], + preferredFields = [], + preferredSuburbs = [], + minimumPayRate = 0, + acceptsUrgentShifts = false, + } = req.body; + + const preference = await GuardPreference.findOneAndUpdate( + { guardId }, + { + guardId, + preferredShiftTypes, + preferredFields, + preferredSuburbs, + minimumPayRate, + acceptsUrgentShifts, + }, + { new: true, upsert: true, runValidators: true } + ); + + return res.status(200).json({ + message: "Guard preferences saved", + preference, + }); + } catch (error) { + return res.status(500).json({ + message: "Failed to save guard preferences", + error: error.message, + }); + } +}; + +export const getMyGuardPreference = async (req, res) => { + try { + if (req.user.role !== "guard") { + return res.status(403).json({ message: "Only guards can view preferences" }); + } + + const guardId = req.user._id || req.user.id; + + const preference = await GuardPreference.findOne({ guardId }); + + return res.status(200).json({ + preference, + }); + } catch (error) { + return res.status(500).json({ + message: "Failed to fetch guard preferences", + error: error.message, + }); + } +}; + +export const inviteGuardToShift = async (req, res) => { + try { + if (req.user.role !== "employer" && req.user.role !== "admin") { + return res.status(403).json({ message: "Only employers/admins can invite guards" }); + } + + const { shiftId, guardId } = req.params; + const { message } = req.body; + + if (!mongoose.isValidObjectId(shiftId) || !mongoose.isValidObjectId(guardId)) { + return res.status(400).json({ message: "Invalid shiftId or guardId" }); + } + + const employerId = req.user._id || req.user.id; + + const shift = await Shift.findById(shiftId); + + if (!shift) { + return res.status(404).json({ message: "Shift not found" }); + } + + const isOwner = String(shift.createdBy) === String(employerId); + const isAdmin = req.user.role === "admin"; + + if (!isOwner && !isAdmin) { + return res.status(403).json({ message: "Not allowed to invite for this shift" }); + } + + const guard = await Guard.findById(guardId); + + if (!guard || guard.isDeleted) { + return res.status(404).json({ message: "Guard not found" }); + } + + const invitation = await ShiftInvitation.findOneAndUpdate( + { shiftId, guardId }, + { + shiftId, + guardId, + employerId, + message, + status: "PENDING", + respondedAt: null, + }, + { new: true, upsert: true, runValidators: true } + ); + + return res.status(201).json({ + message: "Invitation sent", + invitation, + }); + } catch (error) { + if (error.code === 11000) { + return res.status(409).json({ message: "Invitation already exists" }); + } + + return res.status(500).json({ + message: "Failed to invite guard", + error: error.message, + }); + } +}; + +export const getMyShiftInvitations = async (req, res) => { + try { + const userId = req.user._id || req.user.id; + + let query = {}; + + if (req.user.role === "guard") { + query.guardId = userId; + } else if (req.user.role === "employer") { + query.employerId = userId; + } else if (req.user.role !== "admin") { + return res.status(403).json({ message: "Not allowed to view invitations" }); + } + + const invitations = await ShiftInvitation.find(query) + .populate("shiftId", "title date startTime endTime status location urgency payRate") + .populate("guardId", "name email role") + .populate("employerId", "name email role") + .sort({ createdAt: -1 }); + + return res.status(200).json({ + total: invitations.length, + invitations, + }); + } catch (error) { + return res.status(500).json({ + message: "Failed to fetch invitations", + error: error.message, + }); + } +}; + +export const respondToShiftInvitation = async (req, res) => { + try { + if (req.user.role !== "guard") { + return res.status(403).json({ message: "Only guards can respond to invitations" }); + } + + const { invitationId } = req.params; + const { status } = req.body; + + if (!["ACCEPTED", "DECLINED"].includes(status)) { + return res.status(400).json({ message: "status must be ACCEPTED or DECLINED" }); + } + + if (!mongoose.isValidObjectId(invitationId)) { + return res.status(400).json({ message: "Invalid invitationId" }); + } + + const guardId = req.user._id || req.user.id; + + const invitation = await ShiftInvitation.findById(invitationId); + + if (!invitation) { + return res.status(404).json({ message: "Invitation not found" }); + } + + if (String(invitation.guardId) !== String(guardId)) { + return res.status(403).json({ message: "Not allowed to respond to this invitation" }); + } + + if (invitation.status !== "PENDING") { + return res.status(400).json({ message: `Invitation already ${invitation.status}` }); + } + + invitation.status = status; + invitation.respondedAt = new Date(); + + await invitation.save(); + + return res.status(200).json({ + message: `Invitation ${status.toLowerCase()}`, + invitation, + }); + } catch (error) { + return res.status(500).json({ + message: "Failed to respond to invitation", + error: error.message, + }); + } +}; \ No newline at end of file diff --git a/app-backend/src/models/GuardPreference.js b/app-backend/src/models/GuardPreference.js new file mode 100644 index 000000000..cdb1d2d8a --- /dev/null +++ b/app-backend/src/models/GuardPreference.js @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +const guardPreferenceSchema = new mongoose.Schema( + { + guardId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + index: true, + }, + + preferredShiftTypes: { + type: [String], + enum: ["Day", "Night"], + default: [], + }, + + preferredFields: { + type: [String], + default: [], + }, + + preferredSuburbs: { + type: [String], + default: [], + }, + + minimumPayRate: { + type: Number, + min: 0, + default: 0, + }, + + acceptsUrgentShifts: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +export default mongoose.model("GuardPreference", guardPreferenceSchema); \ No newline at end of file diff --git a/app-backend/src/models/ShiftInvitation.js b/app-backend/src/models/ShiftInvitation.js new file mode 100644 index 000000000..1904ae748 --- /dev/null +++ b/app-backend/src/models/ShiftInvitation.js @@ -0,0 +1,52 @@ +import mongoose from "mongoose"; + +const shiftInvitationSchema = new mongoose.Schema( + { + shiftId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Shift", + required: true, + index: true, + }, + + guardId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + employerId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + status: { + type: String, + enum: ["PENDING", "ACCEPTED", "DECLINED", "EXPIRED"], + default: "PENDING", + index: true, + }, + + message: { + type: String, + trim: true, + maxlength: 500, + }, + + respondedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true } +); + +shiftInvitationSchema.index( + { shiftId: 1, guardId: 1 }, + { unique: true } +); + +export default mongoose.model("ShiftInvitation", shiftInvitationSchema); \ No newline at end of file diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index 33371cfbd..3bb21899d 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -15,11 +15,13 @@ import notificationRoutes from './notification.routes.js' import equipmentRoutes from './equipment.routes.js'; import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; +import shiftMatchingRoutes from "./shiftMatching.routes.js"; const router = express.Router(); router.use('/documents', documentRoutes); router.use('/health', healthRoutes); router.use('/auth', authRoutes); router.use('/shifts', shiftRoutes); +router.use("/shift-matches", shiftMatchingRoutes); router.use('/messages', messageRoutes); router.use('/admin', adminRoutes); router.use('/availability', availabilityRoutes); diff --git a/app-backend/src/routes/shiftMatching.routes.js b/app-backend/src/routes/shiftMatching.routes.js new file mode 100644 index 000000000..b782912a4 --- /dev/null +++ b/app-backend/src/routes/shiftMatching.routes.js @@ -0,0 +1,285 @@ +import express from "express"; +import auth from "../middleware/auth.js"; +import { + getShiftMatches, + createOrUpdateGuardPreference, + getMyGuardPreference, + inviteGuardToShift, + getMyShiftInvitations, + respondToShiftInvitation, +} from "../controllers/shiftMatching.controller.js"; + +const router = express.Router(); + +const employerOrAdminOnly = (req, res, next) => { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + if (!["employer", "admin"].includes(req.user.role)) { + return res.status(403).json({ + message: "Only employers or admins can access shift matches", + }); + } + + next(); +}; + +const guardOnly = (req, res, next) => { + if (!req.user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + if (req.user.role !== "guard") { + return res.status(403).json({ + message: "Only guards can access this endpoint", + }); + } + + next(); +}; + +/** + * @swagger + * tags: + * name: Shift Matching + * description: Guard recommendation, preference, invitation, and urgent shift matching system + */ + +/** + * @swagger + * /api/v1/shift-matches/preferences/me: + * get: + * summary: Get logged-in guard's matching preferences + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Guard preferences returned successfully + * 401: + * description: Unauthorized + * 403: + * description: Only guards can view preferences + * + * post: + * summary: Create or update logged-in guard's matching preferences + * description: | + * Stores guard preferences used by the shift matching system. + * + * These preferences can include preferred shift types, fields, suburbs, + * minimum pay rate, and urgent shift availability. + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * preferredShiftTypes: + * type: array + * items: + * type: string + * enum: [Day, Night] + * example: ["Day"] + * preferredFields: + * type: array + * items: + * type: string + * example: ["warehouse", "patrol"] + * preferredSuburbs: + * type: array + * items: + * type: string + * example: ["Fitzroy", "Melbourne"] + * minimumPayRate: + * type: number + * example: 30 + * acceptsUrgentShifts: + * type: boolean + * example: true + * responses: + * 200: + * description: Guard preferences saved successfully + * 401: + * description: Unauthorized + * 403: + * description: Only guards can set preferences + */ +router + .route("/preferences/me") + .get(auth, guardOnly, getMyGuardPreference) + .post(auth, guardOnly, createOrUpdateGuardPreference); + +/** + * @swagger + * /api/v1/shift-matches/invitations/me: + * get: + * summary: Get shift invitations for logged-in user + * description: | + * Guards see invitations sent to them. + * Employers see invitations they have sent. + * Admins can view all invitations. + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Invitations returned successfully + * 401: + * description: Unauthorized + * 403: + * description: Not allowed to view invitations + */ +router.get("/invitations/me", auth, getMyShiftInvitations); + +/** + * @swagger + * /api/v1/shift-matches/invitations/{invitationId}/respond: + * patch: + * summary: Respond to a shift invitation + * description: | + * Allows a guard to accept or decline a pending shift invitation. + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: invitationId + * required: true + * schema: + * type: string + * description: Shift invitation ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - status + * properties: + * status: + * type: string + * enum: [ACCEPTED, DECLINED] + * example: ACCEPTED + * responses: + * 200: + * description: Invitation response saved successfully + * 400: + * description: Invalid status or invitation already handled + * 401: + * description: Unauthorized + * 403: + * description: Only invited guard can respond + * 404: + * description: Invitation not found + */ +router.patch( + "/invitations/:invitationId/respond", + auth, + guardOnly, + respondToShiftInvitation +); + +/** + * @swagger + * /api/v1/shift-matches/{shiftId}/invite/{guardId}: + * post: + * summary: Invite a matched guard to a shift + * description: | + * Allows an employer/admin to invite a guard to a shift. + * This does not automatically assign the guard. + * The guard must accept or decline the invitation. + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: shiftId + * required: true + * schema: + * type: string + * description: Shift ID + * - in: path + * name: guardId + * required: true + * schema: + * type: string + * description: Guard ID + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "You are a strong match for this shift. Please review and respond." + * responses: + * 201: + * description: Invitation sent successfully + * 400: + * description: Invalid shift or guard ID + * 401: + * description: Unauthorized + * 403: + * description: Not allowed to invite for this shift + * 404: + * description: Shift or guard not found + * 409: + * description: Invitation already exists + */ +router.post( + "/:shiftId/invite/:guardId", + auth, + employerOrAdminOnly, + inviteGuardToShift +); + +/** + * @swagger + * /api/v1/shift-matches/{shiftId}: + * get: + * summary: Get recommended guards for a shift + * description: | + * Returns ranked guard suggestions for an existing shift. + * This endpoint is read-only and does not assign guards or modify the shift. + * + * Matching considers: + * - Guard availability + * - Live availability status + * - Shift time conflicts + * - Verified licence + * - Guard rating + * - Employer favourites + * - Same suburb match + * tags: [Shift Matching] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: shiftId + * required: true + * schema: + * type: string + * description: Shift ID to generate matches for + * responses: + * 200: + * description: Ranked guard matches returned successfully + * 400: + * description: Invalid shift ID + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Shift not found + */ +router.get("/:shiftId", auth, employerOrAdminOnly, getShiftMatches); + +export default router; \ No newline at end of file