diff --git a/app-backend/README.md b/app-backend/README.md index 890451a46..98609f019 100644 --- a/app-backend/README.md +++ b/app-backend/README.md @@ -157,6 +157,75 @@ Approving or rejecting a shift request currently records the decision on the `Sh --- +## SOS / Emergency API + +The backend exposes two authenticated SOS endpoint families: + +- Legacy emergency family: `/api/v1/emergency/sos` +- Guard App family: `/api/v1/sos` + +Both families use the same controller/service logic. Calling one alias does not call the other alias +or create duplicate writes. + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| `POST` | `/api/v1/emergency/sos` | `guard` | Create an SOS using the legacy emergency route. | +| `POST` | `/api/v1/sos/trigger` | `guard` | Create an SOS using the Guard App route contract. | +| `GET` | `/api/v1/emergency/sos` | `admin`, `employer` | List SOS records visible to the authenticated admin or employer. | +| `GET` | `/api/v1/emergency/sos/active` | `guard`, `employer`, `admin` | Get the latest active SOS in the authenticated user's scope. | +| `GET` | `/api/v1/sos/active` | `guard`, `employer`, `admin` | Guard App alias for active SOS lookup. | +| `GET` | `/api/v1/emergency/sos/:id` | `guard`, `employer`, `admin` | Get one SOS in the authenticated user's scope. | +| `GET` | `/api/v1/sos/:id` | `guard`, `employer`, `admin` | Guard App alias for one SOS status. | +| `POST` | `/api/v1/emergency/sos/:id/location` | `guard` | Update location for an active SOS owned by the guard. | +| `POST` | `/api/v1/sos/:id/location` | `guard` | Guard App alias for location update. | +| `POST` | `/api/v1/emergency/sos/:id/note` | `guard` | Add or replace guard-provided SOS context. | +| `POST` | `/api/v1/sos/:id/note` | `guard` | Guard App alias for note update. | +| `POST` | `/api/v1/emergency/sos/:id/cancel` | `guard` | Cancel an active SOS owned by the guard. | +| `POST` | `/api/v1/sos/:id/cancel` | `guard` | Guard App alias for cancellation. | +| `PUT` | `/api/v1/emergency/sos/:id` | `admin`, `employer` | Transition SOS status in the authenticated user's scope. | + +SOS responses include both the existing backend `data` field and the Guard App `sos` field. The +Guard App shape includes `_id`, `guardId`, optional `shiftId`, `triggeredAt`, lower-case status, +`location`, `history`, `note`, `emergencyContact`, `cancelledAt`, and `resolvedAt`. + +### SOS roles and visibility + +- Guards can create SOS records and can read, update location, add notes, or cancel only their own + SOS records. +- Employers can read or transition only SOS records tied to shifts where `Shift.createdBy` is their + user ID. +- Admins can read or transition SOS records across the system. +- SOS records without `shiftId` are intentionally not visible to employers. The Guard App must send + `shiftId` during SOS creation when employer visibility is required. + +### SOS status transitions + +SOS records use controlled backend statuses: + +- `ACTIVE -> ESCALATED` +- `ACTIVE -> RESOLVED` +- `ACTIVE -> CANCELLED` +- `ESCALATED -> RESOLVED` +- `ESCALATED -> CANCELLED` + +`RESOLVED` and `CANCELLED` are terminal. Terminal records cannot be reactivated, cancelled again, +resolved again, or updated with new notes/location. + +The backend allows only one active SOS per guard at a time. `ACTIVE` and `ESCALATED` both count as +active. After any SOS creation, the guard has a 60-second creation cooldown even if the prior SOS has +already reached a terminal status. + +### SOS limitations + +- The Guard App currently has `USE_MOCK_SOS = true` in `guard_app/src/api/sos.ts`; until that flag + changes, the mobile UI continues to use its local mock flow instead of these backend endpoints. +- The SOS backend does not place automatic phone calls. +- The SOS backend does not contact emergency services or any external dispatch provider. +- Firebase/APNs and broader notification infrastructure are intentionally out of scope for the SOS + backend completion. + +--- + ## ๐Ÿงช Testing ```bash diff --git a/app-backend/src/controllers/emergency.controller.js b/app-backend/src/controllers/emergency.controller.js index 3c8130c06..5eb7c005c 100644 --- a/app-backend/src/controllers/emergency.controller.js +++ b/app-backend/src/controllers/emergency.controller.js @@ -1,95 +1,110 @@ -import Emergency from "../models/Emergency.js"; -import Notification from "../models/Notification.js"; +import { + cancelSOSForUser, + createSOS, + EmergencyServiceError, + getActiveSOSForUser, + getSOSById, + listSOS, + serializeSOS, + transitionSOS, + updateSOSLocationForUser, + updateSOSNoteForUser, +} from "../services/emergency.service.js"; -// ๐Ÿ”ด Trigger SOS -export const triggerSOS = async (req, res) => { - try { - const guardId = req.user.id; - const { latitude, longitude, message } = req.body; - - if (!latitude || !longitude) { - return res.status(400).json({ - message: "Latitude and longitude are required", - }); - } - - // ๐Ÿšซ Prevent spam (1 min cooldown) - const lastSOS = await Emergency.findOne({ guardId }).sort({ - createdAt: -1, - }); +const sendSOS = (res, statusCode, message, sos) => { + res.status(statusCode).json({ + message, + data: sos, + sos: serializeSOS(sos), + }); +}; - if (lastSOS && Date.now() - new Date(lastSOS.createdAt).getTime() < 60000) { - return res.status(429).json({ - message: "SOS already triggered recently. Please wait.", - }); +const handleError = (res, error) => { + if (error instanceof EmergencyServiceError) { + const response = { message: error.message }; + if (error.details?.sos) { + response.data = error.details.sos; + response.sos = serializeSOS(error.details.sos); } + return res.status(error.statusCode).json(response); + } - const sos = await Emergency.create({ - guardId, - latitude, - longitude, - message, - }); - - // ๐Ÿ”” Notification (basic) - await Notification.create({ - title: "๐Ÿšจ SOS Alert", - message: `Guard ${guardId} triggered SOS`, - type: "SOS", - priority: "HIGH", - }); + console.error("Emergency controller error:", error); + return res.status(500).json({ message: "Internal server error" }); +}; - res.status(201).json({ - message: "SOS triggered successfully", - data: sos, - }); +export const triggerSOS = async (req, res) => { + try { + const sos = await createSOS(req.user, req.body); + return sendSOS(res, 201, "SOS triggered successfully", sos); } catch (error) { - console.error(error); - res.status(500).json({ message: "Internal server error" }); + return handleError(res, error); } }; -// ๐Ÿ“œ Get SOS history export const getSOSHistory = async (req, res) => { try { - const data = await Emergency.find() - .populate("guardId", "name email") - .sort({ createdAt: -1 }); - - res.status(200).json({ + const data = await listSOS(req.user); + return res.status(200).json({ count: data.length, data, + sos: data.map(serializeSOS), }); - } catch { - res.status(500).json({ message: "Internal server error" }); + } catch (error) { + return handleError(res, error); } }; -// โœ… Update SOS status -export const updateSOSStatus = async (req, res) => { +export const getActiveSOS = async (req, res) => { try { - const { id } = req.params; - const { status } = req.body; + const sos = await getActiveSOSForUser(req.user); + return res.status(200).json({ data: sos, sos: serializeSOS(sos) }); + } catch (error) { + return handleError(res, error); + } +}; - if (!["ACTIVE", "RESOLVED"].includes(status)) { - return res.status(400).json({ message: "Invalid status" }); - } +export const getSOSStatus = async (req, res) => { + try { + const sos = await getSOSById(req.user, req.params.id); + return res.status(200).json({ data: sos, sos: serializeSOS(sos) }); + } catch (error) { + return handleError(res, error); + } +}; - const sos = await Emergency.findByIdAndUpdate( - id, - { status }, - { new: true }, - ); +export const updateSOSStatus = async (req, res) => { + try { + const sos = await transitionSOS(req.user, req.params.id, req.body.status, req.body.message); + return sendSOS(res, 200, "Status updated", sos); + } catch (error) { + return handleError(res, error); + } +}; - if (!sos) { - return res.status(404).json({ message: "SOS not found" }); - } +export const updateSOSLocation = async (req, res) => { + try { + const sos = await updateSOSLocationForUser(req.user, req.params.id, req.body); + return sendSOS(res, 200, "SOS location updated", sos); + } catch (error) { + return handleError(res, error); + } +}; - res.status(200).json({ - message: "Status updated", - data: sos, - }); - } catch { - res.status(500).json({ message: "Internal server error" }); +export const addSOSNote = async (req, res) => { + try { + const sos = await updateSOSNoteForUser(req.user, req.params.id, req.body.note); + return sendSOS(res, 200, "SOS note updated", sos); + } catch (error) { + return handleError(res, error); + } +}; + +export const cancelSOS = async (req, res) => { + try { + const sos = await cancelSOSForUser(req.user, req.params.id); + return sendSOS(res, 200, "SOS cancelled", sos); + } catch (error) { + return handleError(res, error); } }; diff --git a/app-backend/src/models/Emergency.js b/app-backend/src/models/Emergency.js index 7b39f4391..4d93f10c8 100644 --- a/app-backend/src/models/Emergency.js +++ b/app-backend/src/models/Emergency.js @@ -1,5 +1,51 @@ import mongoose from "mongoose"; +const statusHistorySchema = new mongoose.Schema( + { + status: { + type: String, + enum: ["ACTIVE", "ESCALATED", "RESOLVED", "CANCELLED"], + required: true, + }, + message: { + type: String, + trim: true, + maxlength: 500, + }, + at: { + type: Date, + default: Date.now, + }, + by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + }, + { _id: false }, +); + +const locationUpdateSchema = new mongoose.Schema( + { + latitude: { + type: Number, + required: true, + min: -90, + max: 90, + }, + longitude: { + type: Number, + required: true, + min: -180, + max: 180, + }, + timestamp: { + type: Date, + default: Date.now, + }, + }, + { _id: false }, +); + const emergencySchema = new mongoose.Schema( { guardId: { @@ -7,25 +53,65 @@ const emergencySchema = new mongoose.Schema( ref: "User", required: true, }, + shiftId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Shift", + default: null, + index: true, + }, latitude: { type: Number, required: true, + min: -90, + max: 90, }, longitude: { type: Number, required: true, + min: -180, + max: 180, }, message: { type: String, default: "", + trim: true, + maxlength: 500, + }, + note: { + type: String, + default: "", + trim: true, + maxlength: 2000, }, status: { type: String, - enum: ["ACTIVE", "RESOLVED"], + enum: ["ACTIVE", "ESCALATED", "RESOLVED", "CANCELLED"], default: "ACTIVE", }, + statusHistory: { + type: [statusHistorySchema], + default: [], + }, + locationUpdates: { + type: [locationUpdateSchema], + default: [], + }, + cancelledAt: { + type: Date, + default: null, + }, + resolvedAt: { + type: Date, + default: null, + }, + escalatedAt: { + type: Date, + default: null, + }, }, { timestamps: true } ); -export default mongoose.model("Emergency", emergencySchema); \ No newline at end of file +emergencySchema.index({ guardId: 1, status: 1, createdAt: -1 }); + +export default mongoose.model("Emergency", emergencySchema); diff --git a/app-backend/src/routes/emergency.routes.js b/app-backend/src/routes/emergency.routes.js index d9a721bfc..378429f8c 100644 --- a/app-backend/src/routes/emergency.routes.js +++ b/app-backend/src/routes/emergency.routes.js @@ -4,6 +4,7 @@ import { getSOSHistory, updateSOSStatus, } from "../controllers/emergency.controller.js"; +import { registerSOSInteractionRoutes } from "./sos.route-set.js"; // โœ… correct auth import import auth from "../middleware/auth.js"; @@ -79,6 +80,8 @@ router.get( getSOSHistory ); +registerSOSInteractionRoutes(router, "/sos"); + /** * @swagger * /api/v1/emergency/sos/{id}: @@ -87,7 +90,7 @@ router.get( * tags: [Emergency] * security: * - bearerAuth: [] - * description: Admin/Employer resolves SOS + * description: Admin/Employer transitions SOS status * parameters: * - in: path * name: id @@ -103,7 +106,7 @@ router.get( * properties: * status: * type: string - * enum: [ACTIVE, RESOLVED] + * enum: [ACTIVE, ESCALATED, RESOLVED, CANCELLED] * responses: * 200: * description: SOS updated @@ -115,4 +118,4 @@ router.put( updateSOSStatus ); -export default router; \ No newline at end of file +export default router; diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index 07fba2210..cb4184f45 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 sosRoutes from "./sos.routes.js"; import timesheetRoutes from './timesheet.routes.js'; import shiftRequestRoutes from './shiftrequest.routes.js'; const router = express.Router(); @@ -37,4 +38,5 @@ router.use('/timesheets', timesheetRoutes); router.use('/shift-requests', shiftRequestRoutes); router.use('/equipment', equipmentRoutes); router.use("/emergency", emergencyRoutes); +router.use("/sos", sosRoutes); export default router; diff --git a/app-backend/src/routes/sos.route-set.js b/app-backend/src/routes/sos.route-set.js new file mode 100644 index 000000000..07bf53491 --- /dev/null +++ b/app-backend/src/routes/sos.route-set.js @@ -0,0 +1,17 @@ +import { + addSOSNote, + cancelSOS, + getActiveSOS, + getSOSStatus, + updateSOSLocation, +} from "../controllers/emergency.controller.js"; +import auth from "../middleware/auth.js"; +import { allowRoles } from "../middleware/role.js"; + +export const registerSOSInteractionRoutes = (router, basePath = "") => { + router.get(`${basePath}/active`, auth, allowRoles("guard", "admin", "employer"), getActiveSOS); + router.get(`${basePath}/:id`, auth, allowRoles("guard", "admin", "employer"), getSOSStatus); + router.post(`${basePath}/:id/location`, auth, allowRoles("guard"), updateSOSLocation); + router.post(`${basePath}/:id/note`, auth, allowRoles("guard"), addSOSNote); + router.post(`${basePath}/:id/cancel`, auth, allowRoles("guard"), cancelSOS); +}; diff --git a/app-backend/src/routes/sos.routes.js b/app-backend/src/routes/sos.routes.js new file mode 100644 index 000000000..58e3742f0 --- /dev/null +++ b/app-backend/src/routes/sos.routes.js @@ -0,0 +1,13 @@ +import express from "express"; + +import { triggerSOS } from "../controllers/emergency.controller.js"; +import auth from "../middleware/auth.js"; +import { allowRoles } from "../middleware/role.js"; +import { registerSOSInteractionRoutes } from "./sos.route-set.js"; + +const router = express.Router(); + +router.post("/trigger", auth, allowRoles("guard"), triggerSOS); +registerSOSInteractionRoutes(router); + +export default router; diff --git a/app-backend/src/services/emergency.service.js b/app-backend/src/services/emergency.service.js new file mode 100644 index 000000000..7d1e3a6be --- /dev/null +++ b/app-backend/src/services/emergency.service.js @@ -0,0 +1,304 @@ +import mongoose from "mongoose"; + +import Emergency from "../models/Emergency.js"; +import Shift from "../models/Shift.js"; + +export const TERMINAL_STATUSES = ["RESOLVED", "CANCELLED"]; +export const ACTIVE_STATUSES = ["ACTIVE", "ESCALATED"]; +export const SOS_STATUSES = ["ACTIVE", "ESCALATED", "RESOLVED", "CANCELLED"]; + +const MAX_MESSAGE_LENGTH = 500; +const MAX_NOTE_LENGTH = 2000; +const COOLDOWN_MS = 60_000; +const ALLOWED_TRANSITIONS = { + ACTIVE: ["ESCALATED", "RESOLVED", "CANCELLED"], + ESCALATED: ["RESOLVED", "CANCELLED"], + RESOLVED: [], + CANCELLED: [], +}; +const STATUS_TO_GUARD_STATUS = { + ACTIVE: "pending", + ESCALATED: "connected", + RESOLVED: "resolved", + CANCELLED: "cancelled", +}; + +export class EmergencyServiceError extends Error { + constructor(statusCode, message, details = {}) { + super(message); + this.name = "EmergencyServiceError"; + this.statusCode = statusCode; + this.details = details; + } +} + +const getUserId = (user) => user?._id || user?.id; + +const isValidObjectId = (value) => mongoose.isValidObjectId(value); + +const parseCoordinate = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const parseOptionalTimestamp = (value) => { + if (value === undefined || value === null || value === "") return new Date(); + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new EmergencyServiceError(400, "timestamp must be a valid date/time value"); + } + return date; +}; + +const normalizeText = (value) => (typeof value === "string" ? value.trim() : ""); + +const validateTextLength = (value, fieldName, maxLength) => { + if (value.length > maxLength) { + throw new EmergencyServiceError(400, `${fieldName} must be ${maxLength} characters or fewer`); + } +}; + +const isValidLatitude = (value) => value !== null && value >= -90 && value <= 90; +const isValidLongitude = (value) => value !== null && value >= -180 && value <= 180; + +export const normalizeStatus = (status) => { + if (typeof status !== "string") return null; + return status.toUpperCase(); +}; + +const mapHistoryStatus = (status) => STATUS_TO_GUARD_STATUS[status] || "pending"; + +const toISOStringOrNow = (value) => { + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +}; + +const getStatusMessage = (status) => { + switch (status) { + case "ESCALATED": + return "SOS escalated"; + case "RESOLVED": + return "SOS resolved"; + case "CANCELLED": + return "SOS cancelled"; + case "ACTIVE": + default: + return "SOS active"; + } +}; + +export const serializeSOS = (sos) => { + const plain = typeof sos.toObject === "function" ? sos.toObject() : sos; + const latestLocationAt = plain.locationUpdates?.at(-1)?.timestamp || plain.updatedAt || plain.createdAt; + + return { + _id: String(plain._id), + guardId: String(plain.guardId?._id || plain.guardId), + shiftId: plain.shiftId ? String(plain.shiftId?._id || plain.shiftId) : null, + triggeredAt: toISOStringOrNow(plain.createdAt), + status: mapHistoryStatus(plain.status), + statusMessage: getStatusMessage(plain.status), + location: { + latitude: plain.latitude, + longitude: plain.longitude, + timestamp: latestLocationAt ? new Date(latestLocationAt).getTime() : undefined, + }, + history: (plain.statusHistory || []).map((entry) => ({ + status: mapHistoryStatus(entry.status), + message: entry.message, + at: toISOStringOrNow(entry.at), + })), + note: plain.note || plain.message || "", + emergencyContact: { + name: "Emergency Services", + phone: "000", + }, + cancelledAt: plain.cancelledAt ? new Date(plain.cancelledAt).toISOString() : null, + resolvedAt: plain.resolvedAt ? new Date(plain.resolvedAt).toISOString() : null, + }; +}; + +const canTransition = (from, to) => { + if (from === to) return false; + return ALLOWED_TRANSITIONS[from]?.includes(to) || false; +}; + +export const buildScopedEmergencyQuery = async (user, baseQuery = {}) => { + if (user?.role === "admin") return baseQuery; + + const userId = getUserId(user); + if (user?.role === "guard") { + return { ...baseQuery, guardId: userId }; + } + + if (user?.role === "employer") { + const shifts = await Shift.find({ createdBy: userId }).select("_id").lean(); + return { ...baseQuery, shiftId: { $in: shifts.map((shift) => shift._id) } }; + } + + return { ...baseQuery, _id: null }; +}; + +const findScopedSOSById = async (user, id) => { + if (!isValidObjectId(id)) { + throw new EmergencyServiceError(404, "SOS not found"); + } + const query = await buildScopedEmergencyQuery(user, { _id: id }); + const sos = await Emergency.findOne(query); + if (!sos) { + throw new EmergencyServiceError(404, "SOS not found"); + } + return sos; +}; + +const validateCoordinates = (body) => { + const latitude = parseCoordinate(body.latitude); + const longitude = parseCoordinate(body.longitude); + + if (!isValidLatitude(latitude) || !isValidLongitude(longitude)) { + throw new EmergencyServiceError( + 400, + "Latitude must be between -90 and 90 and longitude must be between -180 and 180", + ); + } + + return { latitude, longitude }; +}; + +export const createSOS = async (user, body) => { + const guardId = getUserId(user); + if (!guardId) { + throw new EmergencyServiceError(401, "Authenticated user id missing from context"); + } + + const { latitude, longitude } = validateCoordinates(body); + const message = normalizeText(body.message); + const note = normalizeText(body.note) || message; + const shiftId = body.shiftId || null; + const timestamp = parseOptionalTimestamp(body.timestamp); + + validateTextLength(message, "message", MAX_MESSAGE_LENGTH); + validateTextLength(note, "note", MAX_NOTE_LENGTH); + + if (shiftId && !isValidObjectId(shiftId)) { + throw new EmergencyServiceError(400, "shiftId must be a valid ID"); + } + + const activeSOS = await Emergency.findOne({ + guardId, + status: { $in: ACTIVE_STATUSES }, + }).sort({ createdAt: -1 }); + + if (activeSOS) { + throw new EmergencyServiceError(409, "An active SOS already exists for this guard", { + sos: activeSOS, + }); + } + + const lastSOS = await Emergency.findOne({ guardId }).sort({ createdAt: -1 }); + if (lastSOS && Date.now() - new Date(lastSOS.createdAt).getTime() < COOLDOWN_MS) { + throw new EmergencyServiceError(429, "SOS already triggered recently. Please wait."); + } + + const sos = await Emergency.create({ + guardId, + shiftId, + latitude, + longitude, + message, + note, + statusHistory: [{ status: "ACTIVE", message: "SOS triggered", by: guardId }], + locationUpdates: [{ latitude, longitude, timestamp }], + }); + + return sos; +}; + +export const listSOS = async (user) => { + const query = await buildScopedEmergencyQuery(user); + return Emergency.find(query) + .populate("guardId", "name email") + .populate("shiftId", "title date createdBy") + .sort({ createdAt: -1 }); +}; + +export const getActiveSOSForUser = async (user) => { + const query = await buildScopedEmergencyQuery(user, { status: { $in: ACTIVE_STATUSES } }); + const sos = await Emergency.findOne(query).sort({ createdAt: -1 }); + if (!sos) { + throw new EmergencyServiceError(404, "No active SOS found"); + } + return sos; +}; + +export const getSOSById = async (user, id) => findScopedSOSById(user, id); + +export const transitionSOS = async (user, id, statusInput, messageInput = "") => { + const status = normalizeStatus(statusInput); + const message = normalizeText(messageInput); + + if (!SOS_STATUSES.includes(status)) { + throw new EmergencyServiceError(400, "Invalid status"); + } + validateTextLength(message, "message", MAX_MESSAGE_LENGTH); + + const sos = await findScopedSOSById(user, id); + if (!canTransition(sos.status, status)) { + throw new EmergencyServiceError(409, `Cannot transition SOS from ${sos.status} to ${status}`); + } + + sos.status = status; + if (status === "RESOLVED") sos.resolvedAt = new Date(); + if (status === "CANCELLED") sos.cancelledAt = new Date(); + if (status === "ESCALATED") sos.escalatedAt = new Date(); + sos.statusHistory.push({ + status, + message: message || getStatusMessage(status), + by: getUserId(user), + }); + + await sos.save(); + return sos; +}; + +export const updateSOSLocationForUser = async (user, id, body) => { + const { latitude, longitude } = validateCoordinates(body); + const timestamp = parseOptionalTimestamp(body.timestamp); + const sos = await findScopedSOSById(user, id); + + if (TERMINAL_STATUSES.includes(sos.status)) { + throw new EmergencyServiceError(409, "Cannot update location for an inactive SOS"); + } + + sos.latitude = latitude; + sos.longitude = longitude; + sos.locationUpdates.push({ latitude, longitude, timestamp }); + + await sos.save(); + return sos; +}; + +export const updateSOSNoteForUser = async (user, id, noteInput) => { + const note = normalizeText(noteInput); + if (!note) { + throw new EmergencyServiceError(400, "note is required"); + } + validateTextLength(note, "note", MAX_NOTE_LENGTH); + + const sos = await findScopedSOSById(user, id); + if (TERMINAL_STATUSES.includes(sos.status)) { + throw new EmergencyServiceError(409, "Cannot add a note to an inactive SOS"); + } + + sos.note = note; + sos.statusHistory.push({ + status: sos.status, + message: "SOS note updated", + by: getUserId(user), + }); + + await sos.save(); + return sos; +}; + +export const cancelSOSForUser = async (user, id) => transitionSOS(user, id, "CANCELLED", "SOS cancelled by guard"); diff --git a/app-backend/tests/emergency.controller.test.js b/app-backend/tests/emergency.controller.test.js new file mode 100644 index 000000000..c542bbc1b --- /dev/null +++ b/app-backend/tests/emergency.controller.test.js @@ -0,0 +1,388 @@ +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import { beforeEach, describe, expect, jest, test } from "@jest/globals"; + +import emergencyRoutes from "../src/routes/emergency.routes.js"; +import sosRoutes from "../src/routes/sos.routes.js"; +import Emergency from "../src/models/Emergency.js"; +import Shift from "../src/models/Shift.js"; + +jest.mock("../src/models/Emergency.js"); +jest.mock("../src/models/Shift.js"); + +process.env.JWT_SECRET = "test-secret"; + +const makeToken = (role, id = new mongoose.Types.ObjectId().toString()) => + jwt.sign({ id, role }, process.env.JWT_SECRET); + +const chainSort = (value) => ({ + sort: jest.fn().mockResolvedValue(value), +}); + +const chainSelectLean = (value) => ({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(value), + }), +}); + +const chainEmergencyList = (value) => ({ + populate: jest.fn().mockReturnThis(), + sort: jest.fn().mockResolvedValue(value), +}); + +const createApp = () => { + const app = express(); + app.use(express.json()); + app.use("/api/v1/emergency", emergencyRoutes); + app.use("/api/v1/sos", sosRoutes); + return app; +}; + +const auth = (role, id) => ({ Authorization: `Bearer ${makeToken(role, id)}` }); + +const buildSOS = (overrides = {}) => ({ + _id: new mongoose.Types.ObjectId(), + guardId: new mongoose.Types.ObjectId(), + shiftId: null, + latitude: -34.9285, + longitude: 138.6007, + message: "", + note: "", + status: "ACTIVE", + statusHistory: [{ status: "ACTIVE", message: "SOS triggered", at: new Date() }], + locationUpdates: [], + createdAt: new Date(), + updatedAt: new Date(), + cancelledAt: null, + resolvedAt: null, + save: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +describe("Emergency SOS routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("guard creates SOS through Guard App alias", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const createdSOS = buildSOS({ guardId, note: "Gate alarm" }); + + Emergency.findOne + .mockReturnValueOnce(chainSort(null)) + .mockReturnValueOnce(chainSort(null)); + Emergency.create.mockResolvedValue(createdSOS); + + const res = await request(createApp()) + .post("/api/v1/sos/trigger") + .set(auth("guard", guardId)) + .send({ + latitude: -34.9285, + longitude: 138.6007, + note: "Gate alarm", + }); + + expect(res.statusCode).toBe(201); + expect(Emergency.create).toHaveBeenCalledTimes(1); + expect(res.body.data).toBeDefined(); + expect(res.body.sos).toEqual( + expect.objectContaining({ + _id: String(createdSOS._id), + guardId, + status: "pending", + note: "Gate alarm", + location: expect.objectContaining({ + latitude: -34.9285, + longitude: 138.6007, + }), + }), + ); + }); + + test("legacy emergency create endpoint is preserved and writes once", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const createdSOS = buildSOS({ guardId }); + + Emergency.findOne + .mockReturnValueOnce(chainSort(null)) + .mockReturnValueOnce(chainSort(null)); + Emergency.create.mockResolvedValue(createdSOS); + + const res = await request(createApp()) + .post("/api/v1/emergency/sos") + .set(auth("guard", guardId)) + .send({ latitude: -34.9285, longitude: 138.6007 }); + + expect(res.statusCode).toBe(201); + expect(Emergency.create).toHaveBeenCalledTimes(1); + expect(res.body.sos._id).toBe(String(createdSOS._id)); + }); + + test("guard retrieves own active SOS and /active is not swallowed by /:id", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const activeSOS = buildSOS({ guardId }); + + Emergency.findOne.mockReturnValue(chainSort(activeSOS)); + + const res = await request(createApp()) + .get("/api/v1/sos/active") + .set(auth("guard", guardId)); + + expect(res.statusCode).toBe(200); + expect(Emergency.findOne).toHaveBeenCalledWith({ + status: { $in: ["ACTIVE", "ESCALATED"] }, + guardId, + }); + expect(res.body.sos._id).toBe(String(activeSOS._id)); + }); + + test("unrelated guard is denied SOS lookup by scoped query", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + + Emergency.findOne.mockResolvedValue(null); + + const res = await request(createApp()) + .get(`/api/v1/sos/${sosId}`) + .set(auth("guard", guardId)); + + expect(res.statusCode).toBe(404); + expect(Emergency.findOne).toHaveBeenCalledWith({ _id: sosId, guardId }); + }); + + test("employer sees SOS for owned shift", async () => { + const employerId = new mongoose.Types.ObjectId().toString(); + const shiftId = new mongoose.Types.ObjectId(); + const visibleSOS = buildSOS({ shiftId }); + + Shift.find.mockReturnValue(chainSelectLean([{ _id: shiftId }])); + Emergency.find.mockReturnValue(chainEmergencyList([visibleSOS])); + + const res = await request(createApp()) + .get("/api/v1/emergency/sos") + .set(auth("employer", employerId)); + + expect(res.statusCode).toBe(200); + expect(Shift.find).toHaveBeenCalledWith({ createdBy: employerId }); + expect(Emergency.find).toHaveBeenCalledWith({ shiftId: { $in: [shiftId] } }); + expect(res.body.count).toBe(1); + }); + + test("unrelated employer is denied SOS lookup", async () => { + const employerId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + + Shift.find.mockReturnValue(chainSelectLean([])); + Emergency.findOne.mockResolvedValue(null); + + const res = await request(createApp()) + .get(`/api/v1/sos/${sosId}`) + .set(auth("employer", employerId)); + + expect(res.statusCode).toBe(404); + expect(Emergency.findOne).toHaveBeenCalledWith({ _id: sosId, shiftId: { $in: [] } }); + }); + + test("admin can list SOS records without ownership scope", async () => { + const sos = buildSOS(); + + Emergency.find.mockReturnValue(chainEmergencyList([sos])); + + const res = await request(createApp()) + .get("/api/v1/emergency/sos") + .set(auth("admin")); + + expect(res.statusCode).toBe(200); + expect(Emergency.find).toHaveBeenCalledWith({}); + expect(Shift.find).not.toHaveBeenCalled(); + }); + + test("guard updates valid location through emergency alias", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + const sos = buildSOS({ _id: sosId, guardId }); + + Emergency.findOne.mockResolvedValue(sos); + + const res = await request(createApp()) + .post(`/api/v1/emergency/sos/${sosId}/location`) + .set(auth("guard", guardId)) + .send({ latitude: -35, longitude: 138.5 }); + + expect(res.statusCode).toBe(200); + expect(Emergency.findOne).toHaveBeenCalledWith({ _id: sosId, guardId }); + expect(sos.latitude).toBe(-35); + expect(sos.longitude).toBe(138.5); + expect(sos.locationUpdates).toHaveLength(1); + expect(sos.save).toHaveBeenCalled(); + }); + + test("invalid coordinates return 400", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + + const res = await request(createApp()) + .post(`/api/v1/sos/${sosId}/location`) + .set(auth("guard", guardId)) + .send({ latitude: -91, longitude: 138.5 }); + + expect(res.statusCode).toBe(400); + expect(Emergency.findOne).not.toHaveBeenCalled(); + }); + + test("guard adds valid note", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + const sos = buildSOS({ _id: sosId, guardId }); + + Emergency.findOne.mockResolvedValue(sos); + + const res = await request(createApp()) + .post(`/api/v1/sos/${sosId}/note`) + .set(auth("guard", guardId)) + .send({ note: "Moved to the east entrance" }); + + expect(res.statusCode).toBe(200); + expect(sos.note).toBe("Moved to the east entrance"); + expect(sos.save).toHaveBeenCalled(); + }); + + test("invalid note length returns 400", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + + const res = await request(createApp()) + .post(`/api/v1/sos/${sosId}/note`) + .set(auth("guard", guardId)) + .send({ note: "x".repeat(2001) }); + + expect(res.statusCode).toBe(400); + expect(Emergency.findOne).not.toHaveBeenCalled(); + }); + + test("guard cancels active SOS", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + const sos = buildSOS({ _id: sosId, guardId }); + + Emergency.findOne.mockResolvedValue(sos); + + const res = await request(createApp()) + .post(`/api/v1/sos/${sosId}/cancel`) + .set(auth("guard", guardId)) + .send({}); + + expect(res.statusCode).toBe(200); + expect(sos.status).toBe("CANCELLED"); + expect(sos.cancelledAt).toBeInstanceOf(Date); + expect(sos.save).toHaveBeenCalled(); + expect(res.body.sos.status).toBe("cancelled"); + }); + + test("invalid terminal transition returns controlled error", async () => { + const adminId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + const sos = buildSOS({ + _id: sosId, + status: "RESOLVED", + resolvedAt: new Date(), + }); + + Emergency.findOne.mockResolvedValue(sos); + + const res = await request(createApp()) + .put(`/api/v1/emergency/sos/${sosId}`) + .set(auth("admin", adminId)) + .send({ status: "ACTIVE" }); + + expect(res.statusCode).toBe(409); + expect(sos.save).not.toHaveBeenCalled(); + }); + + test("malformed ObjectId returns 404 instead of 500", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + + const res = await request(createApp()) + .get("/api/v1/sos/not-an-object-id") + .set(auth("guard", guardId)); + + expect(res.statusCode).toBe(404); + expect(Emergency.findOne).not.toHaveBeenCalled(); + }); + + test("duplicate active SOS returns 409 and does not write", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const activeSOS = buildSOS({ guardId }); + + Emergency.findOne.mockReturnValueOnce(chainSort(activeSOS)); + + const res = await request(createApp()) + .post("/api/v1/sos/trigger") + .set(auth("guard", guardId)) + .send({ latitude: -34.9285, longitude: 138.6007 }); + + expect(res.statusCode).toBe(409); + expect(Emergency.create).not.toHaveBeenCalled(); + expect(res.body.sos._id).toBe(String(activeSOS._id)); + }); + + test("recent terminal SOS cooldown returns 429 and does not write", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const recentSOS = buildSOS({ + guardId, + status: "CANCELLED", + createdAt: new Date(), + }); + + Emergency.findOne + .mockReturnValueOnce(chainSort(null)) + .mockReturnValueOnce(chainSort(recentSOS)); + + const res = await request(createApp()) + .post("/api/v1/sos/trigger") + .set(auth("guard", guardId)) + .send({ latitude: -34.9285, longitude: 138.6007 }); + + expect(res.statusCode).toBe(429); + expect(Emergency.create).not.toHaveBeenCalled(); + }); + + test("alias status lookup returns Guard App response shape", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + const sos = buildSOS({ _id: sosId, guardId }); + + Emergency.findOne.mockResolvedValue(sos); + + const res = await request(createApp()) + .get(`/api/v1/sos/${sosId}`) + .set(auth("guard", guardId)); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + data: expect.any(Object), + sos: expect.objectContaining({ + _id: sosId, + status: "pending", + emergencyContact: { name: "Emergency Services", phone: "000" }, + }), + }), + ); + }); + + test("RBAC blocks guard from admin status endpoint", async () => { + const guardId = new mongoose.Types.ObjectId().toString(); + const sosId = new mongoose.Types.ObjectId().toString(); + + const res = await request(createApp()) + .put(`/api/v1/emergency/sos/${sosId}`) + .set(auth("guard", guardId)) + .send({ status: "RESOLVED" }); + + expect(res.statusCode).toBe(403); + expect(Emergency.findOne).not.toHaveBeenCalled(); + }); +});