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
61 changes: 44 additions & 17 deletions app-backend/src/config/multer.js
Original file line number Diff line number Diff line change
@@ -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
});
82 changes: 62 additions & 20 deletions app-backend/src/controllers/incident.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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)) {
Expand All @@ -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];
Expand Down Expand Up @@ -109,15 +150,13 @@ 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)
) {
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)) {
Expand All @@ -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 } =
Expand All @@ -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) };
}

Expand Down Expand Up @@ -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);

Expand All @@ -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));
}
Expand All @@ -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();
Expand All @@ -244,7 +287,6 @@ export const uploadAttachment = async (req, res, next) => {
}
};


// GET ATTACHMENT
export const getAttachment = async (req, res, next) => {
try {
Expand Down
51 changes: 49 additions & 2 deletions app-backend/src/models/Incident.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,13 +39,15 @@ const attachmentSchema = new mongoose.Schema(
{ _id: true }
);

// ---------------- Incident Schema ----------------
const incidentSchema = new mongoose.Schema(
{
shiftId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Shift",
required: true,
},

guardId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
Expand All @@ -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",
Expand Down
Loading
Loading