diff --git a/app-backend/src/config/multer.js b/app-backend/src/config/multer.js index dfc13656c..d7ef4f35a 100644 --- a/app-backend/src/config/multer.js +++ b/app-backend/src/config/multer.js @@ -1,34 +1,61 @@ // config/multer.js -import multer from 'multer'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import multer from "multer"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ensure uploads dir exists -const uploadsDir = path.join(__dirname, '..', 'uploads'); +const uploadsDir = path.join(__dirname, "..", "uploads"); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); // where & how to store files const storage = multer.diskStorage({ - destination: (_req, _file, cb) => cb(null, uploadsDir), - filename: (_req, file, cb) => { - const safeOriginal = file.originalname.replace(/[^\w.-]/g, '_'); - const ts = Date.now(); - cb(null, `${ts}-${safeOriginal}`); - }, + destination: (_req, _file, cb) => cb(null, uploadsDir), + filename: (_req, file, cb) => { + const safeOriginal = file.originalname.replace(/[^\w.-]/g, "_"); + const ts = Date.now(); + cb(null, `${ts}-${safeOriginal}`); + }, }); -// accept images only + 5MB limit +// supported files: images, PDFs, videos, and audio +const allowedMimeTypes = [ + // images + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + + // pdf + "application/pdf", + + // videos + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/webm", + + // audio + "audio/mpeg", + "audio/wav", + "audio/webm", + "audio/mp4", +]; + const fileFilter = (_req, file, cb) => { - const ok = ['image/jpeg', 'image/png', 'image/webp', 'image/heic','application/pdf' ].includes(file.mimetype); - cb(ok ? null : new Error('Only image files are allowed'), ok); + const ok = allowedMimeTypes.includes(file.mimetype); + + cb( + ok ? null : new Error("Only images, videos, audio, and PDF files are allowed"), + ok + ); }; export const upload = multer({ - storage, - fileFilter, - limits: { fileSize: 5 * 1024 * 1024 }, + storage, + fileFilter, + limits: { fileSize: 25 * 1024 * 1024 }, // 25MB }); \ No newline at end of file diff --git a/app-backend/src/controllers/incident.controller.js b/app-backend/src/controllers/incident.controller.js index b82614f27..a320c5ab5 100644 --- a/app-backend/src/controllers/incident.controller.js +++ b/app-backend/src/controllers/incident.controller.js @@ -8,13 +8,31 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const validStatuses = ["SUBMITTED", "IN_REVIEW", "RESOLVED"]; + +const allowedTransitions = { + SUBMITTED: ["IN_REVIEW"], + IN_REVIEW: ["RESOLVED"], + RESOLVED: [], +}; + +const getMediaType = (mimeType) => { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + if (mimeType === "application/pdf") return "pdf"; + return "other"; +}; + // CREATE INCIDENT export const createIncident = async (req, res, next) => { try { - const { shiftId, severity, description } = req.body; + const { shiftId, severity, description, latitude, longitude } = req.body; if (!shiftId || !severity || !description) { - return next(new ErrorResponse("All fields are required", 400)); + return next( + new ErrorResponse("shiftId, severity, and description are required", 400) + ); } const shift = await Shift.findById(shiftId); @@ -31,10 +49,18 @@ export const createIncident = async (req, res, next) => { guardId: req.user._id, severity, description, + status: "SUBMITTED", + recordedAt: new Date(), + location: { + latitude: latitude !== undefined ? Number(latitude) : undefined, + longitude: longitude !== undefined ? Number(longitude) : undefined, + }, }); await req.audit.log(req.user._id, ACTIONS.INCIDENT_CREATED, { incidentId: incident._id, + recordedAt: incident.recordedAt, + location: incident.location, }); res.status(201).json({ success: true, data: incident }); @@ -55,15 +81,12 @@ export const updateIncident = async (req, res, next) => { let allowedFields = []; if (req.user.role === "guard") { - // guards can only update their own incidents if (String(incident.guardId) !== String(req.user._id)) { return next(new ErrorResponse("Not authorized", 403)); } - // guards should only update limited fields allowedFields = ["description"]; } else if (req.user.role === "employer") { - // employers can only update incidents belonging to their own shifts const shift = await Shift.findById(incident.shiftId); if (!shift || String(shift.createdBy) !== String(req.user._id)) { @@ -77,6 +100,24 @@ export const updateIncident = async (req, res, next) => { return next(new ErrorResponse("Not authorized", 403)); } + if (req.body.status) { + if (!validStatuses.includes(req.body.status)) { + return next(new ErrorResponse("Invalid status value", 400)); + } + + const currentStatus = incident.status; + const nextStatus = req.body.status; + + if (!allowedTransitions[currentStatus]?.includes(nextStatus)) { + return next( + new ErrorResponse( + `Invalid status transition from ${currentStatus} to ${nextStatus}`, + 400 + ) + ); + } + } + allowedFields.forEach((field) => { if (req.body[field] !== undefined) { incident[field] = req.body[field]; @@ -109,7 +150,6 @@ export const getIncident = async (req, res, next) => { return next(new ErrorResponse("Incident not found", 404)); } - // Guard access if ( req.user.role === "guard" && String(incident.guardId._id) !== String(req.user._id) @@ -117,7 +157,6 @@ export const getIncident = async (req, res, next) => { return next(new ErrorResponse("Not authorized", 403)); } - // Employer access if (req.user.role === "employer") { const shift = await Shift.findById(incident.shiftId._id); if (String(shift.createdBy) !== String(req.user._id)) { @@ -131,7 +170,7 @@ export const getIncident = async (req, res, next) => { } }; -// LIST INCIDENTS (WITH FILTERS) +// LIST INCIDENTS export const getIncidents = async (req, res, next) => { try { const { shiftId, guardId, severity, status, startDate, endDate } = @@ -142,23 +181,26 @@ export const getIncidents = async (req, res, next) => { if (shiftId) query.shiftId = shiftId; if (guardId) query.guardId = guardId; if (severity) query.severity = severity; - if (status) query.status = status; + + if (status) { + if (!validStatuses.includes(status)) { + return next(new ErrorResponse("Invalid status filter", 400)); + } + query.status = status; + } if (startDate || endDate) { - query.createdAt = {}; - if (startDate) query.createdAt.$gte = new Date(startDate); - if (endDate) query.createdAt.$lte = new Date(endDate); + query.recordedAt = {}; + if (startDate) query.recordedAt.$gte = new Date(startDate); + if (endDate) query.recordedAt.$lte = new Date(endDate); } - // RBAC filtering if (req.user.role === "guard") { query.guardId = req.user._id; } if (req.user.role === "employer") { - const shifts = await Shift.find({ createdBy: req.user._id }).select( - "_id" - ); + const shifts = await Shift.find({ createdBy: req.user._id }).select("_id"); query.shiftId = { $in: shifts.map((s) => s._id) }; } @@ -206,14 +248,12 @@ export const uploadAttachment = async (req, res, next) => { return next(new ErrorResponse("Incident not found", 404)); } - // guard can upload only to their own incident if (req.user.role === "guard") { if (String(incident.guardId) !== String(req.user._id)) { return next(new ErrorResponse("Not authorized", 403)); } } - // employer can upload only to incidents on their own shifts if (req.user.role === "employer") { const shift = await Shift.findById(incident.shiftId); @@ -222,7 +262,6 @@ export const uploadAttachment = async (req, res, next) => { } } - // any non-admin role outside the above is not allowed if (!["guard", "employer", "admin"].includes(req.user.role)) { return next(new ErrorResponse("Not authorized", 403)); } @@ -233,7 +272,11 @@ export const uploadAttachment = async (req, res, next) => { incident.attachments.push({ fileName: req.file.filename, + originalName: req.file.originalname, fileUrl: `/uploads/${req.file.filename}`, + mimeType: req.file.mimetype, + fileSize: req.file.size, + mediaType: getMediaType(req.file.mimetype), }); await incident.save(); @@ -244,7 +287,6 @@ export const uploadAttachment = async (req, res, next) => { } }; - // GET ATTACHMENT export const getAttachment = async (req, res, next) => { try { diff --git a/app-backend/src/models/Incident.js b/app-backend/src/models/Incident.js index 223dc12a7..ab3ed9a73 100644 --- a/app-backend/src/models/Incident.js +++ b/app-backend/src/models/Incident.js @@ -1,15 +1,36 @@ import mongoose from "mongoose"; +// ---------------- Attachment Schema ---------------- const attachmentSchema = new mongoose.Schema( { fileName: { type: String, required: true, }, + + originalName: { + type: String, + }, + fileUrl: { type: String, required: true, }, + + mimeType: { + type: String, + }, + + fileSize: { + type: Number, + }, + + mediaType: { + type: String, + enum: ["image", "video", "audio", "pdf", "other"], + default: "other", + }, + uploadedAt: { type: Date, default: Date.now, @@ -18,6 +39,7 @@ const attachmentSchema = new mongoose.Schema( { _id: true } ); +// ---------------- Incident Schema ---------------- const incidentSchema = new mongoose.Schema( { shiftId: { @@ -25,6 +47,7 @@ const incidentSchema = new mongoose.Schema( ref: "Shift", required: true, }, + guardId: { type: mongoose.Schema.Types.ObjectId, ref: "User", @@ -43,19 +66,43 @@ const incidentSchema = new mongoose.Schema( trim: true, }, + // NEW structured lifecycle status: { type: String, - enum: ["open", "in-progress", "resolved"], - default: "open", + enum: ["SUBMITTED", "IN_REVIEW", "RESOLVED"], + default: "SUBMITTED", + }, + + // NEW GPS location + location: { + latitude: { + type: Number, + min: -90, + max: 90, + }, + longitude: { + type: Number, + min: -180, + max: 180, + }, + }, + + // NEW standard timestamp + recordedAt: { + type: Date, + default: Date.now, }, attachments: [attachmentSchema], + // ---------------- Soft Delete ---------------- isDeleted: { type: Boolean, default: false, }, + deletedAt: Date, + deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", diff --git a/app-backend/src/routes/incident.routes.js b/app-backend/src/routes/incident.routes.js index 3ac5d7bc2..b69acf088 100644 --- a/app-backend/src/routes/incident.routes.js +++ b/app-backend/src/routes/incident.routes.js @@ -10,7 +10,6 @@ import { } from "../controllers/incident.controller.js"; import auth from "../middleware/auth.js"; - import { authorizePermissions } from "../middleware/rbac.js"; import { upload } from "../config/multer.js"; @@ -23,7 +22,6 @@ const router = express.Router(); * description: Incident reporting and management */ - router.use(auth); /** @@ -34,6 +32,7 @@ router.use(auth); * description: | * Create a new incident report for a shift. * Typically used by guards for shifts assigned to them. + * Supports optional GPS metadata through latitude and longitude. * tags: [Incidents] * security: * - bearerAuth: [] @@ -50,14 +49,20 @@ router.use(auth); * properties: * shiftId: * type: string - * example: "65f1c6a3b5e18f9b9a3d52f77" + * example: "69f97b98bcb1382842655170" * severity: * type: string * enum: [low, medium, high] - * example: "high" + * example: high * description: * type: string - * example: "Unauthorized entry detected near the loading dock." + * example: "Unauthorized access detected near the loading dock." + * latitude: + * type: number + * example: -37.8136 + * longitude: + * type: number + * example: 144.9631 * responses: * 201: * description: Incident created successfully @@ -66,22 +71,15 @@ router.use(auth); * 401: * description: Unauthorized * 403: - * description: Forbidden + * description: Forbidden or guard not assigned to shift * 404: * description: Shift not found * * get: * summary: List incidents * description: | - * Retrieve incidents with optional filters: - * - shiftId - * - guardId - * - severity - * - status - * - startDate - * - endDate - * - * Returned data is still subject to role- and ownership-based filtering in the controller. + * Retrieve incidents with optional filters for shift, guard, severity, + * structured status lifecycle, and recorded date range. * tags: [Incidents] * security: * - bearerAuth: [] @@ -90,52 +88,47 @@ router.use(auth); * name: shiftId * schema: * type: string - * required: false * description: Filter by shift ID * - in: query * name: guardId * schema: * type: string - * required: false * description: Filter by guard ID * - in: query * name: severity * schema: * type: string * enum: [low, medium, high] - * required: false * description: Filter by severity * - in: query * name: status * schema: * type: string - * enum: [open, in-progress, resolved] - * required: false - * description: Filter by incident status + * enum: [SUBMITTED, IN_REVIEW, RESOLVED] + * description: Filter by incident lifecycle status * - in: query * name: startDate * schema: * type: string * format: date - * required: false - * description: Return incidents created on or after this date + * description: Return incidents recorded on or after this date * - in: query * name: endDate * schema: * type: string * format: date - * required: false - * description: Return incidents created on or before this date + * description: Return incidents recorded on or before this date * responses: * 200: * description: Incidents retrieved successfully + * 400: + * description: Invalid filter value * 401: * description: Unauthorized * 403: * description: Forbidden */ -// Create router.post("/", authorizePermissions("incident:create"), createIncident); router.get("/", authorizePermissions("incident:view"), getIncidents); @@ -145,13 +138,8 @@ router.get("/", authorizePermissions("incident:view"), getIncidents); * get: * summary: Retrieve a single incident * description: | - * Return full incident details including reporter, shift, severity, - * status, timestamps, and attachments. - * - * **Access rules:** - * - **Guard**: can view only their own incident - * - **Employer**: can view only incidents linked to shifts they created - * - **Admin**: can view any incident allowed by permissions + * Return full incident details including shift, reporter, severity, + * status, recorded timestamp, GPS location, and attachments. * tags: [Incidents] * security: * - bearerAuth: [] @@ -175,14 +163,10 @@ router.get("/", authorizePermissions("incident:view"), getIncidents); * patch: * summary: Update an incident * description: | - * Update an existing incident with role- and ownership-based restrictions. - * - * **Update rules:** - * - **Guard**: can update only their own incident, and only the `description` field - * - **Employer**: can update only incidents linked to shifts they created, and only the `description` and `status` fields - * - **Admin**: can update `severity`, `description`, and `status` + * Update incident details with role-based restrictions. * - * Any unauthorized field changes are ignored by the controller. + * Status follows the structured lifecycle: + * SUBMITTED → IN_REVIEW → RESOLVED. * tags: [Incidents] * security: * - bearerAuth: [] @@ -207,16 +191,17 @@ router.get("/", authorizePermissions("incident:view"), getIncidents); * description: Admin only * description: * type: string - * example: Updated after site supervisor review. - * description: Allowed for guard, employer, and admin (subject to ownership/scope) + * example: "Updated incident details after review." * status: * type: string - * enum: [open, in-progress, resolved] - * example: in-progress + * enum: [SUBMITTED, IN_REVIEW, RESOLVED] + * example: IN_REVIEW * description: Employer and admin only * responses: * 200: * description: Incident updated successfully + * 400: + * description: Invalid status value or invalid lifecycle transition * 401: * description: Unauthorized * 403: @@ -227,7 +212,8 @@ router.get("/", authorizePermissions("incident:view"), getIncidents); * delete: * summary: Soft-delete an incident * description: | - * Admin-only endpoint that soft-deletes an incident while preserving audit logs. + * Soft-delete an incident without permanently removing it from the database. + * This preserves audit history. * tags: [Incidents] * security: * - bearerAuth: [] @@ -259,14 +245,9 @@ router.delete("/:id", authorizePermissions("incident:delete"), deleteIncident); * post: * summary: Upload an incident attachment * description: | - * Upload a supported file (such as an image or PDF) and attach it to an existing incident. - * - * **Access rules:** - * - **Guard**: can upload only to their own incident - * - **Employer**: can upload only to incidents linked to shifts they created - * - **Admin**: can upload to any incident + * Upload a supported file and attach it to an existing incident. * - * In addition to route permission checks, the controller validates incident ownership/scope before saving the attachment. + * Supported files: images, videos, audio, and PDFs. * tags: [Incidents] * security: * - bearerAuth: [] @@ -289,12 +270,12 @@ router.delete("/:id", authorizePermissions("incident:delete"), deleteIncident); * file: * type: string * format: binary - * description: Supported image or PDF file + * description: Images, videos, audio, and PDF files are supported. * responses: * 200: * description: Attachment uploaded successfully * 400: - * description: No file uploaded + * description: No file uploaded or invalid file type * 401: * description: Unauthorized * 403: @@ -335,8 +316,6 @@ router.post( * responses: * 200: * description: Attachment returned successfully - * 400: - * description: No file uploaded or invalid file type * 401: * description: Unauthorized * 403: @@ -350,4 +329,5 @@ router.get( authorizePermissions("incident:view"), getAttachment ); + export default router; \ No newline at end of file