-
Notifications
You must be signed in to change notification settings - Fork 8
Implemented Incident Management API with RBAC and Attachment Support #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5a50c7e
87948c6
ba3c4c6
8244553
3425c40
13eb514
3804ba7
ecf0ea5
6c6adb4
37cdc80
7a6479a
fee7898
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| import Incident from "../models/Incident.js"; | ||
| import Shift from "../models/Shift.js"; | ||
| import { ErrorResponse } from "../utils/errorResponse.js"; | ||
| import { ACTIONS } from "../middleware/logger.js"; | ||
| import path from "path"; | ||
| import { fileURLToPath } from "url"; | ||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = path.dirname(__filename); | ||
|
|
||
| // CREATE INCIDENT | ||
| export const createIncident = async (req, res, next) => { | ||
| try { | ||
| const { shiftId, severity, description } = req.body; | ||
|
|
||
| if (!shiftId || !severity || !description) { | ||
| return next(new ErrorResponse("All fields are required", 400)); | ||
| } | ||
|
|
||
| const shift = await Shift.findById(shiftId); | ||
| if (!shift) { | ||
| return next(new ErrorResponse("Shift not found", 404)); | ||
| } | ||
|
|
||
| if (String(shift.acceptedBy) !== String(req.user._id)) { | ||
| return next(new ErrorResponse("Not assigned to this shift", 403)); | ||
| } | ||
|
|
||
| const incident = await Incident.create({ | ||
| shiftId, | ||
| guardId: req.user._id, | ||
| severity, | ||
| description, | ||
| }); | ||
|
|
||
| await req.audit.log(req.user._id, ACTIONS.INCIDENT_CREATED, { | ||
| incidentId: incident._id, | ||
| }); | ||
|
|
||
| res.status(201).json({ success: true, data: incident }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
| // UPDATE INCIDENT | ||
| export const updateIncident = async (req, res, next) => { | ||
| try { | ||
| const incident = await Incident.findById(req.params.id); | ||
|
|
||
| if (!incident || incident.isDeleted) { | ||
| return next(new ErrorResponse("Incident not found", 404)); | ||
| } | ||
|
|
||
| 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)) { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
|
|
||
| allowedFields = ["description", "status"]; | ||
| } else if (req.user.role === "admin") { | ||
| allowedFields = ["severity", "description", "status"]; | ||
| } else { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
|
|
||
| allowedFields.forEach((field) => { | ||
| if (req.body[field] !== undefined) { | ||
| incident[field] = req.body[field]; | ||
| } | ||
| }); | ||
|
|
||
| await incident.save(); | ||
|
|
||
| await req.audit.log(req.user._id, ACTIONS.INCIDENT_UPDATED, { | ||
| incidentId: incident._id, | ||
| updatedFields: Object.keys(req.body).filter((field) => | ||
| allowedFields.includes(field) | ||
| ), | ||
| }); | ||
|
|
||
| res.json({ success: true, data: incident }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
| // GET SINGLE INCIDENT | ||
| export const getIncident = async (req, res, next) => { | ||
| try { | ||
| const incident = await Incident.findById(req.params.id) | ||
| .populate("shiftId") | ||
| .populate("guardId"); | ||
|
|
||
| if (!incident || incident.isDeleted) { | ||
| 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)) { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
| } | ||
|
|
||
| res.json({ success: true, data: incident }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
| // LIST INCIDENTS (WITH FILTERS) | ||
| export const getIncidents = async (req, res, next) => { | ||
| try { | ||
| const { shiftId, guardId, severity, status, startDate, endDate } = | ||
| req.query; | ||
|
|
||
| let query = { isDeleted: false }; | ||
|
|
||
| if (shiftId) query.shiftId = shiftId; | ||
| if (guardId) query.guardId = guardId; | ||
| if (severity) query.severity = severity; | ||
| if (status) query.status = status; | ||
|
|
||
| if (startDate || endDate) { | ||
| query.createdAt = {}; | ||
| if (startDate) query.createdAt.$gte = new Date(startDate); | ||
| if (endDate) query.createdAt.$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" | ||
| ); | ||
| query.shiftId = { $in: shifts.map((s) => s._id) }; | ||
| } | ||
|
|
||
| const incidents = await Incident.find(query) | ||
| .populate("shiftId") | ||
| .populate("guardId"); | ||
|
|
||
| res.json({ success: true, count: incidents.length, data: incidents }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
| // SOFT DELETE | ||
| export const deleteIncident = async (req, res, next) => { | ||
| try { | ||
| const incident = await Incident.findById(req.params.id); | ||
|
|
||
| if (!incident || incident.isDeleted) { | ||
| return next(new ErrorResponse("Incident not found", 404)); | ||
| } | ||
|
|
||
| incident.isDeleted = true; | ||
| incident.deletedAt = new Date(); | ||
| incident.deletedBy = req.user._id; | ||
|
|
||
| await incident.save(); | ||
|
|
||
| await req.audit.log(req.user._id, ACTIONS.INCIDENT_DELETED, { | ||
| incidentId: incident._id, | ||
| }); | ||
|
|
||
| res.json({ success: true, message: "Incident deleted" }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
| // UPLOAD ATTACHMENT | ||
| export const uploadAttachment = async (req, res, next) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add the same ownership / employer-scope checks here as well? At the moment this checks that the incident exists, but I’m not seeing a guard ownership check or an employer “owns the related shift” check before the attachment is added. Since the route only requires |
||
| try { | ||
| const incident = await Incident.findById(req.params.id); | ||
|
|
||
| if (!incident || incident.isDeleted) { | ||
| 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); | ||
|
|
||
| if (!shift || String(shift.createdBy) !== String(req.user._id)) { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
| } | ||
|
|
||
| // 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)); | ||
| } | ||
|
|
||
| if (!req.file) { | ||
| return next(new ErrorResponse("No file uploaded", 400)); | ||
| } | ||
|
|
||
| incident.attachments.push({ | ||
| fileName: req.file.filename, | ||
| fileUrl: `/uploads/${req.file.filename}`, | ||
| }); | ||
|
|
||
| await incident.save(); | ||
|
|
||
| res.json({ success: true, data: incident }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| // GET ATTACHMENT | ||
| export const getAttachment = async (req, res, next) => { | ||
| try { | ||
| const incident = await Incident.findById(req.params.id); | ||
|
|
||
| if (!incident || incident.isDeleted) { | ||
| return next(new ErrorResponse("Incident not found", 404)); | ||
| } | ||
|
|
||
| if ( | ||
| req.user.role === "guard" && | ||
| String(incident.guardId) !== String(req.user._id) | ||
| ) { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
|
|
||
| if (req.user.role === "employer") { | ||
| const shift = await Shift.findById(incident.shiftId); | ||
| if (!shift || String(shift.createdBy) !== String(req.user._id)) { | ||
| return next(new ErrorResponse("Not authorized", 403)); | ||
| } | ||
| } | ||
|
|
||
| const attachment = incident.attachments.id(req.params.attachmentId); | ||
|
|
||
| if (!attachment) { | ||
| return next(new ErrorResponse("Attachment not found", 404)); | ||
| } | ||
|
|
||
| const filePath = path.join(__dirname, "..", "uploads", attachment.fileName); | ||
| res.download(filePath); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs tighter field-level and role-level checks.
Right now guards appear able to update
severityandstatuson their own incidents because both fields are included inallowedFields, and employers do not seem to be scoped here to incidents belonging to shifts they created.Could we restrict: