diff --git a/apps/edr-freight-api/data/audit-endpoints.js b/apps/edr-freight-api/data/audit-endpoints.js new file mode 100644 index 000000000..2c11ea95b --- /dev/null +++ b/apps/edr-freight-api/data/audit-endpoints.js @@ -0,0 +1,639 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 488 endpoints. + */ +const AUDIT_ENDPOINTS = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"], + "DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"], + "POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"], + "POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"], + "DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + // NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; + +module.exports = AUDIT_ENDPOINTS; diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d1b5364c3..36ed7ae74 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -90,6 +90,10 @@ export const TrainSchedulingReschedule = () => export const TrainSchedulingRulesManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage); +/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */ +export const TrainSchedulingEditTrainNumber = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber); + /** * Fleet guards take an optional granular per-resource key (locomotives:create, * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain diff --git a/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts new file mode 100644 index 000000000..b5b865ad7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Backoffice audit trail for every state-changing freight endpoint. + * + * No `updated_at` / `deleted_at` columns, unlike every other table here: audit + * rows are insert-only evidence. A soft-delete column would let an actor erase + * their own trail and TypeORM would then hide those rows from default queries + * silently — see the entity comment. + * + * `user_id` intentionally carries NO foreign key to the `iam` schema. + * Cross-schema FKs are forbidden platform-wide, and one here would let user + * deletion cascade away the record of what that user did. + * + * DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race + * `migrationsRun` against each other on the shared dev database. + */ +export class CreateAuditLogs3390000000000 implements MigrationInterface { + name = "CreateAuditLogs3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.audit_logs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + title varchar(255) NOT NULL, + method varchar(10) NOT NULL, + url text NOT NULL, + route_path varchar(255), + type varchar(50) NOT NULL, + is_success boolean NOT NULL, + user_id uuid, + resource_id varchar(64), + request jsonb, + status_code smallint, + error_message text, + user_name varchar(150), + user_role varchar(100), + ip_address inet, + user_agent text, + request_id varchar(64), + duration_ms integer, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY (id) + ) + `); + + // Every audit query is time-bounded, so created_at leads each index. + // DESC matches the "newest first" read path the controller exposes. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at" + ON freight.audit_logs (created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at" + ON freight.audit_logs (user_id, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at" + ON freight.audit_logs (type, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id" + ON freight.audit_logs (type, resource_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at" + ON freight.audit_logs (route_path, created_at DESC) + `); + // Failures are a small slice of the table but carry the security signal + // (403s especially), so they get their own partial index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures" + ON freight.audit_logs (created_at DESC) + WHERE is_success = false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts new file mode 100644 index 000000000..b86327147 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagons the requester specifically asked for. A transfer request stays + * count-driven (`quantity` is what must be delivered), but a requester who + * picked wagons off the yard desk now records WHICH ones — OCC sees the numbers + * on the queue and the fulfil picker pre-selects them. + * + * Stored as a uuid[] column rather than a join table: the list is read and + * written whole, never queried by wagon, and a preference carries no lifecycle + * of its own (no FK — a purged wagon simply drops out of the display). + */ +export class TransferRequestPreferredWagons3400000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[] + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS preferred_wagon_ids + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts new file mode 100644 index 000000000..dd62276d8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Voyage number for one departure. + * + * `train_number` already exists on a schedule (the run number), but operations + * also quote a VOYAGE number — the sailing/run identifier yards and customs use + * for a specific departure. It belongs on the schedule, not the built train: one + * train serves many departures and each carries its own voyage. + * + * Nullable and un-indexed: it is display/reference data typed by staff, not a + * lookup key, and older schedules simply have none. + */ +export class ScheduleVoyageNumber3410000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS voyage_number varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS voyage_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-actor.ts b/apps/edr-freight-api/src/modules/audit/audit-actor.ts new file mode 100644 index 000000000..f89a7ee26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-actor.ts @@ -0,0 +1,84 @@ +/** + * Who acted, and does this API audit them? + * + * The staff/customer split reuses the exact discriminator the permission guards + * already apply (`freight-permission.guard.ts`): `userType === 'employee'` is + * backoffice, `individual` / `external_organization` are customers. Restating + * the rule instead of importing it would let the two drift apart silently. + */ + +const EMPLOYEE_USER_TYPE = 'employee'; +const SUPER_ADMIN_ROLE = 'super_admin'; + +/** The subset of the JWT payload this module reads. */ +export interface AuditActorSource { + id?: string; + sub?: string; + userType?: string; + username?: string; + name?: string | { en?: string; am?: string }; + firstName?: string; + lastName?: string; + email?: string; + roles?: { key?: string; name?: string }[]; + employee?: unknown; +} + +export interface AuditActor { + userId: string | null; + userName: string | null; + userRole: string | null; +} + +function isSuperAdmin(user: AuditActorSource): boolean { + return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE)); +} + +/** + * Is this caller a backoffice user whose actions are audited? + * + * Only employees qualify. Customers are excluded by request, and unauthenticated + * callers are excluded too — which means failed logins, OTP sends and password + * resets produce no audit rows. That was a deliberate call: those endpoints are + * not backoffice actions. Note the trade-off, since failed-auth attempts are + * often what an incident review looks for first. + */ +export function isAuditableActor(user: AuditActorSource | null | undefined): boolean { + if (!user) return false; + // Super admins may not carry an `employee` userType on every token, but are + // unambiguously staff — the permission guards treat them the same way. + return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user); +} + +/** Best-effort display name, tolerating the several shapes tokens use. */ +function resolveUserName(user: AuditActorSource): string | null { + if (typeof user.name === 'string' && user.name.trim()) return user.name.trim(); + + if (user.name && typeof user.name === 'object') { + const localized = user.name.en ?? user.name.am; + if (localized?.trim()) return localized.trim(); + } + + const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (composed) return composed; + + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Snapshot the actor at the moment of the action. + * + * Name and role are copied, never referenced: resolving them from IAM at read + * time would rewrite history whenever someone is renamed, changes role or is + * deleted. An audit row from last year must still say who acted and with what + * authority *then*. + */ +export function resolveAuditActor(user: AuditActorSource): AuditActor { + const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null; + + return { + userId: user.id ?? user.sub ?? null, + userName: resolveUserName(user), + userRole: roleKey, + }; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts new file mode 100644 index 000000000..75ae51fa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts @@ -0,0 +1,140 @@ +import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints'; + +/** What a matched request resolved to. */ +export interface MatchedAuditEndpoint { + /** Human-readable action, e.g. "Approve contract". */ + title: string; + /** Primary entity, e.g. "Contract". */ + type: string; + /** The route template, e.g. `/api/contracts/:id/cancel`. */ + routePath: string; + /** First path parameter of the template, when the route has one. */ + resourceId: string | null; +} + +interface CompiledRoute { + regex: RegExp; + /** Param names in capture-group order, e.g. ['id', 'stepId']. */ + paramNames: string[]; + routePath: string; + meta: AuditEndpointMeta; + /** Literal (non-parameter) segment count — used to rank specificity. */ + staticSegments: number; +} + +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; + +/** + * Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by + * two different controllers, so the generator suffixed the second with + * ` [modules/...controller.ts]` to keep both entries. Only the path itself is + * matchable, so the suffix is stripped here. + */ +function stripSourceSuffix(key: string): string { + const bracket = key.indexOf(' ['); + return bracket === -1 ? key : key.slice(0, bracket); +} + +/** + * Compile one `"/api/contracts/:id/cancel"` template into an anchored regex. + * + * A parameter matches a single path segment only (`[^/]+`), so + * `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`. + */ +function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } { + const paramNames: string[] = []; + const pattern = path + .split('/') + .map((segment) => { + if (!segment.startsWith(':')) { + return segment.replace(ESCAPE_REGEX, '\\$&'); + } + paramNames.push(segment.slice(1)); + return '([^/]+)'; + }) + .join('/'); + + return { regex: new RegExp(`^${pattern}$`), paramNames }; +} + +/** + * Method-bucketed lookup table for the audited routes. + * + * A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates + * with `:params` while a live request carries real ids and a query string, so + * every parameterized route — most of the 488 — would miss. Templates are + * compiled to regexes once at module load and matched per request. + * + * Within a method, routes are ordered by literal-segment count descending, so + * a specific route always wins over a parameterized one that could also match + * (`/api/routes/:id/permanent` before `/api/routes/:id`). + */ +class AuditEndpointMatcher { + private readonly byMethod = new Map(); + + constructor() { + for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) { + const [method, rawPath] = stripSourceSuffix(key).split(' '); + if (!method || !rawPath) continue; + + const { regex, paramNames } = compileTemplate(rawPath); + const bucket = this.byMethod.get(method) ?? []; + bucket.push({ + regex, + paramNames, + routePath: rawPath, + meta, + staticSegments: rawPath + .split('/') + .filter((s) => s && !s.startsWith(':')).length, + }); + this.byMethod.set(method, bucket); + } + + for (const bucket of this.byMethod.values()) { + bucket.sort((a, b) => b.staticSegments - a.staticSegments); + } + } + + /** + * Resolve a live request to its audit metadata, or null when the route is + * not audited (every GET, and anything absent from AUDIT_ENDPOINTS). + * + * `url` may include a query string; it is ignored for matching. + */ + match(method: string, url: string): MatchedAuditEndpoint | null { + const bucket = this.byMethod.get(method.toUpperCase()); + if (!bucket) return null; + + const path = stripQuery(url); + + for (const route of bucket) { + const result = route.regex.exec(path); + if (!result) continue; + + const [title, , type] = route.meta; + return { + title, + type, + routePath: route.routePath, + // The first path parameter is the affected record in this API's + // conventions (`/api/contracts/:id/...`). Routes with no parameter + // (a create) legitimately have no resource id yet. + resourceId: route.paramNames.length > 0 ? result[1] : null, + }; + } + + return null; + } +} + +/** Strip query string and hash from a URL, leaving the path. */ +export function stripQuery(url: string): string { + const queryIndex = url.indexOf('?'); + const path = queryIndex === -1 ? url : url.slice(0, queryIndex); + const hashIndex = path.indexOf('#'); + return hashIndex === -1 ? path : path.slice(0, hashIndex); +} + +/** Compiled once at module load and shared by the interceptor. */ +export const auditEndpointMatcher = new AuditEndpointMatcher(); diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts new file mode 100644 index 000000000..0526636ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -0,0 +1,641 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 488 endpoints. + */ +/** [title, method, entity] for one auditable route. */ +export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; + +export const AUDIT_ENDPOINTS: Readonly> = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"], + "DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"], + "POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"], + "POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"], + "DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + // NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/train-number": ["Edit a departure's train number and voyage number — allowed only until the train is dispatched", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts new file mode 100644 index 000000000..91d6c9901 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { AuditLog } from './entities/audit-log.entity'; + +export interface AuditLogQuery { + type?: string; + userId?: string; + method?: string; + isSuccess?: boolean; + resourceId?: string; + from?: Date; + to?: Date; + skip: number; + take: number; +} + +@Injectable() +export class AuditLogRepository extends BaseRepository { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + ) { + super(auditLogRepository); + } + + /** + * Insert one audit row. + * + * `insert` rather than `save`: save would issue a SELECT first to decide + * between insert and update, which is wasted work for a table that is only + * ever appended to. + */ + async record(entry: Partial): Promise { + await this.auditLogRepository.insert( + entry as QueryDeepPartialEntity, + ); + } + + /** + * Paginated, filtered read. Newest first — every index on this table is + * ordered `created_at DESC` to match. + */ + async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { + const where: FindOptionsWhere = {}; + + if (query.type) where.type = query.type; + if (query.userId) where.userId = query.userId; + if (query.method) where.method = query.method; + if (query.resourceId) where.resourceId = query.resourceId; + if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + + // Date range: either bound may be supplied alone. + if (query.from && query.to) where.createdAt = Between(query.from, query.to); + else if (query.from) where.createdAt = MoreThanOrEqual(query.from); + else if (query.to) where.createdAt = LessThanOrEqual(query.to); + + return this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: query.skip, + take: query.take, + }); + } + + /** Distinct entity types present, for populating a filter dropdown. */ + async distinctTypes(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.type', 'type') + .orderBy('audit_log.type', 'ASC') + .getRawMany<{ type: string }>(); + + return rows.map((row) => row.type); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..1a07a5fe5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginatedResponse } from '@edr/types'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; + +/** + * Read-only view over the audit trail. + * + * Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than + * the broad `admin` key, so reading the trail can be granted without also + * granting write access to everything else. + * + * There is deliberately no write, update or delete endpoint here — rows are + * created only by `AuditInterceptor`, and an audit trail that can be edited + * through the API is not an audit trail. + */ +@ApiTags('audit') +@ApiBearerAuth() +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get('logs') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: + 'List backoffice audit logs — filter by entity type, user, method, outcome and date range', + }) + list(@Query() query: AuditLogQueryDto): Promise> { + return this.auditService.search(query); + } + + @Get('types') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct entity types present in the audit log (filter dropdown)', + }) + types(): Promise { + return this.auditService.listTypes(); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts new file mode 100644 index 000000000..cf12fd4d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -0,0 +1,189 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; +import type { Request, Response } from 'express'; + +import { AuditService } from './audit.service'; +import { + auditEndpointMatcher, + type MatchedAuditEndpoint, +} from './audit-endpoint-matcher'; +import { + isAuditableActor, + resolveAuditActor, + type AuditActorSource, +} from './audit-actor'; +import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; + +/** Methods that can change state. Everything else is never audited. */ +const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** `error_message` ceiling — stack traces do not belong in this column. */ +const MAX_ERROR_LENGTH = 2_000; + +type RequestWithUser = Request & { + user?: AuditActorSource; + files?: unknown; + file?: unknown; + id?: string; +}; + +/** + * Writes one `audit_logs` row per state-changing backoffice request. + * + * An interceptor rather than the two middlewares originally sketched, for one + * decisive reason: Express middleware runs BEFORE guards, so `req.user` is not + * populated yet. Both the backoffice-only rule and `user_id` would be + * unavailable there. Interceptors run after guards and wrap the handler's + * result, so a single class covers both halves — request context on the way in, + * outcome on the way out — sharing one timer for `duration_ms`. + * + * Registered globally (see `audit.module.ts`), so new routes are covered + * automatically as long as they appear in `AUDIT_ENDPOINTS`. + */ +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(private readonly auditService: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. + if (context.getType() !== 'http') return next.handle(); + + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + if (!AUDITED_METHODS.has(request.method)) return next.handle(); + + // Backoffice only. Customers and unauthenticated callers are skipped + // outright — decided in `audit-actor.ts`, which reuses the same + // `userType` discriminator as the permission guards. + if (!isAuditableActor(request.user)) return next.handle(); + + const matched = auditEndpointMatcher.match(request.method, request.originalUrl); + // Not in AUDIT_ENDPOINTS means the route is not a known auditable action; + // recording it would produce rows with no title or entity. + if (!matched) return next.handle(); + + const startedAt = Date.now(); + // The body is captured up front: handlers are free to mutate the DTO they + // are given, so reading it after the fact can record post-mutation values. + const requestPayload = sanitizeRequestPayload( + request.body, + request.files ?? request.file, + ); + + return next.handle().pipe( + tap({ + next: () => { + const response = httpContext.getResponse(); + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: true, + // Nest has not applied the handler's @HttpCode yet at this point + // for some routes; statusCode on the response object is the value + // actually being sent. + statusCode: response.statusCode, + errorMessage: null, + }); + }, + error: (error: unknown) => { + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: false, + statusCode: resolveErrorStatus(error), + errorMessage: resolveErrorMessage(error), + }); + }, + }), + ); + } + + /** + * Build and persist the row. + * + * Deliberately not awaited by `intercept`: the audit write must not add + * latency to the request, and `AuditService.record` already swallows its own + * failures so a rejected promise cannot surface as an unhandled rejection. + */ + private async write( + request: RequestWithUser, + matched: MatchedAuditEndpoint, + requestPayload: Record | null, + startedAt: number, + outcome: { + isSuccess: boolean; + statusCode: number | null; + errorMessage: string | null; + }, + ): Promise { + const actor = resolveAuditActor(request.user as AuditActorSource); + + await this.auditService.record({ + title: matched.title, + method: request.method, + // Full URL including query string, with sensitive query values redacted. + url: redactUrlQuery(request.originalUrl), + routePath: matched.routePath, + type: matched.type, + isSuccess: outcome.isSuccess, + statusCode: outcome.statusCode, + errorMessage: outcome.errorMessage, + userId: actor.userId, + userName: actor.userName, + userRole: actor.userRole, + resourceId: matched.resourceId, + request: requestPayload, + ipAddress: resolveIp(request), + userAgent: request.headers['user-agent'] ?? null, + requestId: resolveRequestId(request), + durationMs: Date.now() - startedAt, + }); + } +} + +/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */ +function resolveErrorStatus(error: unknown): number { + return error instanceof HttpException ? error.getStatus() : 500; +} + +/** Message only — stack traces belong in application logs, not this column. */ +function resolveErrorMessage(error: unknown): string | null { + if (error instanceof HttpException) { + const response = error.getResponse(); + const message = + typeof response === 'string' + ? response + : ((response as { message?: unknown })?.message ?? error.message); + const text = Array.isArray(message) ? message.join('; ') : String(message); + return text.slice(0, MAX_ERROR_LENGTH); + } + + if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH); + return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null; +} + +/** + * Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy + * unless `trust proxy` is set; the forwarded header is preferred and its first + * entry (the original client) taken. + */ +function resolveIp(request: Request): string | null { + const forwarded = request.headers['x-forwarded-for']; + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const candidate = raw?.split(',')[0]?.trim() || request.ip; + if (!candidate) return null; + + // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column + // accepts but which reads badly and breaks grouping by address. + return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; +} + +/** Correlation id from the proxy/tracing layer, when present. */ +function resolveRequestId(request: RequestWithUser): string | null { + const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const value = Array.isArray(header) ? header[0] : header; + return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..608af3672 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,34 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditService } from './audit.service'; + +/** + * Backoffice audit trail. + * + * `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every + * route in the application without touching the 488 mutating handlers + * individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is + * audited as soon as it appears in that map, and unknown routes are skipped + * rather than recorded with an empty title. + * + * Global so other modules can inject `AuditService` to record domain events + * that do not map cleanly onto an HTTP request. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditController], + providers: [ + AuditLogRepository, + AuditService, + { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, + ], + exports: [AuditService, AuditLogRepository], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts new file mode 100644 index 000000000..3a934beea --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts @@ -0,0 +1,195 @@ +/** + * Redaction and shrinking for anything copied into `audit_logs.request`. + * + * This matters more here than in a typical audit log. `main.ts` raises the JSON + * body ceiling to 100MB so contract signing can post a signature AND a company + * stamp as base64 in one request. Copying a body like that verbatim would put + * both a credential-grade artefact and a 100MB blob into the audit table, on + * the write path of every audited endpoint. + */ + +const REDACTED = '[REDACTED]'; + +/** + * Substring-matched against lower-cased key names, so `newPassword`, + * `otpCode` and `x-authorization` are all caught without enumerating variants. + * + * `signature` and `stamp` are here because contract signing posts both as + * base64 — they are simultaneously the largest and the most sensitive fields + * this API accepts. + */ +const SENSITIVE_KEY_PATTERNS = [ + 'password', + 'otp', + 'token', + 'secret', + 'pin', + 'authorization', + 'signature', + 'stamp', + 'apikey', + 'api_key', + 'credential', + 'ssn', +]; + +/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */ +const MAX_REQUEST_BYTES = 64 * 1024; + +/** Depth guard: deep nesting is never worth the recursion cost here. */ +const MAX_DEPTH = 6; + +/** Long strings (base64 blobs) are truncated rather than stored whole. */ +const MAX_STRING_LENGTH = 2_000; + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase(); + return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +/** + * Multer file shape, reduced to a descriptor. The buffer is never stored — + * Postgres is the wrong home for file bytes, and `audit_logs` doubly so. + */ +function isMulterFile(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.originalname === 'string' && + (typeof candidate.mimetype === 'string' || typeof candidate.size === 'number') + ); +} + +function describeFile(value: Record): Record { + return { + __file: true, + originalName: value.originalname ?? null, + mimeType: value.mimetype ?? null, + size: typeof value.size === 'number' ? value.size : null, + fieldName: value.fieldname ?? null, + }; +} + +function sanitizeValue(value: unknown, depth: number): unknown { + if (value === null || value === undefined) return value ?? null; + + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]` + : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + // Buffers are file bytes by definition — never persisted, only described. + if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length }; + + if (depth >= MAX_DEPTH) return '[MAX_DEPTH]'; + + if (Array.isArray(value)) { + // Cap array length: bulk endpoints post large collections. + const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1)); + if (value.length > 50) capped.push(`…[${value.length - 50} more items]`); + return capped; + } + + if (typeof value === 'object') { + if (isMulterFile(value)) return describeFile(value as Record); + + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1); + } + return out; + } + + // Functions, symbols and anything else are not audit data. + return null; +} + +/** + * Sanitize a request body (or query object) for storage. + * + * Returns null when there is nothing worth keeping, so empty bodies do not + * occupy jsonb rows. + */ +export function sanitizeRequestPayload( + body: unknown, + files?: unknown, +): Record | null { + const payload: Record = {}; + + if (body && typeof body === 'object' && Object.keys(body).length > 0) { + const sanitizedBody = sanitizeValue(body, 0); + if (sanitizedBody && typeof sanitizedBody === 'object') { + Object.assign(payload, sanitizedBody as Record); + } + } + + // Multer puts uploads on `req.files`, outside `req.body`, so they are folded + // in explicitly — otherwise a pure-upload request records an empty payload. + if (files) { + const sanitizedFiles = sanitizeValue(files, 0); + if ( + sanitizedFiles && + (Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object') + ) { + const hasEntries = Array.isArray(sanitizedFiles) + ? sanitizedFiles.length > 0 + : Object.keys(sanitizedFiles as object).length > 0; + if (hasEntries) payload.__uploads = sanitizedFiles; + } + } + + if (Object.keys(payload).length === 0) return null; + + // Final size guard. A body can stay under every per-field cap and still be + // enormous in aggregate, so the serialized form is measured before storing. + const serialized = JSON.stringify(payload); + if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) { + return { + __truncated: true, + reason: 'Payload exceeded the audit size limit', + bytes: Buffer.byteLength(serialized, 'utf8'), + keys: Object.keys(payload).slice(0, 50), + }; + } + + return payload; +} + +/** + * Rebuild a URL with sensitive query values redacted. + * + * `url` is stored with its full query string, and query strings are a common + * place for one-time tokens and signed links, so the same deny-list that + * protects the body is applied to the query. + */ +export function redactUrlQuery(url: string): string { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return url; + + const path = url.slice(0, queryIndex); + const query = url.slice(queryIndex + 1); + if (!query) return path; + + const redacted = query + .split('&') + .map((pair) => { + const eq = pair.indexOf('='); + if (eq === -1) return pair; + const key = pair.slice(0, eq); + // Keys arrive percent-encoded; decode before matching so `api%2Dkey` + // is not treated as harmless. + let decodedKey = key; + try { + decodedKey = decodeURIComponent(key); + } catch { + /* malformed encoding — fall back to the raw key */ + } + return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair; + }) + .join('&'); + + return `${path}?${redacted}`; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..de7dfd956 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,71 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; + +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor(private readonly auditLogRepository: AuditLogRepository) {} + + /** + * Persist one audit row, swallowing any failure. + * + * An audit write must never turn a successful business action into an error + * for the user: if this table is full, misconfigured or mid-migration, + * contract approvals still need to work. Failures are logged so the gap is + * visible in application logs rather than silent. + */ + async record(entry: Partial): Promise { + try { + await this.auditLogRepository.record(entry); + } catch (error) { + this.logger.error( + `Failed to write audit log for ${entry.method} ${entry.routePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** Paginated, filtered audit history, newest first. */ + async search(query: AuditLogQueryDto): Promise> { + const { page, pageSize, skip, take } = normalizePagination(query); + + const from = query.from ? new Date(query.from) : undefined; + const to = query.to ? new Date(query.to) : undefined; + + // A reversed range silently returns zero rows, which reads as "nothing + // happened" rather than "your filter is wrong" — reject it explicitly. + if (from && to && from > to) { + throw new BadRequestException('`from` must be earlier than `to`'); + } + + const [items, total] = await this.auditLogRepository.search({ + type: query.type, + userId: query.userId, + method: query.method, + resourceId: query.resourceId, + isSuccess: + query.isSuccess === undefined ? undefined : query.isSuccess === 'true', + from, + to, + skip, + take, + }); + + return { items, meta: buildPaginationMeta(total, page, pageSize) }; + } + + /** Distinct entity types, for the filter dropdown on the audit screen. */ + async listTypes(): Promise { + return this.auditLogRepository.distinctTypes(); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts new file mode 100644 index 000000000..5a5199538 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBooleanString, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const AUDITED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + +/** + * Filters for the audit log read endpoint. + * + * Extends the shared pagination DTO so page/pageSize behave (and are capped) + * exactly as they do on every other list endpoint. + */ +export class AuditLogQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Entity type, e.g. "Contract", "Booking", "Locomotive".', + example: 'Contract', + }) + @IsOptional() + @IsString() + @MaxLength(50) + type?: string; + + @ApiPropertyOptional({ description: 'IAM id of the acting backoffice user.' }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ enum: AUDITED_METHODS }) + @IsOptional() + @Transform(({ value }) => String(value).toUpperCase()) + @IsIn([...AUDITED_METHODS]) + method?: string; + + @ApiPropertyOptional({ description: 'Id of the affected record.' }) + @IsOptional() + @IsString() + @MaxLength(64) + resourceId?: string; + + @ApiPropertyOptional({ + description: 'Filter by outcome: true = succeeded, false = failed.', + }) + @IsOptional() + @IsBooleanString() + isSuccess?: string; + + @ApiPropertyOptional({ + description: 'Inclusive start of the range (ISO 8601).', + example: '2026-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ + description: 'Inclusive end of the range (ISO 8601).', + example: '2026-01-31T23:59:59.999Z', + }) + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 000000000..0ff7727ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,132 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * One backoffice action against a state-changing endpoint. + * + * Deliberately does NOT extend `BaseEntity`, which is the repo standard + * everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both + * are wrong here: + * + * - `updatedAt` implies an audit row can be edited. A record that can be + * rewritten after the fact is not evidence. + * - `deletedAt` (soft delete) would let anyone who can delete erase their own + * trail, and TypeORM would then hide those rows from every default query — + * the failure would be silent, which is the worst property an audit log can + * have. + * + * Rows are insert-only: nothing in this module updates or deletes them. + * + * `userId` is a bare uuid with NO foreign key into the `iam` schema. Two + * reasons: cross-schema FKs are forbidden platform-wide, and a FK would let + * deleting a user cascade away the record of what that user did — exactly + * backwards. `userName` / `userRole` are point-in-time snapshots for the same + * reason: resolving them at read time would rewrite history whenever somebody + * is renamed or changes role. + */ +@Entity({ schema: 'freight', name: 'audit_logs' }) +// Every audit query is time-bounded, so created_at leads most indexes. +@Index('IDX_audit_logs_created_at', ['createdAt']) +@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt']) +@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt']) +@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId']) +@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + /** + * Human-readable action, e.g. "Approve contract" — taken from the matched + * entry in `AUDIT_ENDPOINTS`, which sources it from each route's + * `@ApiOperation` summary. + */ + @Column({ name: 'title', type: 'varchar', length: 255 }) + title!: string; + + @Column({ name: 'method', type: 'varchar', length: 10 }) + method!: string; + + /** + * The URL as actually called, real ids and query string included + * (`/api/contracts/abc-123/cancel?force=true`). Query values run through the + * same redaction pass as the body, so a `?token=` never lands here. + */ + @Column({ name: 'url', type: 'text' }) + url!: string; + + /** + * The route template (`/api/contracts/:id/cancel`). + * + * `url` alone cannot be grouped — every contract cancel is a distinct string. + * This column is the join key back to `AUDIT_ENDPOINTS` and makes + * "every contract cancellation" one indexed query instead of a regex scan. + */ + @Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true }) + routePath?: string | null; + + /** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */ + @Column({ name: 'type', type: 'varchar', length: 50 }) + type!: string; + + @Column({ name: 'is_success', type: 'boolean' }) + isSuccess!: boolean; + + /** IAM user id. Nullable by design — see the class comment. */ + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string | null; + + /** + * Id of the affected record, recovered from the first path parameter of the + * matched template. + * + * `varchar`, not `uuid`: not every identifier is a uuid + * (`/api/contract-templates/:code`), and a create has no id at all until it + * succeeds. A `uuid NOT NULL` column would throw during the write and lose + * the audit row rather than the id. + */ + @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) + resourceId?: string | null; + + /** + * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads + * are reduced to `{ __file, originalName, mimeType, size }` descriptors — + * never raw bytes. See `audit.sanitizer.ts`. + */ + @Column({ name: 'request', type: 'jsonb', nullable: true }) + request?: Record | null; + + /** + * `isSuccess` alone cannot separate 403 (denied — the security signal worth + * alerting on) from 500 (broke). Both are simply `false`. + */ + @Column({ name: 'status_code', type: 'smallint', nullable: true }) + statusCode?: number | null; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string | null; + + /** Snapshot of the actor's display name at the time of the action. */ + @Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true }) + userName?: string | null; + + /** Snapshot of the actor's role at the time of the action. */ + @Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true }) + userRole?: string | null; + + /** Non-repudiation: the first thing asked in any incident review. */ + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress?: string | null; + + /** Helps separate a real browser session from a script using a stolen token. */ + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string | null; + + /** Correlates this row with application logs/traces for the same request. */ + @Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true }) + requestId?: string | null; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs?: number | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f10b5f021..9c4b69ad6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1267,18 +1267,18 @@ export class BookingsController { @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, - file, + files ?? [], resolveAuthUserId(user), { vesselArrivalDate, doCollectedDate }, ); @@ -1287,17 +1287,17 @@ export class BookingsController { @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingReleaseOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( id, - file, + files ?? [], vesselDepartureDate, resolveAuthUserId(user), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e2a3d9b0c..121c889bc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -59,9 +59,11 @@ import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** - * The allocated train as the backoffice booking detail page needs it: which - * train, its window phase, and both the planned and actual clock. Attached by - * `findById` only when the booking is on a schedule. + * The train as the backoffice booking detail page needs it: which train, its + * window phase, and both the planned and actual clock. Attached by `findById` + * for the allocated train (`train_schedule_id`) or, before the batch engine has + * allocated one, the train the customer picked at day-commit + * (`requested_train_schedule_id`) — see `isRequested`. */ export interface TrainScheduleSummary { id: string; @@ -74,6 +76,12 @@ export interface TrainScheduleSummary { actualArrivalAt: string | null; windowPhase: string | null; paymentPhaseEndsAt: string | null; + /** + * True when this is the customer's requested train rather than a confirmed + * allocation — the state staff review at OPERATION_REQUEST_PENDING, before + * accepting the operation puts the booking into the batch pool. + */ + isRequested: boolean; } /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -2132,15 +2140,23 @@ export class BookingsService { // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. - if (booking.trainScheduleId) { + // The allocated train, or — before the batch engine has allocated one — the + // train the customer picked at day-commit. Staff reviewing an operation + // request (OPERATION_REQUEST_PENDING) must see which train they are + // accepting onto before they approve, and at that point only the requested + // id is set. + const summarySourceId = booking.trainScheduleId ?? booking.requestedTrainScheduleId; + if (summarySourceId) { const schedule = await this.dataSource .getRepository(TrainSchedule) - .findOne({ where: { id: booking.trainScheduleId } }); + .findOne({ where: { id: summarySourceId } }); + // trainScheduleStatus drives the portal's Arrival stage, so it stays tied + // to a real allocation — a merely requested train has not departed. (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = - schedule?.status ?? null; - // Backoffice staff view: the allocated train's identity and clock, so the - // detail page can state which train the booking rides and when it runs - // without a second round-trip to the schedules API. + booking.trainScheduleId ? (schedule?.status ?? null) : null; + // Backoffice staff view: the train's identity and clock, so the detail + // page can state which train the booking rides and when it runs without a + // second round-trip to the schedules API. ( booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } ).trainScheduleSummary = schedule @@ -2155,6 +2171,7 @@ export class BookingsService { actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, windowPhase: schedule.windowPhase ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + isRequested: !booking.trainScheduleId, } : null; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index df55493de..8c127e840 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -291,7 +291,7 @@ describe('BookingClearanceService', () => { const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); const result = await service.uploadReleaseOrder( 'b-export', - { fieldname: 'ro' } as Express.Multer.File, + [{ fieldname: 'ro' } as Express.Multer.File], dateStr, ); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index c9c15f8b0..e0d30a2be 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, + isDeliveryOrderFileCode, isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, type ClearanceOffloadState, @@ -29,7 +30,7 @@ import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -813,7 +814,7 @@ export class BookingClearanceService { // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(bookingId, 'bookings'); - if (files.some((f) => f.code === 'delivery_order')) { + if (files.some((f) => isDeliveryOrderFileCode(f.code))) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); await this.workflowService.markReadyForOperation(bookingId); } @@ -823,7 +824,7 @@ export class BookingClearanceService { async uploadDeliveryOrder( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { @@ -832,19 +833,12 @@ export class BookingClearanceService { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!file) throw new BadRequestException('No Delivery Order uploaded'); - const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'delivery_order', - file, - }); + await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselArrivalDate, @@ -880,7 +874,7 @@ export class BookingClearanceService { async uploadReleaseOrder( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], vesselDepartureDate: string, userId?: string, ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { @@ -894,7 +888,6 @@ export class BookingClearanceService { 'RELEASE_ORDER_SECURED', ); - if (!file) throw new BadRequestException('No Release Order uploaded'); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } @@ -902,12 +895,7 @@ export class BookingClearanceService { const minDays = await this.resolveRoMinDays(); const leadDays = this.daysUntil(vesselDepartureDate); - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'release_order', - file, - }); + await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselDepartureDate, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2ebd8fc70..76106a9ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { ContractDocPhase, + isDeliveryOrderFileCode, type ClearanceFinalInvoiceSummary, type ClearanceOffloadState, type ClearanceSecondDuty, @@ -36,7 +37,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, persistDeclarationUploads, persistDeliveryOrderUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -1449,7 +1450,7 @@ export class ContractClearanceService { // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(contractId, 'contracts'); - if (files.some((f) => f.code === 'delivery_order')) { + if (files.some((f) => isDeliveryOrderFileCode(f.code))) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); await this.workflowService.markReadyForBooking(contractId); } @@ -1460,7 +1461,7 @@ export class ContractClearanceService { async uploadDeliveryOrder( contractId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { @@ -1470,19 +1471,12 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - if (!file) throw new BadRequestException('No Delivery Order uploaded'); - const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: 'delivery_order', - file, - }); + await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { @@ -1520,7 +1514,7 @@ export class ContractClearanceService { async uploadReleaseOrder( contractId: string, - file: Express.Multer.File, + files: Express.Multer.File[], vesselDepartureDate: string, userId?: string, ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { @@ -1535,7 +1529,6 @@ export class ContractClearanceService { 'RELEASE_ORDER_SECURED', ); - if (!file) throw new BadRequestException('No Release Order uploaded'); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } @@ -1545,12 +1538,7 @@ export class ContractClearanceService { const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: 'release_order', - file, - }); + await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []); await this.contractsRepository.updateCycle(cycle.id, { vesselDepartureDate, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ff9b7c24..c9db49b24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -956,20 +956,20 @@ export class ContractsController { @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: - 'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates', + 'GL DJ uploads Delivery Order files (import) with vessel arrival + DO collected dates', }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), { + return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), { vesselArrivalDate, doCollectedDate, }); @@ -977,18 +977,18 @@ export class ContractsController { @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + @ApiOperation({ summary: 'GL DJ uploads Release Order files + vessel departure date (export)' }) uploadReleaseOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadReleaseOrder( id, - file, + files ?? [], vesselDepartureDate, resolveAuthUserId(user), ); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 78d0d6b47..ab2a2b2d4 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,6 +5,10 @@ import { draftDeclarationFileLabel, isDeclarationFileCode, isDraftDeclarationFileCode, + isDeliveryOrderFileCode, + isReleaseOrderFileCode, + deliveryOrderFileLabel, + releaseOrderFileLabel, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, @@ -166,6 +170,98 @@ export async function persistTransitPermitUploads( ); } +/** Require at least one Delivery Order file in the upload batch. */ +export function assertDeliveryOrderFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No Delivery Order uploaded'); + } +} + +/** Assign stable `delivery_order_*` codes for multi-file DO uploads. */ +export function normalizeDeliveryOrderFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `delivery_order_${index}`, + })); +} + +/** Replace all Delivery Order files on a resource with a new multi-file batch. */ +export async function persistDeliveryOrderUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeliveryOrderFieldNames(files); + assertDeliveryOrderFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeliveryOrderFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `delivery_order_${index}`, + file, + }), + ), + ); +} + +/** Require at least one Release Order file in the upload batch. */ +export function assertReleaseOrderFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No Release Order uploaded'); + } +} + +/** Assign stable `release_order_*` codes for multi-file RO uploads. */ +export function normalizeReleaseOrderFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `release_order_${index}`, + })); +} + +/** Replace all Release Order files on a resource with a new multi-file batch. */ +export async function persistReleaseOrderUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeReleaseOrderFieldNames(files); + assertReleaseOrderFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isReleaseOrderFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `release_order_${index}`, + file, + }), + ), + ); +} + /** Require at least one export transport document in the upload batch. */ export function assertExportTransportFiles(files: Express.Multer.File[]): void { if (files.length === 0) { @@ -422,6 +518,22 @@ export function buildWorkflowFiles( }); }); + const extraDeliveryOrders = files + .filter((f) => f.code && isDeliveryOrderFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeliveryOrders.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: deliveryOrderFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + const extraT1 = files .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); @@ -440,6 +552,22 @@ export function buildWorkflowFiles( } if (tradeDirection === 'EXPORT') { + const extraReleaseOrders = files + .filter((f) => f.code && isReleaseOrderFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraReleaseOrders.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: releaseOrderFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + const extraExportTransport = files .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 5730120e9..cd8fe1bb9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -64,6 +64,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; + /** + * Voyage (sailing) number for this departure — the identifier yards and + * customs quote alongside the train number. Per-departure, so it lives here + * rather than on the built train. + */ + @Column({ name: 'voyage_number', type: 'varchar', length: 20, nullable: true }) + voyageNumber?: string | null; + // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule // list, booking windows, and load lists. Assigned at creation from the highest // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index c87d118c8..93a187a95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -15,6 +15,7 @@ import { PortalCustomer, TrainSchedulingCancel, TrainSchedulingCreate, + TrainSchedulingEditTrainNumber, TrainSchedulingReschedule, TrainSchedulingRulesManage, TrainSchedulingUpdate, @@ -52,6 +53,8 @@ import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-q import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; +import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto"; +import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto"; import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto"; import { TrainSchedulingService } from "../services/train-scheduling.service"; import { BookingBatchService } from "../booking-batch.service"; @@ -795,6 +798,47 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/train-number") + @TrainSchedulingEditTrainNumber() + @ApiOperation({ + summary: + "Edit a departure's train number and voyage number — allowed only until the train is dispatched", + }) + async updateScheduleTrainNumber( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleTrainNumberDto, + ) { + await this.trainSchedulingService.updateScheduleTrainNumber(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Get("schedules/:id/merge-preview/:targetTrainId") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "What merging a train into this schedule would do — affected schedules, wagon totals and any blocking reasons. Read-only.", + }) + async previewScheduleMerge( + @Param("id", ParseUUIDPipe) id: string, + @Param("targetTrainId", ParseUUIDPipe) targetTrainId: string, + ) { + return this.trainSchedulingService.previewMerge(id, targetTrainId); + } + + @Post("schedules/:id/merge") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", + }) + async mergeScheduleTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MergeScheduleTrainDto, + ) { + await this.trainSchedulingService.mergeScheduleTrain(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/maintenance") @TrainSchedulingReschedule() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts new file mode 100644 index 000000000..8f4c09d43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +/** + * Merge another train into this schedule's train. The schedule always survives: + * its train set is repointed at `targetTrainId`, that train's wagons join this + * consist, and the source train is left empty and deactivated. + */ +export class MergeScheduleTrainDto { + @ApiProperty({ + description: "The train being merged IN. This schedule's train absorbs it.", + }) + @IsUUID() + targetTrainId!: string; + + @ApiPropertyOptional({ + description: 'Why the trains were merged — kept on the audit trail.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts new file mode 100644 index 000000000..5756732c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Edit a departure's operational run identifiers. Both fields are optional so + * either can be corrected alone; the service rejects a body carrying neither, + * so an empty request cannot write an audit row for a no-op. + * + * Sending an empty string clears the field; omitting it leaves it unchanged. + */ +export class UpdateScheduleTrainNumberDto { + @ApiPropertyOptional({ + example: '9201', + description: "Run number for this departure. Empty string clears it.", + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + trainNumber?: string; + + @ApiPropertyOptional({ + example: 'V-2026-014', + description: 'Voyage (sailing) number for this departure. Empty string clears it.', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + voyageNumber?: string; + + @ApiPropertyOptional({ + description: 'Why the numbers changed — kept on the audit trail.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts new file mode 100644 index 000000000..c3acfbecb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts @@ -0,0 +1,399 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Merging one train into a schedule. The schedule ALWAYS survives: its train + * set is repointed at the target train, that train's wagons join the consist, + * a same-day schedule on the target is absorbed (bookings move here, it is + * soft-deleted), and the emptied source train is deactivated. + * + * Driven against stub repositories — every rule under test is service logic. + */ +describe('TrainSchedulingService — train merge', () => { + const DAY = '2026-08-12T00:00:00.000Z'; + const OTHER_DAY = '2026-08-14T00:00:00.000Z'; + + /** Rows each repository returns, keyed by entity. */ + type Fixture = { + schedule: Record | null; + train?: Record | null; + trainSets?: Record[]; + schedules?: Record[]; + wagons?: Record[]; + wagonTypes?: Record[]; + scheduleBookings?: Record[]; + allocations?: Record[]; + milestones?: Record[]; + setWagons?: Record[]; + }; + + const makeService = (fx: Fixture) => { + const updates: Array<{ entity: string; args: unknown[] }> = []; + const softDeletes: string[] = []; + + const repoFor = (entity: unknown) => { + const name = (entity as { name?: string })?.name ?? String(entity); + const rows = (): Record[] => { + switch (name) { + case 'Train': + return fx.train ? [fx.train] : []; + case 'TrainSet': + return fx.trainSets ?? []; + case 'TrainSchedule': + return fx.schedules ?? []; + case 'Wagon': + return fx.wagons ?? []; + case 'WagonType': + return fx.wagonTypes ?? []; + case 'TrainScheduleBooking': + return fx.scheduleBookings ?? []; + case 'WagonBookingAllocation': + return fx.allocations ?? []; + case 'RouteMilestone': + return fx.milestones ?? []; + case 'TrainSetWagon': + return fx.setWagons ?? []; + default: + return []; + } + }; + return { + find: jest.fn().mockImplementation(async () => rows()), + findOne: jest.fn().mockImplementation(async () => rows()[0] ?? null), + update: jest.fn().mockImplementation(async (...args: unknown[]) => { + updates.push({ entity: name, args }); + }), + softDelete: jest.fn().mockImplementation(async (id: string) => { + softDeletes.push(id); + }), + }; + }; + + const dataSource = { + getRepository: jest.fn().mockImplementation(repoFor), + transaction: jest + .fn() + .mockImplementation(async (cb: (m: unknown) => Promise) => + cb({ getRepository: repoFor }), + ), + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + trainSchedulesRepository: { + findByIdWithFullGraph: jest.fn().mockResolvedValue(fx.schedule), + findById: jest.fn().mockResolvedValue(fx.schedule), + }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, updates, softDeletes }; + }; + + /** A draft schedule on T1 with 10 wagons and no locomotive caps. */ + const baseSchedule = (over: Record = {}) => ({ + id: 'S1', + reference: 'S-2026-00001', + status: 'DRAFT', + scheduledDepartureDate: DAY, + routeId: null, + maxWagons: 0, + trainSetId: 'TS1', + trainSet: { + id: 'TS1', + trainId: 'T1', + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + ...over, + }); + + const targetWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `200${i}`, + wagonTypeId: 'wt-1', + trainId: 'T2', + })); + + describe('guards', () => { + it('refuses to merge into a dispatched schedule', async () => { + const { service } = makeService({ + schedule: baseSchedule({ status: 'DISPATCHED' }), + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses to merge a train into itself', async () => { + const { service } = makeService({ schedule: baseSchedule() }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T1' }), + ).rejects.toThrow(/already this schedule's train/i); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService({ schedule: null }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('blocks when the target train has no wagons to give', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: [], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/no wagons to merge/i); + }); + }); + + describe('preview', () => { + it('reports the merged wagon total and the emptied source train', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2', trainNumber: '8002' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.canMerge).toBe(true); + expect(preview.wagons).toEqual({ current: 10, incoming: 40, merged: 50 }); + expect(preview.sourceTrainWillDeactivate).toBe(true); + expect(preview.absorbedSchedule).toBeNull(); + }); + + it('names the same-day schedule whose bookings move here', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + { id: 'sb-2', bookingId: 'bk-2', trainScheduleId: 'S2' }, + ], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toMatchObject({ + id: 'S2', + reference: 'S-2026-00002', + bookingsMoving: 2, + }); + }); + + it('lists an other-day schedule as wagons-only, never absorbed', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S3', + reference: 'S-2026-00003', + status: 'DRAFT', + scheduledDepartureDate: OTHER_DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(1); + expect(preview.affectedSchedules[0]).toMatchObject({ id: 'S3' }); + }); + + it('leaves a dispatched schedule on the target untouched', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S4', + status: 'DISPATCHED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + // Same day, but dispatched — its cargo stays put. + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(0); + expect(preview.untouchedSchedules).toHaveLength(1); + }); + }); + + describe('commit', () => { + it('repoints the set, moves the wagons and deactivates the source train', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const setRepoint = updates.find( + (u) => u.entity === 'TrainSet' && u.args[0] === 'TS1', + ); + expect(setRepoint?.args[1]).toMatchObject({ trainId: 'T2' }); + + const wagonMove = updates.find((u) => u.entity === 'Wagon'); + expect(wagonMove?.args[1]).toMatchObject({ trainId: 'T2' }); + + const trainPark = updates.find( + (u) => u.entity === 'Train' && u.args[0] === 'T1', + ); + expect(trainPark?.args[1]).toMatchObject({ status: 'DEACTIVATED' }); + }); + + it('moves the absorbed schedule\'s bookings here and soft-deletes it', async () => { + const { service, updates, softDeletes } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const bookingMove = updates.find( + (u) => u.entity === 'TrainScheduleBooking', + ); + expect(bookingMove?.args[0]).toMatchObject({ trainScheduleId: 'S2' }); + expect(bookingMove?.args[1]).toMatchObject({ trainScheduleId: 'S1' }); + + // Soft-deleted, not cancelled — the bookings still exist and still depart. + expect(softDeletes).toEqual(['S2']); + }); + + it('appends merged wagons after the existing consist', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(2), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + setWagons: [ + { id: 'in-0', trainSetId: 'TS2', physicalWagonId: 'w-0' }, + { id: 'in-1', trainSetId: 'TS2', physicalWagonId: 'w-1' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + // 10 existing wagons occupy 1..10, so the merged pair lands at 11 and 12 + // — staff reorder them in the train builder afterwards. + const seqs = updates + .filter((u) => u.entity === 'TrainSetWagon') + .map((u) => (u.args[1] as { sequenceNo: number }).sequenceNo); + expect(seqs).toEqual([11, 12]); + }); + }); + + describe('capacity', () => { + it('blocks a merge that overruns the locomotive length cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + // A short loco: 100m of train, already 10 × 14m = 140m used. + locomotive: { + maxPullWeightTons: 5000, + maxTrainLengthMeters: 100, + }, + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max train length/i); + }); + + it('blocks a merge that overruns the pull-weight cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + locomotive: { + maxPullWeightTons: 300, + maxTrainLengthMeters: 10000, + }, + wagons: [], + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max pull weight/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts new file mode 100644 index 000000000..d7dae50f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; +import type { UpdateScheduleTrainNumberDto } from './dto/update-schedule-train-number.dto'; + +/** + * Guards around renumbering a departure. Exercised against a stub repository — + * the rules (dispatch lock, empty-body rejection, clear-vs-leave semantics) are + * pure service logic and need no database. + */ +describe('TrainSchedulingService.updateScheduleTrainNumber', () => { + const makeService = (schedule: Record | null) => { + const update = jest.fn().mockResolvedValue(undefined); + const findById = jest.fn().mockResolvedValue(schedule); + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + trainSchedulesRepository: { findById, update }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, update, findById }; + }; + + const call = (service: TrainSchedulingService, dto: UpdateScheduleTrainNumberDto) => + service.updateScheduleTrainNumber('sched-1', dto); + + it('updates both numbers on a SCHEDULED train', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'SCHEDULED', + trainNumber: '9101', + voyageNumber: null, + }); + + await call(service, { trainNumber: '9201', voyageNumber: 'V-2026-014' }); + + expect(update).toHaveBeenCalledWith('sched-1', { + trainNumber: '9201', + voyageNumber: 'V-2026-014', + }); + }); + + it('refuses to renumber a dispatched train', async () => { + // The numbers are already printed on paperwork that left with the train. + const { service, update } = makeService({ + id: 'sched-1', + status: 'DISPATCHED', + trainNumber: '9101', + }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['ARRIVED', 'CANCELLED', 'COMPLETED'])( + 'refuses to renumber a %s schedule', + async (status) => { + const { service, update } = makeService({ id: 'sched-1', status }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }, + ); + + it('rejects a body carrying neither number before touching the schedule', async () => { + const { service, update, findById } = makeService({ + id: 'sched-1', + status: 'DRAFT', + }); + + await expect(call(service, {})).rejects.toBeInstanceOf(BadRequestException); + expect(findById).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('leaves an omitted field untouched rather than clearing it', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + trainNumber: '9101', + voyageNumber: 'V-1', + }); + + await call(service, { trainNumber: '9201' }); + + expect(update).toHaveBeenCalledWith('sched-1', { trainNumber: '9201' }); + expect(update.mock.calls[0][1]).not.toHaveProperty('voyageNumber'); + }); + + it('clears a field when an empty string is sent', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + voyageNumber: 'V-1', + }); + + await call(service, { voyageNumber: ' ' }); + + expect(update).toHaveBeenCalledWith('sched-1', { voyageNumber: null }); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService(null); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 657703ada..8bce5fb80 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -96,6 +96,8 @@ import { } from '../dto/import-djibouti-operation.dto'; import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; +import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto'; +import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto'; import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from '../booking-window.config'; import { BookingWindowGateway } from '../booking-window.gateway'; @@ -132,14 +134,17 @@ import { } from '../utils/wagon-plan.util'; import { CorridorBudget } from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util'; import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, bulkTonsPerWagon, + consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, + trainHardCaps, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -928,6 +933,68 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Correct a departure's operational run identifiers — the train number and + * voyage number yards and customs quote. + * + * Editable only until the train leaves: once DISPATCHED (or beyond) the + * numbers are printed on paperwork and quoted downstream, so a late edit would + * desync records that already left with the train. The audit row is written by + * the global AuditInterceptor from the registered route. + */ + async updateScheduleTrainNumber( + id: string, + dto: UpdateScheduleTrainNumberDto, + ): Promise { + if (dto.trainNumber === undefined && dto.voyageNumber === undefined) { + throw new BadRequestException( + 'Provide a train number or a voyage number to update.', + ); + } + + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + // Only a train that has not left can be renumbered. CANCELLED is excluded + // too — renumbering a dead schedule has no meaning. + const editable: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + if (!editable.includes(schedule.status)) { + throw new BadRequestException( + `Cannot change the train or voyage number of a ${schedule.status} schedule — ` + + 'the numbers are fixed once the train is dispatched.', + ); + } + + // An empty string clears the field; an omitted field is left untouched. + const patch: Partial = {}; + if (dto.trainNumber !== undefined) { + patch.trainNumber = dto.trainNumber.trim() || null; + } + if (dto.voyageNumber !== undefined) { + patch.voyageNumber = dto.voyageNumber.trim() || null; + } + + await this.trainSchedulesRepository.update(id, patch); + this.logger.log( + `Schedule ${schedule.reference ?? id} renumbered` + + (patch.trainNumber !== undefined + ? ` — train ${schedule.trainNumber ?? '—'} → ${patch.trainNumber ?? '—'}` + : '') + + (patch.voyageNumber !== undefined + ? ` — voyage ${schedule.voyageNumber ?? '—'} → ${patch.voyageNumber ?? '—'}` + : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Reschedule ONE train's departure date (staff action on the ops board). Only * allowed while the booking window has not opened yet — an OPEN/past schedule @@ -4072,7 +4139,15 @@ export class TrainSchedulingService { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, + trainSet: { + locomotive: true, + locomotives: { locomotive: true }, + train: true, + // Slot allocations back the list's "used wagons" figure — without + // them the row can only report the coupled consist size, which is + // what made the list disagree with the detail page's wagon plan. + wagons: { allocations: true }, + }, // Yards carry the route's display name used by mapScheduleListItem; // milestones (with yards) let it show the full corridor path. route: { originYard: true, destinationYard: true, milestones: { yard: true } }, @@ -5752,12 +5827,22 @@ export class TrainSchedulingService { } private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { + // Wagon figures must match the detail page's wagon plan (WagonPlanGrid) — + // see computeScheduleWagonUsage for why the stored counter cannot be used. + const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } = + computeScheduleWagonUsage({ + wagonSlots: schedule.trainSet?.wagons, + storedWagonCount: schedule.trainSet?.wagonCount, + scheduleBookings: schedule.scheduleBookings, + }); + return { id: schedule.id, reference: schedule.reference ?? null, createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, direction: schedule.direction ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, @@ -5786,6 +5871,14 @@ export class TrainSchedulingService { currentYardId: loco.currentYardId ?? null, })), wagonCount: schedule.trainSet?.wagonCount ?? 0, + /** Coupled slots carrying a booking allocation — matches the wagon plan. */ + wagonsUsed, + /** Coupled consist size; the denominator of "used". */ + wagonsTotal, + /** Claimed by bookings (incl. unpaid) — not bookable. */ + wagonsReserved, + /** Consist minus what bookings have claimed; what is still bookable. */ + wagonsRemaining, totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), bookingsCount: schedule.scheduleBookings?.length ?? 0, @@ -7703,6 +7796,7 @@ export class TrainSchedulingService { status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, reverseWagonOrder: schedule.reverseWagonOrder ?? false, @@ -9122,4 +9216,376 @@ export class TrainSchedulingService { }); return new Set(allocations.map((a) => a.bookingId)); } + + // ── Train merge ──────────────────────────────────────────────────────────── + // Combine two trains into one departure. The schedule the action is taken + // from ALWAYS survives: its train set is repointed at the target train, the + // target's wagons join this consist, and the source train is emptied and + // deactivated. When the target also runs a schedule on the SAME DAY, that + // schedule's bookings move here and it is soft-deleted; the target's + // other-day schedules contribute wagons only. + + /** Statuses whose schedules may take part in a merge. */ + private static readonly MERGEABLE_STATUSES: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + + /** + * Everything a merge needs to decide, gathered once. Both `previewMerge` and + * `mergeScheduleTrain` run this so the modal shows exactly what will happen + * and the commit cannot diverge from it. + */ + private async planMerge(scheduleId: string, targetTrainId: string) { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) { + throw new BadRequestException( + `Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`, + ); + } + + const sourceTrainId = schedule.trainSet?.trainId ?? null; + if (sourceTrainId && sourceTrainId === targetTrainId) { + throw new BadRequestException( + 'That is already this schedule\'s train — pick a different one to merge in.', + ); + } + + const targetTrain = await this.dataSource + .getRepository(Train) + .findOne({ where: { id: targetTrainId } }); + if (!targetTrain) { + throw new NotFoundException(`Train ${targetTrainId} not found`); + } + + // Every schedule the target train is committed to, via its train sets. + const targetSets = await this.dataSource + .getRepository(TrainSet) + .find({ where: { trainId: targetTrainId } }); + const targetSetIds = targetSets.map((s) => s.id); + const targetSchedules = targetSetIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { trainSetId: In(targetSetIds) }, + }) + : []; + + // The same-day schedule is the one whose bookings move here. Only a + // draft/scheduled one qualifies — a dispatched departure keeps its cargo. + const sameDay = (a: Date | string, b: Date | string) => + new Date(a).toISOString().slice(0, 10) === + new Date(b).toISOString().slice(0, 10); + + const absorbed = + targetSchedules.find( + (s) => + s.id !== schedule.id && + sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ) ?? null; + + // Wagons ride with the train, so every OTHER draft/scheduled schedule on it + // is affected too — it gains the merged consist but never the bookings. + const affectedOthers = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + const untouched = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + !TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + + // The wagons joining this consist: whatever physically sits on the target + // train today. + const incomingWagons = await this.dataSource + .getRepository(Wagon) + .find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } }); + + const movingBookings = absorbed + ? await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { trainScheduleId: absorbed.id }, + relations: { booking: true }, + }) + : []; + + return { + schedule, + sourceTrainId, + targetTrain, + absorbed, + affectedOthers, + untouched, + incomingWagons, + movingBookings, + }; + } + + /** + * Blocking checks, run against the plan. Returns human-readable reasons; an + * empty array means the merge may proceed. Kept separate from `planMerge` so + * the preview can SHOW the reasons rather than throwing on them. + */ + private async mergeBlockers( + plan: Awaited>, + ): Promise { + const blockers: string[] = []; + const { schedule, incomingWagons, movingBookings, absorbed } = plan; + + if (incomingWagons.length === 0) { + blockers.push( + `${plan.targetTrain.code} has no wagons to merge — nothing would move.`, + ); + } + + // ── Capacity: the merged consist must fit this schedule's locomotives ──── + const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters) || 0, + tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0, + cargoTons: 0, + })); + const wagonTypeIds = [ + ...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)), + ]; + const wagonTypes = wagonTypeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(wagonTypeIds) } }) + : []; + const typeById = new Map(wagonTypes.map((t) => [t.id, t])); + const incomingSlots = incomingWagons.map((w) => { + const t = typeById.get(w.wagonTypeId); + return { + lengthMeters: Number(t?.lengthMeters) || 0, + tareWeightTons: Number(t?.tareWeightTons) || 0, + cargoTons: 0, + }; + }); + + const limits = trainSetLocomotiveLimits(schedule.trainSet); + if (limits) { + const rules = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ take: 1 }); + const caps = trainHardCaps(limits, { + maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined, + maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined, + }); + const merged = [...existingSlots, ...incomingSlots]; + // maxWagons is the schedule's own slot ceiling; fall back to the consist + // size when it is unset so the count axis never blocks spuriously. + const violations = consistViolations(merged, { + maxWeightTons: caps.maxWeightTons, + maxLengthMeters: caps.maxLengthMeters, + maxWagonSlots: schedule.maxWagons || merged.length, + }); + blockers.push(...violations); + } + + // ── Legs: an absorbed booking must be servable by THIS schedule's route ── + if (absorbed && movingBookings.length) { + const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null); + if (routeYardIds.length) { + const position = new Map(routeYardIds.map((id, i) => [id, i])); + const slotIds = movingBookings.map((mb) => mb.bookingId); + const allocations = slotIds.length + ? await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { bookingId: In(slotIds) }, + relations: { trainSetWagon: true }, + }) + : []; + const offRoute = new Set(); + for (const alloc of allocations) { + const board = alloc.trainSetWagon?.boardYardId ?? null; + const alight = alloc.trainSetWagon?.alightYardId ?? null; + // Null on both = rides the whole route; always compatible. + if (!board && !alight) continue; + const from = board ? position.get(board) : 0; + const to = alight ? position.get(alight) : routeYardIds.length - 1; + if (from === undefined || to === undefined || from >= to) { + offRoute.add(alloc.bookingId); + } + } + if (offRoute.size) { + blockers.push( + `${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` + + 'travel legs this schedule\'s route does not serve in the same order.', + ); + } + } + } + + return blockers; + } + + /** Ordered yard ids along a route, origin first. Empty when unknown. */ + private async routeYardSequence(routeId: string | null): Promise { + if (!routeId) return []; + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId }, order: { sequenceNo: 'ASC' } }); + return milestones + .map((m) => m.yardId) + .filter((id): id is string => Boolean(id)); + } + + /** + * What a merge WOULD do, without doing it. Drives the confirmation modal: + * which schedules gain wagons, which one is absorbed, and why it is blocked. + */ + async previewMerge(scheduleId: string, targetTrainId: string) { + const plan = await this.planMerge(scheduleId, targetTrainId); + const blockers = await this.mergeBlockers(plan); + + const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0; + return { + canMerge: blockers.length === 0, + blockers, + targetTrain: { + id: plan.targetTrain.id, + code: plan.targetTrain.code, + trainNumber: plan.targetTrain.trainNumber ?? null, + }, + wagons: { + current: existingCount, + incoming: plan.incomingWagons.length, + merged: existingCount + plan.incomingWagons.length, + }, + /** The same-day schedule whose bookings move here and is then removed. */ + absorbedSchedule: plan.absorbed + ? { + id: plan.absorbed.id, + reference: plan.absorbed.reference ?? null, + scheduledDepartureDate: plan.absorbed.scheduledDepartureDate, + status: plan.absorbed.status, + bookingsMoving: plan.movingBookings.length, + } + : null, + /** Other draft/scheduled schedules on the target — wagons only. */ + affectedSchedules: plan.affectedOthers.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + /** On the target train but left alone (dispatched, cancelled, …). */ + untouchedSchedules: plan.untouched.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + sourceTrainWillDeactivate: Boolean(plan.sourceTrainId), + }; + } + + /** + * Execute the merge. One transaction: repoint the train set, move the wagons + * (appended last so the builder can reorder them later), carry the absorbed + * schedule's bookings across, soft-delete that schedule, and deactivate the + * emptied source train. + */ + async mergeScheduleTrain( + scheduleId: string, + dto: MergeScheduleTrainDto, + ): Promise { + const plan = await this.planMerge(scheduleId, dto.targetTrainId); + const blockers = await this.mergeBlockers(plan); + if (blockers.length) { + throw new BadRequestException(blockers.join(' ')); + } + + const { + schedule, + sourceTrainId, + targetTrain, + absorbed, + incomingWagons, + movingBookings, + } = plan; + const trainSetId = schedule.trainSetId; + + await this.dataSource.transaction(async (manager) => { + // 1. This schedule's set now runs on the target train. + await manager.getRepository(TrainSet).update(trainSetId, { + trainId: targetTrain.id, + }); + + // 2. The physical wagons follow the train. + if (incomingWagons.length) { + await manager.getRepository(Wagon).update( + { id: In(incomingWagons.map((w) => w.id)) }, + { trainId: targetTrain.id }, + ); + } + + // 3. Carry the target's train-set wagon rows into THIS consist, appended + // after the existing wagons. Sequence is provisional — staff reorder + // in the train builder afterwards. + const existing = schedule.trainSet?.wagons ?? []; + let nextSequence = + existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1; + const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({ + where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) }, + }); + for (const row of incomingSetWagons) { + if (row.trainSetId === trainSetId) continue; + await manager.getRepository(TrainSetWagon).update(row.id, { + trainSetId, + sequenceNo: nextSequence, + }); + nextSequence += 1; + } + + // 4. The absorbed schedule's bookings move here. `bookingId` is uniquely + // indexed, so these rows are UPDATED across rather than re-inserted. + if (absorbed && movingBookings.length) { + await manager + .getRepository(TrainScheduleBooking) + .update( + { trainScheduleId: absorbed.id }, + { trainScheduleId: schedule.id }, + ); + } + + // 5. The absorbed schedule is soft-deleted — its bookings still exist and + // still depart that day, so nobody is notified and nothing is lost. + if (absorbed) { + await manager.getRepository(TrainSchedule).softDelete(absorbed.id); + } + + // 6. The source train is now empty; park it. + if (sourceTrainId) { + await manager.getRepository(Train).update(sourceTrainId, { + status: Freight.TrainStatus.Deactivated, + }); + } + + // 7. Keep the set's cached totals honest. + const mergedCount = + (schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length; + await manager + .getRepository(TrainSet) + .update(trainSetId, { wagonCount: mergedCount }); + }); + + this.logger.log( + `Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` + + ` — ${incomingWagons.length} wagon(s) moved` + + (absorbed + ? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))` + : '') + + (sourceTrainId ? ', source train deactivated' : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts new file mode 100644 index 000000000..8fa9c1260 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts @@ -0,0 +1,96 @@ +import { computeScheduleWagonUsage } from './schedule-wagon-usage.util'; + +/** A coupled slot; `allocated` = a booking actually sits on it. */ +const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] }); +const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } }); + +describe('computeScheduleWagonUsage', () => { + it('reports allocated slots as used, not the coupled consist size', () => { + // The reported bug: a 37-wagon consist carrying 3 allocated bookings read + // "37 wgn used" in the list while the detail page read "3 in use". + const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))]; + + const usage = computeScheduleWagonUsage({ + wagonSlots: slots, + storedWagonCount: 37, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsTotal).toBe(37); + }); + + it('counts a built train with no bookings as 0 used', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(40).fill(slot(false)), + storedWagonCount: 40, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsRemaining).toBe(40); + }); + + it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => { + // Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5 + // bookable on a 10-wagon train — the reservation is not free space. + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(10).fill(slot(false)), + storedWagonCount: 10, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsReserved).toBe(5); + expect(usage.wagonsRemaining).toBe(5); + }); + + it('does not double-count a booking that is both reserved and allocated', () => { + // 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4. + const usage = computeScheduleWagonUsage({ + wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))], + storedWagonCount: 10, + scheduleBookings: [booking(3)], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsReserved).toBe(3); + expect(usage.wagonsRemaining).toBe(7); + }); + + it('never reports negative remaining when claims exceed the consist', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(2).fill(slot(false)), + storedWagonCount: 2, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsRemaining).toBe(0); + }); + + it('falls back to the stored counter when slot rows were not loaded', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: [], + storedWagonCount: 12, + scheduleBookings: [], + }); + + expect(usage.wagonsTotal).toBe(12); + expect(usage.wagonsUsed).toBe(0); + }); + + it('tolerates missing relations and null wagonsRequired', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: null, + storedWagonCount: null, + scheduleBookings: [booking(null)], + }); + + expect(usage).toEqual({ + wagonsUsed: 0, + wagonsTotal: 0, + wagonsReserved: 0, + wagonsRemaining: 0, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts new file mode 100644 index 000000000..f48d60f9a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts @@ -0,0 +1,58 @@ +/** + * Wagon figures for a train-schedule list row. + * + * The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE — + * under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings + * read "37 wgn used" in the list while its detail page (WagonPlanGrid) read + * "37 wagons · 3 in use". These helpers make the list agree with the detail + * page, which is the figure staff trust. + */ + +/** The shape this math needs — a slot counts as used when it has allocations. */ +export interface WagonSlotLike { + allocations?: unknown[] | null; +} + +export interface ScheduleBookingLike { + booking?: { wagonsRequired?: number | null } | null; +} + +export interface ScheduleWagonUsage { + /** Coupled slots carrying at least one booking allocation. */ + wagonsUsed: number; + /** Coupled consist size — the denominator of `wagonsUsed`. */ + wagonsTotal: number; + /** Wagons claimed by bookings, including bookings that have not paid. */ + wagonsReserved: number; + /** Consist minus what bookings have claimed — what is still bookable. */ + wagonsRemaining: number; +} + +export function computeScheduleWagonUsage(input: { + wagonSlots?: WagonSlotLike[] | null; + /** Stored counter; used only when the slot rows were not loaded. */ + storedWagonCount?: number | null; + scheduleBookings?: ScheduleBookingLike[] | null; +}): ScheduleWagonUsage { + const slots = input.wagonSlots ?? []; + + // Same predicate as the detail page's WagonPlanGrid: a slot is in use only + // when a booking is actually allocated onto it. + const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length; + + // Prefer live slot rows; the stored counter drifts when a consist is edited + // without a recompute, which is why the list and detail disagreed on totals. + const wagonsTotal = slots.length || (input.storedWagonCount ?? 0); + + // An unpaid booking still holds its wagons, so reserved space is NOT bookable. + const wagonsReserved = (input.scheduleBookings ?? []).reduce( + (sum, link) => sum + (link.booking?.wagonsRequired ?? 0), + 0, + ); + + // Reserved subsumes allocated — an allocated booking still counts its wagons — + // so remaining subtracts whichever claim is larger, never both. + const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved)); + + return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining }; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 0c9415e42..7b0bdd3bd 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -51,9 +51,9 @@ export class BuildTrainDto { @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiProperty({ maxLength: 100, description: 'Vogue number' }) + @ApiProperty({ maxLength: 100, description: 'Voyage number' }) @IsString() - @IsNotEmpty({ message: 'Vogue number is required' }) + @IsNotEmpty({ message: 'Voyage number is required' }) @MaxLength(100) trainName!: string; diff --git a/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts new file mode 100644 index 000000000..269c4f9b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SendWagonToMaintenanceDto { + @ApiPropertyOptional({ + description: + "Why the wagon is going to maintenance. Stored on the wagon's status-history " + + 'log alongside the train it was detached from, matching the fleet desk flow.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index c1b1459a3..248092f95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -135,8 +136,14 @@ export class TrainBuilderController { @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, @CurrentUser() user: AuthUserPayload, + @Body() dto?: SendWagonToMaintenanceDto, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); + return this.trainBuilderService.sendWagonToMaintenance( + id, + wagonId, + resolveAuthUserId(user), + dto?.note, + ); } @Post(':id/reorder-wagons') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts new file mode 100644 index 000000000..a6157db7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts @@ -0,0 +1,83 @@ +import { buildMaintenanceNotes, formatTrainRunLabel } from './train-builder.service'; + +describe('formatTrainRunLabel', () => { + it('names the train by its export and import run numbers', () => { + // What staff actually recognise — NOT the internal code (TRN-LEDGER-PW2). + expect( + formatTrainRunLabel({ + exportTrainNumber: '9201', + importTrainNumber: '9202', + trainNumber: 'TRN-7', + code: 'TRN-LEDGER-PW2', + }), + ).toBe('export 9201 / import 9202'); + }); + + it('shows only the run number that is set', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: '9201', code: 'TRN-LEDGER-PW2' }), + ).toBe('export 9201'); + expect( + formatTrainRunLabel({ importTrainNumber: '9202', code: 'TRN-LEDGER-PW2' }), + ).toBe('import 9202'); + }); + + it('falls back to the train number, then the code, when no run is set', () => { + expect(formatTrainRunLabel({ trainNumber: 'TRN-7', code: 'TRN-LEDGER-PW2' })).toBe( + 'TRN-7', + ); + expect(formatTrainRunLabel({ code: 'TRN-LEDGER-PW2' })).toBe('TRN-LEDGER-PW2'); + }); + + it('ignores blank run numbers rather than printing empty labels', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: ' ', importTrainNumber: null, code: 'C-1' }), + ).toBe('C-1'); + }); + + it('never returns an empty label', () => { + expect(formatTrainRunLabel({})).toBe('unknown'); + }); +}); + +describe('buildMaintenanceNotes', () => { + it('records the operator reason together with the train it came off', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', 'Brake shoe worn through'); + + expect(notes.statusLogNote).toBe( + 'Brake shoe worn through (detached from train export 9201 / import 9202)', + ); + expect(notes.movementNote).toBe( + 'Sent to maintenance from train export 9201 / import 9202: Brake shoe worn through', + ); + }); + + it('still records the train number when no reason is given', () => { + // The reason is optional, but which train a wagon left is never optional — + // the history has to answer that on its own. + const notes = buildMaintenanceNotes('export 9201 / import 9202'); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('treats a whitespace-only reason as no reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' '); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('trims padding around a real reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' Coupler damage '); + + expect(notes.statusLogNote).toBe('Coupler damage (detached from train export 9201 / import 9202)'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202: Coupler damage'); + }); + + it('handles a null reason from an older client', () => { + expect(buildMaintenanceNotes('export 9201 / import 9202', null).statusLogNote).toBe( + 'Detached from train export 9201 / import 9202', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 9da3cecf0..0f377071c 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -530,7 +531,12 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { + async sendWagonToMaintenance( + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -542,6 +548,8 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } + const previousStatus = wagon.status; + const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, @@ -549,6 +557,22 @@ export class TrainBuilderService { importTrainNumber: null, exportTrainNumber: null, }); + + // Status-history row, same as the fleet desk's "Send to maintenance" — + // without it a maintenance detach made here is invisible in the wagon's + // status history. The train number is folded into the note so the history + // answers "which train did it come off, and why" in one line. + if (previousStatus !== WagonStatus.Maintenance) { + await manager.getRepository(WagonStatusLog).save( + manager.getRepository(WagonStatusLog).create({ + wagonId: wagon.id, + fromStatus: previousStatus, + toStatus: WagonStatus.Maintenance, + changedByUserId: userId ?? null, + note: notes.statusLogNote, + }), + ); + } // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -560,7 +584,7 @@ export class TrainBuilderService { fromYardId: yardId, toYardId: yardId, kind: WagonMovementKind.Maintenance, - note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`, + note: notes.movementNote, occurredAt: new Date(), }), ); @@ -1111,6 +1135,48 @@ export class TrainBuilderService { * pinned to a reordered wagon adopt the wagon's new position; unpinned slots * trail behind in their previous relative order. */ +/** + * How a train is named in a wagon's history. Staff identify a train by its + * OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers + * typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a + * ledger key and means nothing on the ground. Both runs are shown when set, + * since one built train carries the pair. Falls back to the train number, then + * the code, only when no run number exists. + */ +export function formatTrainRunLabel(train: { + exportTrainNumber?: string | null; + importTrainNumber?: string | null; + trainNumber?: string | null; + code?: string | null; +}): string { + const exportNo = train.exportTrainNumber?.trim(); + const importNo = train.importTrainNumber?.trim(); + const runs = [ + exportNo ? `export ${exportNo}` : null, + importNo ? `import ${importNo}` : null, + ].filter(Boolean); + if (runs.length) return runs.join(' / '); + return train.trainNumber?.trim() || train.code?.trim() || 'unknown'; +} + +/** + * Notes for a maintenance detach. The train's run numbers are always recorded — + * staff need to know which consist a wagon came off — and the operator's reason + * is folded in when given, so the wagon's status history answers "which train, + * and why" in one line (matching the fleet desk's Send-to-maintenance note). + */ +export function buildMaintenanceNotes(trainLabel: string, note?: string | null) { + const reason = note?.trim(); + return { + statusLogNote: reason + ? `${reason} (detached from train ${trainLabel})` + : `Detached from train ${trainLabel}`, + movementNote: reason + ? `Sent to maintenance from train ${trainLabel}: ${reason}` + : `Sent to maintenance from train ${trainLabel}`, + }; +} + export function orderSlotsByWagonSequence< T extends Pick, >(slots: T[], newSeq: Map): T[] { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index 85c879f70..615ef0701 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + IsArray, IsInt, IsNotEmpty, IsOptional, @@ -11,11 +13,14 @@ import { } from 'class-validator'; /** - * A count-only wagon-transfer request. The requester picks source yard, wagon - * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks - * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard (enforced in the service, which is the only - * layer that can count them), and a reason is mandatory. + * A wagon-transfer request. The requester picks source yard, wagon type, + * destination yard and HOW MANY. The quantity may not exceed the AVAILABLE + * wagons of that type currently in the source yard (enforced in the service, + * which is the only layer that can count them), and a reason is mandatory. + * + * The requester may additionally name the specific wagons they want via + * `preferredWagonIds`. That is a preference recorded for OCC, not a + * reservation — the count still drives fulfilment. */ export class CreateTransferRequestDto { @IsUUID() @@ -38,6 +43,17 @@ export class CreateTransferRequestDto { @MaxLength(2000) reason!: string; + @ApiPropertyOptional({ + description: + 'Specific wagons the requester wants, if they picked any. A preference for OCC — the wagons are not reserved.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(1000) + @IsUUID('4', { each: true }) + preferredWagonIds?: string[]; + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c39b12b9d..8b1922711 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity { }) status!: WagonTransferRequestStatus; + /** + * The wagons the requester specifically asked for, when they picked any. A + * preference, not a reservation — the wagons stay AVAILABLE to everyone else, + * and OCC may still send different ones. Null/empty on a plain count request. + */ + @Column({ name: 'preferred_wagon_ids', type: 'uuid', array: true, nullable: true }) + preferredWagonIds?: string[] | null; + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) requestedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 8f7457b37..74fb6d6d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { expect(result.skipped).toHaveLength(0); }); + it('auto-picks the wagons the requester named ahead of the rest', async () => { + // Asked for 2 and named w-3 — the auto-pick must take it even though + // wagon-number order would have sent w-0 and w-1. + build(request({ quantity: 2, preferredWagonIds: ['w-3'] })); + wagonRepo.find.mockResolvedValue(availableWagons(5)); + + await service.bulkFulfill(['req-1']); + + const [{ wagonIds }] = wagonsService.bulkTransfer.mock.calls[0]; + expect(wagonIds).toHaveLength(2); + expect(wagonIds[0]).toBe('w-3'); + }); + it('skips only when the yard has nothing to give', async () => { wagonRepo.find.mockResolvedValue([]); @@ -253,6 +266,84 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { expect(requestRepo.save).not.toHaveBeenCalled(); }); + it('records the wagons the requester hand-picked', async () => { + wagonRepo.count.mockResolvedValue(20); + wagonRepo.find.mockResolvedValue(availableWagons(3)); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 3, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toEqual(['w-0', 'w-1', 'w-2']); + }); + + it('leaves the picks null on a plain count request', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toBeNull(); + }); + + it('refuses picks that are not available in the source yard', async () => { + wagonRepo.count.mockResolvedValue(20); + // Sitting in another yard — the requester's list is stale. + wagonRepo.find.mockResolvedValue([ + { ...availableWagons(1)[0], currentYardId: 'yard-z' }, + ]); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + preferredWagonIds: ['w-0'], + }, + 'user-1', + ), + ).rejects.toThrow(/no longer available in the source yard/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses more picks than the requested quantity', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 2, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ), + ).rejects.toThrow(/picked 3 wagon\(s\) but are requesting 2/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + it('still refuses a same-yard move', async () => { await expect( service.createRequest( diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 6b63460ad..dc50a8742 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { WagonsService } from './wagons.service'; +/** + * A request as sent to clients: the entity plus the resolved wagon numbers for + * whatever the requester hand-picked, so the desk can name them without a + * second round trip. + */ +export interface TransferRequestView extends WagonTransferRequest { + preferredWagons?: Array<{ id: string; wagonNumber: string }>; +} + /** Bundled per-user activity: requests they touched + wagons they moved. */ export interface TransferHistory { requests: WagonTransferRequest[]; @@ -75,11 +84,15 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, but the - * count IS capped by what the source yard can hand over right now: a request - * may not exceed the AVAILABLE, uncoupled wagons of that type in the source - * yard (the same number the yard desk shows). A reason is mandatory and is - * shown on the OCC queue. + * Record a PENDING request. The count is capped by what the source yard can + * hand over right now: a request may not exceed the AVAILABLE, uncoupled + * wagons of that type in the source yard (the same number the yard desk + * shows). A reason is mandatory and is shown on the OCC queue. + * + * The requester may also name the wagons they want (`preferredWagonIds`). + * Those are validated against the source yard here so a bad pick is rejected + * at request time rather than surfacing at fulfilment, but they are only a + * preference — the wagons are not reserved and OCC may send others. */ async createRequest( dto: CreateTransferRequestDto, @@ -104,11 +117,15 @@ export class WagonTransferRequestsService { `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, ); } + + const preferredWagonIds = await this.validatePreferredWagons(dto); + const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, wagonTypeId: dto.wagonTypeId, quantity: dto.quantity, + preferredWagonIds, status: WagonTransferRequestStatus.Pending, requestedByUserId: userId ?? null, reason: dto.reason, @@ -118,6 +135,45 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } + /** + * Check the requester's hand-picked wagons against the source yard: each must + * exist, sit in that yard, match the requested type, be AVAILABLE and be + * uncoupled — the same conditions fulfilment will apply. Returns the + * de-duplicated ids, or null when the requester picked nothing. + */ + private async validatePreferredWagons( + dto: CreateTransferRequestDto, + ): Promise { + const ids = [...new Set(dto.preferredWagonIds ?? [])]; + if (ids.length === 0) return null; + + if (ids.length > dto.quantity) { + throw new BadRequestException( + `You picked ${ids.length} wagon(s) but are requesting ${dto.quantity} — pick at most ${dto.quantity}`, + ); + } + + const wagons = await this.wagonRepo.find({ where: { id: In(ids) } }); + if (wagons.length !== ids.length) { + throw new NotFoundException('One or more selected wagons not found'); + } + const unusable = wagons.filter( + (w) => + w.currentYardId !== dto.fromYardId || + w.wagonTypeId !== dto.wagonTypeId || + w.status !== WagonStatus.Available || + w.trainId != null, + ); + if (unusable.length) { + throw new BadRequestException( + `These wagons are no longer available in the source yard: ${unusable + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + return ids; + } + /** * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move * right now. Shown on the desk beside the outstanding count so staff see at a @@ -179,16 +235,51 @@ export class WagonTransferRequestsService { : 'r.createdAt'; qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); - return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); + const page = await paginateQuery(qb, { + page: query.page, + pageSize: query.pageSize, + }); + return { ...page, items: await this.withPreferredWagons(page.items) }; } - async findById(id: string): Promise { + /** + * Resolve `preferredWagonIds` into wagon numbers for a page of requests in a + * single query, so the desk can show WHICH wagons were asked for. Ids that no + * longer resolve (purged wagon) simply drop out — the column carries no FK. + */ + private async withPreferredWagons( + requests: WagonTransferRequest[], + ): Promise { + const ids = [ + ...new Set(requests.flatMap((r) => r.preferredWagonIds ?? [])), + ]; + if (ids.length === 0) return requests; + + const wagons = await this.wagonRepo.find({ + where: { id: In(ids) }, + select: { id: true, wagonNumber: true }, + }); + const byId = new Map(wagons.map((w) => [w.id, w.wagonNumber])); + + return requests.map((r) => { + const picked = r.preferredWagonIds ?? []; + if (picked.length === 0) return r; + return Object.assign(r, { + preferredWagons: picked + .filter((id) => byId.has(id)) + .map((id) => ({ id, wagonNumber: byId.get(id)! })), + }); + }); + } + + async findById(id: string): Promise { const request = await this.requestRepo.findOne({ where: { id }, relations: REQUEST_RELATIONS, }); if (!request) throw new NotFoundException(`Transfer request ${id} not found`); - return request; + const [view] = await this.withPreferredWagons([request]); + return view; } /** Wagons still owed on an open request. */ @@ -411,7 +502,7 @@ export class WagonTransferRequestsService { continue; } const remaining = this.remainingOn(request); - const wagons = await this.wagonRepo.find({ + const candidates = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, @@ -419,8 +510,19 @@ export class WagonTransferRequestsService { trainId: IsNull(), }, order: { wagonNumber: 'ASC' }, - take: remaining, }); + // Honour the requester's picks first — any that are still available in + // the yard go out ahead of the plain wagon-number order, and the rest of + // the instalment is topped up from whatever else is on hand. + const preferred = new Set(request.preferredWagonIds ?? []); + const wagons = ( + preferred.size + ? [ + ...candidates.filter((w) => preferred.has(w.id)), + ...candidates.filter((w) => !preferred.has(w.id)), + ] + : candidates + ).slice(0, remaining); if (wagons.length === 0) { skipped.push({ id, @@ -479,7 +581,7 @@ export class WagonTransferRequestsService { }); return { - requests, + requests: await this.withPreferredWagons(requests), movements, meta: { page: page ?? 1, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index d90fe7b80..1798a8d5f 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; +/** + * Audit trail access. + * + * Read-only by design: `audit_logs` rows are written solely by + * `AuditInterceptor` and the module exposes no create/update/delete route, so + * there is deliberately no `:create` / `:update` / `:delete` twin to grant. + * The matching `:read` key is derived automatically by + * `deriveReadPermissions` below. + */ +const AUDIT_LOG_PERMISSIONS = [ + { + id: "52b2cdef-5313-4954-8c69-0f8c58ea2c1e", + key: "edr_freight_app:audit_log:view", + name: { am: "የኦዲት መዝገብ ይመልከቱ", en: "View audit logs" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, @@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS, + ...AUDIT_LOG_PERMISSIONS, ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 4728cf5f3..57bbda267 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1622,11 +1622,24 @@ export const FREIGHT_PERMS = { dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", + /** + * Edit a schedule's operational run numbers (train + voyage) before + * dispatch. Separate from `update`: these numbers are what yards and + * customs quote, so changing them is narrower than general scheduling edits. + */ + editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number", }, fleet: { view: "edr_freight_app:fleet:view", manage: "edr_freight_app:fleet:manage", }, + /** + * Audit trail. View-only: the module has no write routes, so this is the + * only key it needs — see AUDIT_LOG_PERMISSIONS in edr-freight.seed.ts. + */ + auditLog: { + view: "edr_freight_app:audit_log:view", + }, admin: "edr_freight_app:admin", ruleEngine: { view: (slug: RuleEngineResourceSlug) => @@ -2124,6 +2137,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.dispatch, FREIGHT_PERMS.trainScheduling.markPaid, FREIGHT_PERMS.trainScheduling.expireBooking, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, @@ -2288,6 +2302,7 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 09901b502..81df21a17 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { + Application, Organization, Permission, Position, @@ -8,7 +9,23 @@ import { } from '@tria-plc/iamapi-common'; import { DataSource, EntityManager, In } from 'typeorm'; -import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; +import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; + +/** + * Turn a permission key into a readable fallback name for a row this seeder has + * to mint itself: `edr_freight_app:train_scheduling:edit_train_number` becomes + * "Train scheduling: edit train number". Only used for keys absent from the + * catalog — a key that IS catalogued keeps its curated Amharic/English name. + */ +const nameForKey = (key: string): { en: string } => { + const [, ...rest] = key.split(':'); + const [resource, ...action] = rest; + const humanize = (s: string) => s.replace(/_/g, ' '); + const label = action.length + ? `${humanize(resource)}: ${humanize(action.join(' '))}` + : humanize(resource); + return { en: label.charAt(0).toUpperCase() + label.slice(1) }; +}; const SEED_FLAG = 'SEED_EDR_ORG'; const EDR_ORG_KEY = 'edr_freight'; @@ -83,7 +100,19 @@ export class FreightPositionsSeeder { ); } - /** Resolve every permission key referenced by any position to its id. */ + /** + * Resolve every permission key referenced by any position to its id, minting + * the rows that do not exist yet. + * + * The position presets draw from FREIGHT_PERMS (the registry), which is + * broader than the EDR_FREIGHT_PERMISSIONS catalog EdrOrgSeeder inserts — + * module keys like `train_scheduling:*` live only in the registry. So every + * newly-added preset key would otherwise abort boot with + * `missing_permissions:` until someone hand-inserted it. Ensuring them + * here keeps this seeder self-sufficient: it declares the keys it needs, so + * it is the one that guarantees they exist. Same approach, and the same + * id-less insert reasoning, as FreightNotificationPermissionsSeeder. + */ private async loadPermissionIds( manager: EntityManager, ): Promise> { @@ -91,16 +120,54 @@ export class FreightPositionsSeeder { ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), ]; - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(keys) }, - select: { id: true, key: true }, - }); - - const map = new Map(permissions.map((p) => [p.key, p.id as string])); + const read = async () => { + const rows = await manager.getRepository(Permission).find({ + where: { key: In(keys) }, + select: { id: true, key: true }, + }); + return new Map(rows.map((p) => [p.key, p.id as string])); + }; + let map = await read(); const missing = keys.filter((key) => !map.has(key)); - if (missing.length > 0) { - throw new Error(`missing_permissions:${missing.join(',')}`); + if (missing.length === 0) { + return map; + } + + const application = await manager.getRepository(Application).findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, + select: { id: true }, + }); + if (!application?.id) { + throw new Error(`missing_application:${EDR_FREIGHT_APPLICATION.key}`); + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id already owned by a retired key would slip past + // ON CONFLICT (key) and die on the PK. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + missing.map((key) => ({ + key, + name: nameForKey(key), + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + this.logger.log( + `Seeded ${missing.length} permission(s) referenced by positions but absent from the catalog: ${missing.join(', ')}`, + ); + + map = await read(); + const stillMissing = keys.filter((key) => !map.has(key)); + if (stillMissing.length > 0) { + throw new Error(`missing_permissions:${stillMissing.join(',')}`); } return map; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d221aaad..8c97dcc08 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -41,6 +41,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; +import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; @@ -188,6 +189,7 @@ const App = () => { } /> } /> } /> + } /> {/* Dev/testing page for the mock AI booking assistant. */} {trainLabel ? ( ) : ( )} + {isRequested && trainLabel ? ( + + {hasDeparted + ? "This train has already departed — accepting the operation will not place the booking on it." + : "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."} + + ) : null} + {schedule?.status ? ( @@ -231,10 +254,15 @@ export function BookingSchedulingWindowCard({ formatStamp(schedule.scheduledDepartureDate) ?? "—" } + tone={isRequested && hasDeparted ? "danger" : undefined} hint={ schedule.actualDepartureAt ? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}` - : "Planned" + : departureIso && !hasDeparted + ? `Planned · departs ${formatRelative(departureIso, nowMs)}` + : hasDeparted + ? "Planned — already past" + : "Planned" } /> void; }) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null, ); @@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({ Release Order - + { - if (!file || !vesselDate) return; + if (files.length === 0 || !vesselDate) return; setLoading(true); try { const iso = vesselDate.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index dfe0bf0e4..95dbc787e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -10,11 +10,10 @@ import { toIsoDate, useDoCollectionDates, } from "@/components/contracts/DoCollectionDateFields"; -import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; -import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; +import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; -import type { Freight } from "@edr/types"; +import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types"; export type GlClearanceUploadKind = "do" | "ro"; @@ -44,9 +43,8 @@ export function GlClearanceUploadModal({ vesselArrivalDate, doCollectedDate, onSuccess, - onPreview, }: GlClearanceUploadModalProps) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); @@ -66,16 +64,16 @@ export function GlClearanceUploadModal({ const isDo = kind === "do"; const isRo = kind === "ro"; const replaceMode = isDo - ? Boolean(findWorkflowFile(workflowFiles, "delivery_order")) - : Boolean(findWorkflowFile(workflowFiles, "release_order")); + ? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file) + : workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file); const close = () => { - setFile(null); + setFiles([]); onClose(); }; const submit = async () => { - if (!file || !kind) return; + if (files.length === 0 || !kind) return; if (isRo && !vesselDate) { toast.error("Vessel departure date is required."); return; @@ -93,23 +91,23 @@ export function GlClearanceUploadModal({ doCollectedDate: toIsoDate(doDates.doCollected)!, }; if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); + await bookingsService.uploadDeliveryOrder(entityId, files, dates); } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); + await contractsService.uploadDeliveryOrder(entityId, files, dates); } toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); } else { const iso = vesselDate!.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); } } - setFile(null); + setFiles([]); onSuccess?.(); close(); } catch (e) { @@ -152,14 +150,15 @@ export function GlClearanceUploadModal({ )} - @@ -170,7 +169,7 @@ export function GlClearanceUploadModal({ color="edr-green" loading={loading} disabled={ - !file || + files.length === 0 || (isRo && !vesselDate) || (isDo && !doDatesComplete(doDates)) } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 926637db2..4b44edda6 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -34,12 +34,15 @@ import { Truck, Upload, } from "lucide-react"; -import type { Freight } from "@edr/types"; +import { + deliveryOrderFileLabel, + isDeliveryOrderFileCode, + type Freight, +} from "@edr/types"; import toast from "react-hot-toast"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper"; -import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField"; import { DoCollectionDateFields, doDatesComplete, @@ -2133,56 +2136,82 @@ function DeliveryOrderStep({ onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; }) { - const [files, setFiles] = useState>({ - delivery_order: null, - }); + const [files, setFiles] = useState([]); const [doDates, setDoDates] = useDoCollectionDates({ vesselArrivalDate, doCollectedDate, }); const [loading, setLoading] = useState(false); - const hasFile = Boolean(files.delivery_order); + + const submit = async () => { + if (files.length === 0 || !doDatesComplete(doDates)) return; + const dates = { + vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, + doCollectedDate: toIsoDate(doDates.doCollected)!, + }; + setLoading(true); + try { + if (isBooking) { + await bookingsService.uploadDeliveryOrder(entityId, files, dates); + } else { + await contractsService.uploadDeliveryOrder(entityId, files, dates); + } + setFiles([]); + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }; return ( - setFiles((prev) => ({ ...prev, [key]: file }))} - workflowFiles={workflowFiles} - replaceMode={replaceMode} - loading={loading} - disabled={!hasFile || !doDatesComplete(doDates)} - helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected." - submitLabel={replaceMode ? "Replace DO" : "Upload DO"} - extraFields={ - - } - onViewFile={onViewFile} - onDownloadFile={onDownloadFile} - onSubmit={async () => { - const file = files.delivery_order; - if (!file || !doDatesComplete(doDates)) return; - const dates = { - vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, - doCollectedDate: toIsoDate(doDates.doCollected)!, - }; - setLoading(true); - try { - if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); - } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); - } - setFiles({ delivery_order: null }); - toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); - onChanged?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Upload failed"); - } finally { - setLoading(false); + + + Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the + DO was collected. Add as many files as needed. + + + {workflowFiles + .filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file) + .map((wf, index) => ( + + ))} + + + + + accept="*/*" + value={files} + onChange={setFiles} + replaceMode={replaceMode} + disabled={loading} + /> + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 726711a8b..307cb10aa 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -8,6 +8,7 @@ import { FileSignature, FileText, Hammer, + History, Landmark, LayoutDashboard, LayoutGrid, @@ -500,6 +501,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] FREIGHT_PERMS.settings.supportContent.manage, ], }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.auditLog.view, + }, { label: "Configuration", href: "/dashboard/configuration", diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 21033e7fe..23c6fb220 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain const handleBuild = async () => { if (!trainName.trim()) { toast({ - title: "Enter the vogue number", + title: "Enter the voyage number", variant: "destructive", }); return; @@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain wagons are attached on the next screen. setTrainName(e.currentTarget.value)} maxLength={100} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx index 27122d68c..641903b58 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx @@ -1,7 +1,9 @@ import { useMemo } from "react"; +import { Link } from "react-router-dom"; import { ArrowRight, Landmark, Package } from "lucide-react"; import { Accordion, + Anchor, Badge, Button, Checkbox, @@ -50,9 +52,20 @@ function EligibleBookingRow({ - + {/* Opens the booking in a new tab: the row is a selection control in + an allocation flow, so navigating away would lose staff's picks. */} + e.stopPropagation()} + > {booking.reference} - + {resolvedFreightType ? ( {resolvedFreightType} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx new file mode 100644 index 000000000..a713918b4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx @@ -0,0 +1,344 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Loader, + Modal, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { + ArrowRight, + Ban, + CircleAlert, + Merge, + Search, + TriangleAlert, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +const fmtDate = (iso: string) => + new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + +export interface MergeScheduleTrainModalProps { + scheduleId: string | null; + /** This schedule's current train — excluded from the picker. */ + currentTrainId: string | null; + scheduleReference?: string | null; + opened: boolean; + onClose: () => void; + onMerged?: () => void; +} + +/** + * Merge another train into this schedule. + * + * This schedule always survives: its train set is repointed at the chosen + * train, that train's wagons join this consist, and the emptied train is + * deactivated. When the chosen train also runs a schedule on the SAME DAY, that + * schedule's bookings move here and it is removed — its other-day schedules + * gain the wagons only. The server computes all of that in `previewMerge`, so + * the summary below is exactly what the commit will perform. + */ +export default function MergeScheduleTrainModal({ + scheduleId, + currentTrainId, + scheduleReference, + opened, + onClose, + onMerged, +}: MergeScheduleTrainModalProps) { + const { toast } = useToast(); + const [selectedTrainId, setSelectedTrainId] = useState(null); + const [search, setSearch] = useState(""); + const [reason, setReason] = useState(""); + + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: opened, + }); + + // The schedule's own train cannot be merged into itself. + const options = useMemo(() => { + const q = search.trim().toLowerCase(); + return trains + .filter((t) => t.id !== currentTrainId) + .filter((t) => + q + ? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}` + .toLowerCase() + .includes(q) + : true, + ); + }, [trains, currentTrainId, search]); + + const { data: preview, isFetching: previewLoading } = useQuery({ + ...api.trainScheduling.previewScheduleMerge.queryOptions({ + input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" }, + }), + enabled: opened && Boolean(scheduleId && selectedTrainId), + }); + + const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions()); + + const close = () => { + setSelectedTrainId(null); + setSearch(""); + setReason(""); + onClose(); + }; + + const submit = async () => { + if (!scheduleId || !selectedTrainId || !preview?.canMerge) return; + try { + await merge.mutateAsync({ + id: scheduleId, + targetTrainId: selectedTrainId, + ...(reason.trim() ? { reason: reason.trim() } : {}), + }); + toast({ title: "Trains merged" }); + onMerged?.(); + close(); + } catch (err) { + toast({ + title: "Merge failed", + description: parseError(err, "Could not merge the trains"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Merge another train into this one + + + {scheduleReference ?? "This departure survives the merge"} + + + + } + > + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + radius="md" + /> + + {trainsLoading ? ( + + + + ) : options.length === 0 ? ( + + No other trains available to merge. + + ) : ( + + {options.map((t) => { + const on = t.id === selectedTrainId; + return ( + setSelectedTrainId(t.id)} + style={{ + cursor: "pointer", + borderColor: on + ? "var(--mantine-color-edr-green-5)" + : undefined, + background: on + ? "var(--mantine-color-edr-green-0)" + : undefined, + }} + > + + {t.code} + + + {t.trainNumber ? `No. ${t.trainNumber}` : "—"} + + + ); + })} + + )} + + {selectedTrainId && previewLoading ? ( + + + + ) : null} + + {selectedTrainId && preview && !previewLoading ? ( + + {preview.blockers.length ? ( + } + title="This merge is blocked" + > + + {preview.blockers.map((b) => ( + + {b} + + ))} + + + ) : ( + } + > + This cannot be undone. Wagons are appended last — reorder them + afterwards in the train builder. + + )} + + + + + {preview.wagons.current} wagons + + + + {preview.wagons.merged} wagons + + + +{preview.wagons.incoming} from {preview.targetTrain.code} + + + + {preview.absorbedSchedule ? ( + + + {fmtDate(preview.absorbedSchedule.scheduledDepartureDate)} + + + {preview.absorbedSchedule.reference ?? "Same-day schedule"} —{" "} + + {preview.absorbedSchedule.bookingsMoving} booking(s) + {" "} + move here, then it is removed + + + ) : null} + + {preview.affectedSchedules.map((s) => ( + + + {fmtDate(s.scheduledDepartureDate)} + + + {s.reference ?? s.id.slice(0, 8)} — gains the wagons, keeps + its own bookings + + + ))} + + {preview.untouchedSchedules.map((s) => ( + + + {fmtDate(s.scheduledDepartureDate)} + + + {s.reference ?? s.id.slice(0, 8)} — {s.status.toLowerCase()}, + not affected + + + ))} + + {preview.sourceTrainWillDeactivate ? ( + + + + This schedule's current train is emptied and deactivated. + + + ) : null} + + + {preview.canMerge ? ( +