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
69 changes: 69 additions & 0 deletions app-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 86 additions & 71 deletions app-backend/src/controllers/emergency.controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
90 changes: 88 additions & 2 deletions app-backend/src/models/Emergency.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,117 @@
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: {
type: mongoose.Schema.Types.ObjectId,
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);
emergencySchema.index({ guardId: 1, status: 1, createdAt: -1 });

export default mongoose.model("Emergency", emergencySchema);
Loading
Loading