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/docs/audit-endpoints.md b/apps/edr-freight-api/docs/audit-endpoints.md new file mode 100644 index 000000000..38e89e367 --- /dev/null +++ b/apps/edr-freight-api/docs/audit-endpoints.md @@ -0,0 +1,922 @@ +# Freight API — Mutating Endpoints (Audit Surface) + +Every state-changing route in `apps/edr-freight-api` — `POST`, `PUT`, `PATCH`, `DELETE`. +This is the candidate surface for audit logging: each row is an action a user can take +that changes persisted state and therefore needs a who / what / when trail. + +All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`). +Titles come from each route's `@ApiOperation({ summary })`; where a route has none, +the title is derived from its handler name. + +> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in +> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete. + +## Summary + +| Method | Count | +| --- | ---: | +| `POST` | 351 | +| `PUT` | 8 | +| `PATCH` | 72 | +| `DELETE` | 61 | +| **Total** | **492** | + +Across **66** entities. + +## Entity index + +| Entity | Endpoints | +| --- | ---: | +| [Account](#account) | 3 | +| [AI Assist](#ai-assist) | 1 | +| [Approval Rule](#approval-rule) | 5 | +| [Booking](#booking) | 57 | +| [Cargo](#cargo) | 6 | +| [Cargo Type](#cargo-type) | 5 | +| [Company](#company) | 30 | +| [Compliance](#compliance) | 3 | +| [Consignment](#consignment) | 1 | +| [Container](#container) | 5 | +| [Container Type](#container-type) | 5 | +| [Contract](#contract) | 68 | +| [Contract Template](#contract-template) | 8 | +| [Driver](#driver) | 5 | +| [Dropdown Setting](#dropdown-setting) | 7 | +| [EIMS Invoice](#eims-invoice) | 3 | +| [Exchange Setting](#exchange-setting) | 1 | +| [Facility](#facility) | 3 | +| [Fayda Verification](#fayda-verification) | 1 | +| [File Upload Setting](#file-upload-setting) | 7 | +| [First Mile](#first-mile) | 7 | +| [Fuel](#fuel) | 1 | +| [GPS Tracking](#gps-tracking) | 3 | +| [Import Operation](#import-operation) | 9 | +| [Incident](#incident) | 3 | +| [Interchange Document](#interchange-document) | 3 | +| [Last Mile](#last-mile) | 10 | +| [Last Mile Request](#last-mile-request) | 4 | +| [Locomotive](#locomotive) | 4 | +| [Maintenance](#maintenance) | 13 | +| [Notification Inbox](#notification-inbox) | 2 | +| [Organization User](#organization-user) | 2 | +| [OTP](#otp) | 2 | +| [Password Reset](#password-reset) | 4 | +| [Payment](#payment) | 7 | +| [Priority Config](#priority-config) | 5 | +| [Priority Rule Change Request](#priority-rule-change-request) | 3 | +| [Procurement](#procurement) | 8 | +| [Rate](#rate) | 5 | +| [Rate Change Request](#rate-change-request) | 3 | +| [Route](#route) | 4 | +| [Schedule](#schedule) | 3 | +| [Service Type](#service-type) | 5 | +| [Shipping Line](#shipping-line) | 3 | +| [Signature](#signature) | 1 | +| [Support Chat](#support-chat) | 5 | +| [Support Content](#support-content) | 3 | +| [Train](#train) | 3 | +| [Train Build](#train-build) | 11 | +| [Train Schedule](#train-schedule) | 48 | +| [Transit Agent](#transit-agent) | 3 | +| [Truck Type](#truck-type) | 3 | +| [User Trade Access](#user-trade-access) | 1 | +| [Vehicle](#vehicle) | 3 | +| [Wagon](#wagon) | 8 | +| [Wagon Transfer Request](#wagon-transfer-request) | 5 | +| [Wagon Type](#wagon-type) | 3 | +| [Warehouse](#warehouse) | 12 | +| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 | +| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 | +| [Warehouse Inventory](#warehouse-inventory) | 24 | +| [Warehouse Yard](#warehouse-yard) | 2 | +| [Warehouse Zone](#warehouse-zone) | 1 | +| [Weight Limit Rule](#weight-limit-rule) | 3 | +| [Yard](#yard) | 5 | +| [Yard Distance](#yard-distance) | 3 | + +--- + +## Endpoints by entity + +### Account + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` | +| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` | +| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` | + +### AI Assist + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` | + +### Approval Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` | +| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` | +| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` | +| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` | +| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` | + +### Booking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` | +| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` | +| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` | +| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` | +| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` | +| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` | +| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` | +| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` | +| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` | +| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` | +| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` | +| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` | +| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` | +| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` | +| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` | +| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` | +| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` | +| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` | +| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` | +| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` | +| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` | +| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` | +| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` | +| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` | +| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` | +| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` | +| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` | +| Add a customer self-haul truck carrying 1–2 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` | +| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` | +| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` | +| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` | +| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` | +| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` | +| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` | +| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` | +| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` | +| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` | +| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` | +| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` | +| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` | +| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` | +| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` | +| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` | +| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` | +| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` | +| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` | +| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` | +| 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` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` | +| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` | +| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` | +| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` | +| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` | +| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` | +| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` | +| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` | + +### Cargo + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` | +| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` | +| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` | +| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` | +| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` | +| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` | + +### Cargo Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` | +| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` | +| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` | +| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` | +| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` | + +### Company + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` | +| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` | +| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` | +| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` | +| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` | +| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` | +| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` | +| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` | +| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` | +| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` | +| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` | +| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` | +| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` | +| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` | +| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` | +| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` | +| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` | +| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` | +| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` | +| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` | +| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` | +| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` | +| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` | +| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` | +| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` | +| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` | +| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` | +| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` | +| 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` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` | +| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` | + +### Compliance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` | +| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` | +| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` | + +### Consignment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` | + +### Container + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` | +| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` | +| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` | +| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` | +| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` | + +### Container Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` | +| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` | +| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` | +| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` | +| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` | + +### Contract + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` | +| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` | +| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` | +| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` | +| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` | +| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` | +| 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` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` | +| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` | +| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` | +| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` | +| 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` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` | +| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` | +| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` | +| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` | +| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` | +| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` | +| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` | +| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` | +| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` | +| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` | +| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` | +| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` | +| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` | +| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` | +| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` | +| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` | +| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` | +| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` | +| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` | +| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` | +| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` | +| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` | +| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` | +| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` | +| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` | +| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` | +| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` | +| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` | +| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` | +| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` | +| 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` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` | +| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` | +| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` | +| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` | +| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` | +| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` | +| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` | +| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` | +| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` | +| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` | +| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` | +| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` | +| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` | +| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` | +| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` | +| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` | +| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` | +| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` | +| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` | +| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` | +| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` | +| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` | +| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` | +| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` | +| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` | +| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` | + +### Contract Template + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` | +| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` | +| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` | +| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` | +| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` | +| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` | +| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` | +| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` | + +### Driver + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` | +| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` | +| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` | +| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` | +| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` | + +### Dropdown Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` | +| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` | +| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` | +| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` | +| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` | +| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` | +| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` | + +### EIMS Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` | +| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` | +| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` | + +### Exchange Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` | + +### Facility + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` | +| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` | +| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` | + +### Fayda Verification + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` | + +### File Upload Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` | +| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` | +| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` | +| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` | +| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` | +| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` | +| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` | + +### First Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` | +| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` | +| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` | +| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` | +| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` | +| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` | + +### Fuel + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` | + +### GPS Tracking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` | +| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` | +| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` | + +### Import Operation + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` | +| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` | +| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` | +| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` | +| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` | +| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` | +| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` | +| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` | +| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` | + +### Incident + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` | +| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` | +| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` | + +### Interchange Document + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` | +| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` | +| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` | + +### Last Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` | +| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` | +| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` | +| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` | +| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` | +| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` | +| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` | +| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` | +| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` | + +### Last Mile Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| 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` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` | +| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` | +| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` | +| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` | + +### Locomotive + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` | +| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` | +| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` | +| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` | + +### Maintenance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` | +| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` | +| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` | +| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` | +| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` | +| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` | +| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` | +| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` | +| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` | +| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` | +| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` | +| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` | +| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` | + +### Notification Inbox + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` | +| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` | + +### Organization User + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` | +| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` | + +### OTP + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` | +| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` | + +### Password Reset + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` | +| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` | +| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` | +| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` | + +### Payment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` | +| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` | +| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` | +| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` | +| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` | +| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` | +| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` | + +### Priority Config + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` | +| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` | +| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` | +| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` | +| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` | + +### Priority Rule Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` | +| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` | +| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` | + +### Procurement + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` | +| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` | +| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` | +| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` | +| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` | +| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` | +| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` | +| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` | + +### Rate + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` | +| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` | +| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` | +| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` | +| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` | + +### Rate Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` | +| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` | +| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` | + +### Route + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` | +| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` | +| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` | +| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` | + +### Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` | +| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` | +| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` | + +### Service Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` | +| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` | +| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` | +| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` | +| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` | + +### Shipping Line + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` | +| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` | +| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` | + +### Signature + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` | + +### Support Chat + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` | +| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` | +| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` | +| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` | +| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` | + +### Support Content + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` | +| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` | +| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` | + +### Train + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` | +| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` | +| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` | + +### Train Build + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` | +| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` | +| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` | +| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` | +| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` | +| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` | +| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` | +| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` | +| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` | +| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` | +| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` | + +### Train Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` | +| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` | +| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` | +| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` | +| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` | +| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` | +| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` | +| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` | +| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` | +| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` | +| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` | +| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` | +| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` | +| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` | +| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` | +| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` | +| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` | +| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` | +| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` | +| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` | +| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` | +| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` | +| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` | +| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` | +| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` | +| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` | +| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` | +| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` | +| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` | +| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` | +| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` | +| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` | +| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` | +| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` | +| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` | +| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` | +| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` | +| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` | +| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` | +| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` | +| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` | +| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` | +| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` | +| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` | +| 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` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` | +| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` | +| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` | +| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` | + +### Transit Agent + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` | +| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` | +| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` | + +### Truck Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` | +| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` | +| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` | + +### User Trade Access + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` | + +### Vehicle + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` | +| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` | +| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` | + +### Wagon + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` | +| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` | +| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` | +| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` | +| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` | +| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` | +| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` | +| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` | + +### Wagon Transfer Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` | +| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` | +| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` | +| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` | +| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` | + +### Wagon Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` | +| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` | +| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` | + +### Warehouse + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` | +| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` | +| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` | +| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` | +| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` | +| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` | +| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` | +| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` | +| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` | +| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` | +| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` | +| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` | + +### Warehouse Fee Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` | +| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` | +| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` | +| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` | +| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` | + +### Warehouse Inspection Report + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` | +| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` | +| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` | + +### Warehouse Inventory + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` | +| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` | +| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` | +| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` | +| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` | +| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` | +| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` | +| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` | +| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` | +| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` | +| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` | +| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` | +| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` | +| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` | +| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` | +| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` | +| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` | +| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` | +| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` | +| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` | +| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` | +| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` | +| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` | +| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` | + +### Warehouse Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` | +| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` | + +### Warehouse Zone + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` | + +### Weight Limit Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` | +| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` | +| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` | + +### Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` | +| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` | +| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` | +| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` | +| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` | + +### Yard Distance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` | +| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` | +| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` | + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index fb6a0bd31..f3ba897a0 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -60,7 +60,6 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", - "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..3bab613fb 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,7 +16,6 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; -import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -49,6 +48,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -112,8 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; -import { AuditModule } from "./modules/audit/audit.module"; -import { LoggerMiddleware } from "./logger.middleware"; +import { RequestLogMiddleware } from "@edr/api-common"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -169,19 +168,6 @@ if (!process.env.APPLICATION_NAME) { return dataSource; }, }), - // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). - // Must come after TypeOrmModule above so it picks up this app's DataSource. - // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL - // does: the dev broker only provisions the `edr` user on the `payment` - // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset - // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). - MezgebModule.forRoot({ - applicationName: "freight-api", - rmqUrl: - process.env.RABBITMQ_URL ?? - process.env.PAYMENT_RABBITMQ_URL ?? - "amqp://localhost:5672", - }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -221,6 +207,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + StampSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, @@ -257,7 +244,6 @@ if (!process.env.APPLICATION_NAME) { EimsModule, FleetHistoryModule, AiModule, - AuditModule, ], providers: [ EdrOrgSeeder, @@ -390,7 +376,9 @@ export class AppModule implements OnApplicationBootstrap { } configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware).forRoutes("*"); + // FIRST: opens the request log context every later middleware/guard/service + // writes into via logCtx(). Anything applied above it logs into the void. + consumer.apply(RequestLogMiddleware).forRoutes("*"); consumer .apply(LoginAudienceMiddleware) .forRoutes( 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/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts new file mode 100644 index 000000000..403b1a137 --- /dev/null +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -0,0 +1,146 @@ +import { Logger } from "@nestjs/common"; +import type { Repository } from "typeorm"; +import { + BaseRepository, + RequestLogMiddleware, + getLogContext, + logCtx, + runWithLogContext, +} from "@edr/api-common"; + +describe("logCtx", () => { + it("is a no-op outside a request", () => { + expect(() => logCtx({ bookingId: "b1" })).not.toThrow(); + expect(getLogContext()).toBeUndefined(); + }); + + it("collects data points across the request and isolates concurrent ones", async () => { + const collect = async (id: string) => + runWithLogContext({ requestId: id }, async () => { + logCtx({ bookingId: id }); + await Promise.resolve(); + logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" }); + logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" }); + logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx("PAID", { path: "payment.state", mode: "set" }); + logCtx({ ignored: true }, (ctx) => { + ctx.custom = "yes"; + }); + return getLogContext(); + }); + + const [a, b] = await Promise.all([collect("r1"), collect("r2")]); + + expect(a).toEqual({ + requestId: "r1", + bookingId: "r1", + booking: { status: { from: "DRAFT", to: "SUBMITTED" } }, + wagons: [{ wagonId: "w1" }, { wagonId: "w2" }], + smsSent: 2, + payment: { state: "PAID" }, + custom: "yes", + }); + expect(b?.requestId).toBe("r2"); + expect(b?.bookingId).toBe("r2"); + }); +}); + +describe("RequestLogMiddleware", () => { + it("emits one canonical JSON line carrying the collected context", () => { + // Raw stdout, not the Nest logger — the line must be parsable JSON with no + // "[Nest] … LOG [request]" prefix in front of it. + const lines: string[] = []; + jest.spyOn(process.stdout, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); + jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + + const listeners: Record void> = {}; + const req = { + method: "POST", + url: "/api/bookings/1/submit", + originalUrl: "/api/bookings/1/submit?dry=1", + baseUrl: "/api/bookings", + route: { path: "/:id/submit" }, + headers: { "user-agent": "jest", "x-request-id": "req-42" }, + ip: "10.0.0.1", + query: { dry: "1" }, + user: { id: "u-7" }, + }; + const res = { + statusCode: 409, + writableEnded: true, + setHeader: jest.fn(), + on: (event: string, fn: () => void) => { + listeners[event] = fn; + }, + }; + + new RequestLogMiddleware().use(req, res, () => { + logCtx({ bookingId: "b-1" }); + logCtx("REJECTED", { path: "booking.outcome", mode: "set" }); + }); + listeners.finish(); + listeners.close(); // aborts/close after finish must not double-log + + expect(lines).toHaveLength(1); + expect(lines[0].endsWith("\n")).toBe(true); + expect(lines[0].startsWith("{")).toBe(true); + expect(JSON.parse(lines[0])).toMatchObject({ + level: "warn", + logger: "request", + type: "http_request", + requestId: "req-42", + method: "POST", + route: "/api/bookings/:id/submit", + url: "/api/bookings/1/submit?dry=1", + status: 409, + userId: "u-7", + ip: "10.0.0.1", + userAgent: "jest", + query: { dry: "1" }, + bookingId: "b-1", + booking: { outcome: "REJECTED" }, + }); + expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); + jest.restoreAllMocks(); + }); +}); + +describe("BaseRepository write trail", () => { + class TestRepo extends BaseRepository<{ id: string; status?: string }> { + constructor(repo: Repository<{ id: string; status?: string }>) { + super(repo); + } + } + + const typeormRepo = { + metadata: { tableName: "booking" }, + create: (data: unknown) => data, + save: async (data: unknown) => data, + update: async () => undefined, + findOne: async () => ({ id: "b-1", status: "SUBMITTED" }), + softDelete: async () => undefined, + delete: async () => undefined, + } as unknown as Repository<{ id: string; status?: string }>; + + it("records creates, status changes and deletes without any service opting in", async () => { + const ctx = await runWithLogContext({}, async () => { + const repo = new TestRepo(typeormRepo); + await repo.create({ id: "b-1" }); + await repo.update("b-1", { status: "SUBMITTED" }); + await repo.update("b-1", { id: "b-1" }); // no status → no transition entry + await repo.softDelete("b-1"); + return getLogContext(); + }); + + expect(ctx).toEqual({ + db: { created: { booking: 1 }, updated: { booking: 2 } }, + statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }], + deleted: [{ entity: "booking", id: "b-1" }], + }); + }); +}); diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index af4a5b017..77f4b37e4 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; -import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; - -// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — -// the glob below only matches this app's own src/**/*.entity.ts. -const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions { entities: [ __dirname + "/../**/*.entity.{ts,js}", ...iamEntities, - ...auditEntities, ], migrations: [], }; diff --git a/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts new file mode 100644 index 000000000..023e4ac8c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts @@ -0,0 +1,99 @@ +import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder"; + +/** + * The EDR side of a contract is sealed with the ONE global company stamp, read + * live at render time; the client side keeps whatever stamp the customer + * uploaded. These specs pin that asymmetry — the standing rule is that + * centralizing the EDR seal must not touch customer stamps. + */ +describe("ContractViewModelBuilder.attachProviderStamp", () => { + const STAMP = "data:image/png;base64,RURS"; + + const build = (stampImageUrl: string | null = STAMP) => { + const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl); + const builder = Object.create( + ContractViewModelBuilder.prototype, + ) as ContractViewModelBuilder; + Object.assign(builder, { stampSettings: { getStampImageUrl } }); + return { builder, getStampImageUrl }; + }; + + const sig = (role: "STAFF" | "CUSTOMER", extra: Partial = {}) => + ({ + role, + signerDisplayName: `${role} signer`, + signedAt: "1 January 2026", + signatureImageUrl: "https://minio.local/sig.png", + ...extra, + }) as ContractSignatureView; + + it("stamps the EDR side with the global stamp", async () => { + const { builder } = build(); + const signatures = [sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(STAMP); + }); + + it("leaves the customer side untouched", async () => { + const { builder } = build(); + const customerStamp = "data:image/png;base64,Q1VTVA=="; + const signatures = [ + sig("CUSTOMER", { stampImageUrl: customerStamp }), + sig("STAFF"), + ]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(customerStamp); + expect(signatures[1]!.stampImageUrl).toBe(STAMP); + }); + + it("does not read the stamp at all when EDR has not signed yet", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("CUSTOMER")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).not.toHaveBeenCalled(); + expect(signatures[0]!.stampImageUrl).toBeUndefined(); + }); + + it("renders unstamped rather than failing when no stamp is configured", async () => { + const { builder } = build(null); + const signatures = [sig("STAFF")]; + + await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined(); + expect(signatures[0]!.stampImageUrl).toBeNull(); + }); + + it("reads the stamp once for every EDR signature row", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("STAFF"), sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).toHaveBeenCalledTimes(1); + expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]); + }); + + it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => { + const { builder } = build(); + Object.assign(builder, { + bookingsRepository: { + findContractSignatures: jest.fn().mockResolvedValue([ + { signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() }, + ]), + }, + }); + + const views = await ( + builder as unknown as { + loadSignatures(id: string): Promise; + } + ).loadSignatures("b-1"); + + expect(views[0]!.stampImageUrl).toBe(STAMP); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index d1158d035..94a6809e6 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -9,6 +9,7 @@ import { import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; +import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -16,6 +17,11 @@ export interface ContractSignatureView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + /** + * Round company seal shown beside the signature. Populated for the EDR + * (STAFF) side only, from the single global stamp — see attachProviderStamp. + */ + stampImageUrl?: string | null; } /** @@ -114,6 +120,7 @@ export class ContractViewModelBuilder { private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, + private readonly stampSettings: StampSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -194,7 +201,30 @@ export class ContractViewModelBuilder { private async loadSignatures(bookingId: string): Promise { const rows = await this.bookingsRepository.findContractSignatures(bookingId); - return rows.map((s) => this.toSignatureView(s)); + const views = rows.map((s) => this.toSignatureView(s)); + await this.attachProviderStamp(views); + return views; + } + + /** + * Stamp the EDR side of the contract with the ONE global company stamp + * (StampSettingsService) — staff never upload or pick a stamp, so nothing is + * stored per signature and the seal is read live at render time. The client + * side is left alone: a customer's own stamp is their business. + * + * Read live and deliberately not snapshotted, so replacing the company stamp + * re-seals contracts on their next render. `getStampImageUrl()` never throws + * and returns a data URL, which `signatures_block.hbs` renders as-is and the + * signature inliner skips. + */ + async attachProviderStamp(signatures: ContractSignatureView[]): Promise { + const staff = signatures.filter((s) => s.role === 'STAFF'); + if (staff.length === 0) return; + + const stampImageUrl = await this.stampSettings.getStampImageUrl(); + for (const sig of staff) { + sig.stampImageUrl = stampImageUrl; + } } toSignatureView(row: BookingContractSignature): ContractSignatureView { diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts deleted file mode 100644 index dd7532ec8..000000000 --- a/apps/edr-freight-api/src/logger.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; -import { Request, Response, NextFunction } from "express"; - -@Injectable() -export class LoggerMiddleware implements NestMiddleware { - private readonly logger = new Logger("HTTP"); - - use(req: Request, res: Response, next: NextFunction) { - const start = Date.now(); - - res.on("finish", () => { - const duration = Date.now() - start; - - this.logger.log( - `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, - ); - }); - - next(); - } -} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f26cd19e7..98fed950e 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,15 +10,17 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; -import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; /** - * JSON body ceiling. Signing posts the signature AND the company stamp as - * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is - * ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp - * image with a 413 "request entity too large". + * JSON body ceiling. Customer signing posts the signature AND the customer's + * own company stamp as base64 in one JSON body, and base64 inflates bytes by + * ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which + * rejected any real stamp image with a 413 "request entity too large". + * (Staff signing posts only a signature: EDR's seal is the one global stamp, + * read server-side. Uploading that stamp under Settings goes through this same + * ceiling, so the headroom is still needed on both counts.) * * Sized to clear the 50MB per-document ceiling * (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the @@ -167,11 +169,6 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); - // Audit listener: consumes the RMQ events MezgebModule's client interceptor - // (app.module.ts) emits and persists them via the AuditLogController / - // AuditLogCommandController @EventPattern handlers. Same queue config the - // client side uses, reused from the package so the two never drift apart. - app.connectMicroservice(getAuditLoggerConfig()); await app.startAllMicroservices(); const config = new DocumentBuilder() 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/3390000000000-RemoveGeneralManager.ts b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts new file mode 100644 index 000000000..055c3771b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts @@ -0,0 +1,210 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Onboarding revamp: one company, one verified identity. + * + * The general manager is removed outright — it named who to talk to and gated + * nothing — and the company's people become its **owner** (whoever the eTrade + * licence names as the business's manager) and its **Power of Attorney**. + * Exactly one of them is identity-verified, chosen by the company's own answer + * to "does anyone hold power of attorney for you?", stored as + * `attributes.poaDeclared`. + * + * The order below matters — each step depends on data a later step destroys: + * + * 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall + * through to `attributes->>'generalManagerEmail'`, and `companies.email` was + * only ever written for a Fayda-verified owner — so every foreign company + * had none and was reached solely through that fallback. Promote it to the + * column before the key is stripped, or those companies stop receiving mail + * in silence. + * 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required + * onboarding fields; without this every already-onboarded company would + * report three missing fields the moment it opened its settings page. + * 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It + * is gone, so the companies holding it must be re-expressed: + * - NOT a freight forwarder → "no PoA" (the owner represents themselves, + * nothing to delegate). Their PoA details are cleared. + * - A freight forwarder → "yes" and details KEPT. A forwarder signs on + * other companies' behalf, so a representative is non-negotiable and the + * waiver no longer exists. These companies will be asked for a + * delegation paper they were previously excused — an intentional, + * visible consequence, not an oversight. Count them before deploying. + * 4. Derive the declaration for everyone else, from whether PoA details exist. + * 5. Move drafts off the deleted "personnel" wizard step. + * 6. Only now drop the columns and strip the retired attribute keys. + * + * Irreversible by design: `down()` restores the columns' shape but cannot + * recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would + * be a guess. + */ +export class RemoveGeneralManager3390000000000 implements MigrationInterface { + name = "RemoveGeneralManager3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Rescue the notification address before the key it lives under is gone. + await queryRunner.query(` + UPDATE freight.companies + SET email = COALESCE( + NULLIF(email, ''), + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', '') + ) + WHERE COALESCE(email, '') = '' + `); + + // 2. Backfill the owner from the best source each company actually has: + // its Fayda-verified owner claims (already under owner*), then the general + // manager it named, then its contact person. A company with none of these + // never finished onboarding, and will be asked on its next visit. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes + || jsonb_strip_nulls(jsonb_build_object( + 'ownerName', COALESCE( + NULLIF(attributes->>'ownerName', ''), + NULLIF(attributes->>'generalManagerName', ''), + NULLIF(attributes->>'contactPersonName', '') + ), + 'ownerEmail', COALESCE( + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', ''), + NULLIF(email, '') + ), + 'ownerPhone', COALESCE( + NULLIF(attributes->>'ownerPhone', ''), + NULLIF(attributes->>'generalManagerPhone', ''), + NULLIF(attributes->>'contactPersonPhone', ''), + NULLIF(phone, '') + ) + )) + WHERE attributes IS NOT NULL + `); + + // 2b. Capture eTrade's manager for the owner-vs-licence check the + // backoffice now makes. Nothing stored it before, so the best we have is + // the owner name itself — which makes existing companies read as "matches" + // rather than as a false mismatch on data nobody ever compared. The value + // is refreshed for real on the company's next eTrade lookup. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName') + ) + WHERE COALESCE(attributes->>'ownerName', '') <> '' + AND attributes->>'etradeManagerName' IS NULL + AND COALESCE(licence_number, '') <> '' + `); + + // 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA". + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail' + - 'poaLocation' - 'poaAddress' - 'poaFaydaSub' + - 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender') + || jsonb_build_object('poaDeclared', 'no') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 3b. Forwarders keep their representative and lose the waiver. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 4. Everyone else: "yes" if a representative was named or the company is a + // forwarder, "no" if it finished onboarding without one. A company still + // mid-onboarding is left unanswered — it will be asked, which is the point. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'yes') + WHERE c.attributes->>'poaDeclared' IS NULL + AND ( + COALESCE(c.attributes->>'poaName', '') <> '' + OR COALESCE(c.attributes->>'poaPhone', '') <> '' + OR COALESCE(c.attributes->>'poaEmail', '') <> '' + OR COALESCE(c.attributes->>'poaLocation', '') <> '' + OR COALESCE(c.attributes->>'poaAddress', '') <> '' + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + ) + `); + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'no') + WHERE c.attributes->>'poaDeclared' IS NULL + AND EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = c.id + AND ep.onboarding_completed = true + AND ep.deleted_at IS NULL + ) + `); + + // 5. The "personnel" (general manager) wizard step no longer exists; a + // draft resting on it would fall back to the very first step and make the + // customer walk the whole wizard again. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'owner' + WHERE onboarding_step = 'personnel' + `); + + // 6. Retire the general manager and the flag it shared the model with. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail' + - 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub' + - 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone' + - 'gmAddress' - 'gmBirthdate' - 'gmGender' + - 'poaSameAsOwner' + WHERE attributes IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS general_manager_name, + DROP COLUMN IF EXISTS general_manager_email, + DROP COLUMN IF EXISTS general_manager_phone + `); + } + + /** + * Restores the columns' shape only. The values, the `gm*` attributes and the + * `poaSameAsOwner` flag are not recoverable — this migration folded them into + * `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS general_manager_name varchar(100), + ADD COLUMN IF NOT EXISTS general_manager_email varchar(150), + ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20) + `); + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'personnel' + WHERE onboarding_step = 'owner' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts new file mode 100644 index 000000000..9556318f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see StampSettingsService / + * InvoiceDocumentService). Same single-row shape as exchange_settings; the + * app never inserts more than one row. + */ +export class StampSettings3400000000000 implements MigrationInterface { + name = "StampSettings3400000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.stamp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stamp_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`); + } +} 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 index a7c8782b2..1a07a5fe5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -1,32 +1,46 @@ -import { Controller, Get, Query } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +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 { 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'; -@ApiTags("audit") -@Controller("audit") -@BookingStaff(FREIGHT_PERMS.audit.view) +/** + * 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") - @ApiOperation({ summary: "List freight-api audit log commands" }) - @ApiQuery({ name: "skip", type: Number, required: false }) - @ApiQuery({ name: "take", type: Number, required: false }) - list(@Query("skip") skip?: string, @Query("take") take?: string) { - // Same fallback chain @tria-plc/auditlog's client interceptor uses to - // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) - // — reading it here instead of a hardcoded literal means this can't - // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME - // actually is at runtime. - const application = - process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; - return this.auditService.list( - application, - skip !== undefined ? parseInt(skip, 10) : undefined, - take !== undefined ? parseInt(take, 10) : undefined, - ); + @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 index 635973fc6..608af3672 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.module.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -1,13 +1,34 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuditController } from "./audit.controller"; -import { AuditService } from "./audit.service"; +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([AuditLogCommand])], + imports: [TypeOrmModule.forFeature([AuditLog])], controllers: [AuditController], - providers: [AuditService], + 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 index 04ea4beed..de7dfd956 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -1,70 +1,71 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; -import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; +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'; -export interface AuditLogListResult { - count: number; - items: AuditLogCommand[]; -} - -/** - * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's - * @BookingStaff — the package's own AuditLogCommandController (mounted at - * /api/audit-log-commands) ships with no guards at all, so it can't be used - * directly for a permission-gated UI. Query mirrors the package's - * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. - */ @Injectable() export class AuditService { - constructor( - @InjectRepository(AuditLogCommand) - private readonly auditLogCommandRepository: Repository, - ) {} + private readonly logger = new Logger(AuditService.name); - async list( - application: string, - skip = 0, - take = 10, - ): Promise { - const [items, count] = await this.auditLogCommandRepository - .createQueryBuilder("audit_log_commands") - .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", - { application }, - ) - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", - { status: "Commit" }, - ) - // Backoffice-only view: portal (customer-facing) writes carry the same - // request-header set by every axios call from that app — see - // login-audience.middleware.ts. Rows with no linked auditLog (child/ - // event commands with no request context) stay visible; they aren't - // attributable to any frontend, so they're not portal noise either. - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", - { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, - ) - .select([ - "audit_log_commands.id", - "audit_log_commands.createdAt", - "audit_log_commands.deletedAt", - "audit_log_commands.entityName", - "audit_log_commands.queryMethod", - "audit_log_commands.changes", - "audit_log_commands.payload", - "auditLog.id", - "auditLog.user", - ]) - .addOrderBy("audit_log_commands.createdAt", "DESC") - .skip(skip) - .take(take) - .getManyAndCount(); + constructor(private readonly auditLogRepository: AuditLogRepository) {} - return { count, items }; + /** + * 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/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 9a31466f0..6c7e8455d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -8,6 +8,7 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; +import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; @@ -951,6 +952,24 @@ export class BillingService { await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + // Every invoice status move in the app funnels through here — money + // changing state is the single most-asked question in support. + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + from: invoice.status, + to: status, + event, + amount: Number(invoice.totalAmount), + currency: invoice.currency, + paymentId: extra.paymentId ?? invoice.paymentId ?? undefined, + }, + { path: "invoiceTransitions", mode: "push" }, + ); + const updated = { ...invoice, ...extra, status } as Invoice; return { result: updated, @@ -1320,6 +1339,21 @@ export class BillingService { throw new BadRequestException("Invoice has no outstanding balance."); } + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + companyId: invoice.companyId, + amountDue, + currency: invoice.currency, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + }, + { path: "payment.payInvoice" }, + ); + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is // required up front (the payment service rejects it otherwise, as a 502 here). if ( @@ -1443,7 +1477,24 @@ export class BillingService { // first on DESC, which would hand back an unissued invoice. order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, }); - if (!invoice) return null; + if (!invoice) { + logCtx( + { paymentId, outcome: "no-invoice-for-payment" }, + { path: "payment.settleInvoice" }, + ); + return null; + } + + logCtx( + { + paymentId, + providerTxnId, + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + invoiceStatus: invoice.status, + }, + { path: "payment.settleInvoice" }, + ); const settleable: Freight.InvoiceStatus[] = [ ...OPEN_STATUSES, @@ -1453,6 +1504,12 @@ export class BillingService { // Already PAID is the ordinary idempotent no-op (redelivery, or settled // inline by payInvoice). Anything else means money was captured with // nowhere to land — that needs a person, so say so loudly. + logCtx( + invoice.status === Freight.InvoiceStatus.Paid + ? "already-paid" + : "captured-with-nowhere-to-land", + { path: "payment.settleInvoice.outcome", mode: "set" }, + ); if (invoice.status !== Freight.InvoiceStatus.Paid) { this.logger.error( `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts index c320a5d44..363cda3f0 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service"; * Standalone document infrastructure — generic HTML→PDF plus the shared * invoice/receipt renderer. Has no domain dependencies, so any module (billing, * warehouses, …) can import it to print invoices without coupling to the - * billing payment graph. + * billing payment graph. StampSettingsService is @Global (see + * StampSettingsModule) so InvoiceDocumentService can inject it without this + * module declaring an explicit import. */ @Module({ providers: [PdfRenderService, InvoiceDocumentService], diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 06d164bbd..268e94ad9 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; +import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; import { PdfRenderService } from "./pdf-render.service"; +import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; import { PdfColor, assembleSinglePagePdf, @@ -53,6 +55,13 @@ export interface InvoiceDocumentModel { totals: InvoiceDocumentTotal[]; /** Override the round seal text; defaults from kind/status. */ sealText?: string; + /** + * Company stamp image (data URL) to render instead of the plain text seal. + * Callers normally leave this unset — `InvoiceDocumentService.render()` + * fills it in from the single global stamp in StampSettingsService; set it + * explicitly only to override that default for one document. + */ + stampImageUrl?: string | null; } /** @@ -63,12 +72,21 @@ export interface InvoiceDocumentModel { */ @Injectable() export class InvoiceDocumentService { - constructor(private readonly pdf: PdfRenderService) {} + constructor( + private readonly pdf: PdfRenderService, + private readonly stampSettings: StampSettingsService, + ) {} async render( model: InvoiceDocumentModel, ): Promise<{ filename: string; buffer: Buffer }> { - const html = this.buildHtml(model); + const stampImageUrl = + model.stampImageUrl !== undefined + ? model.stampImageUrl + : await this.stampSettings.getStampImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + + const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, @@ -77,7 +95,11 @@ export class InvoiceDocumentService { // Chromium-less fallback: draw a genuine styled invoice (header, seal, // summary grid, line-item table, totals) from the model — not a flat // plain-text dump — so it still reads as a proper invoice document. - fallback: () => this.buildFallbackPdf(model), + // ponytail: still draws the plain vector seal, not the uploaded stamp + // image — embedding a raster image needs a new PDF XObject primitive + // in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to + // carry the real stamp too; today it's a rare degraded fallback. + fallback: () => this.buildFallbackPdf(resolvedModel), }), }; } @@ -218,6 +240,8 @@ export class InvoiceDocumentService { const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const sealInner = sealMarkup(model.stampImageUrl, sealText); + const sealCssClass = sealClass(model.stampImageUrl); const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -256,6 +280,7 @@ export class InvoiceDocumentService { .meta { text-align: right; font-size: 12px; color: #475569; } .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } + ${sealImageCss()} .summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; } .summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; } .summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; } @@ -283,7 +308,7 @@ export class InvoiceDocumentService { Issued: ${esc(date(model.issuedAt))} -
${esc(sealText)}
+
${sealInner}
${summaryRows}
diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts new file mode 100644 index 000000000..d07f8ebf6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts @@ -0,0 +1,78 @@ +import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; + +/** + * These three helpers are the single image-vs-text branch shared by every + * EDR document's round seal, so a regression here silently unstamps invoices, + * warehouse release papers and handover papers at once. + */ +describe("seal markup helpers", () => { + const STAMP = "data:image/png;base64,QUJD"; + + describe("sealMarkup", () => { + it("renders the stamp image when one is configured", () => { + expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe( + `Company stamp`, + ); + }); + + it("falls back to text rings when no stamp is configured", () => { + expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe( + "EDR
Warehouse
Cleared
", + ); + }); + + it("treats undefined as unset", () => { + expect(sealMarkup(undefined, "EDR")).toBe("EDR"); + }); + + it("accepts a bare string as a single line", () => { + expect(sealMarkup(null, "EDR")).toBe("EDR"); + }); + + it("escapes text lines so document data cannot inject markup", () => { + expect(sealMarkup(null, [''])).toBe( + "<script>alert("x")</script>", + ); + }); + + it("escapes the image src so it cannot break out of the attribute", () => { + expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe( + 'Company stamp', + ); + }); + }); + + describe("sealClass", () => { + it("adds the image modifier only when stamped", () => { + expect(sealClass(STAMP)).toBe("seal seal-image"); + expect(sealClass(null)).toBe("seal"); + }); + + it("honours a document's own seal selector", () => { + expect(sealClass(STAMP, "sig-stamp-box")).toBe( + "sig-stamp-box sig-stamp-box-image", + ); + expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box"); + }); + }); + + describe("sealImageCss", () => { + it("neutralizes the drawn ring and rotation for a real stamp image", () => { + const css = sealImageCss(); + + expect(css).toContain(".seal.seal-image { border: none;"); + expect(css).toContain("transform: none;"); + // The ::before pseudo-element draws the inner ring of the text seal. + expect(css).toContain(".seal.seal-image::before { content: none; }"); + expect(css).toContain(".seal img { max-width: 100%;"); + }); + + it("scopes every rule to the given selector", () => { + const css = sealImageCss("sig-stamp-box"); + + expect(css).not.toContain(".seal"); + expect(css).toContain(".sig-stamp-box.sig-stamp-box-image"); + expect(css).toContain(".sig-stamp-box img"); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts new file mode 100644 index 000000000..2dd9c4715 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts @@ -0,0 +1,64 @@ +/** + * The single decision every EDR document makes about its round seal: draw the + * one uploaded company stamp when one is configured (StampSettingsService), or + * fall back to the plain text rings the document styles itself. + * + * Only the image-vs-text branch and the image overrides live here — each + * document keeps its own `.seal` geometry (the invoice's seal is absolutely + * positioned top-right, the warehouse papers' sit inline above the signature + * lines), so centralizing the source of the stamp does not relayout anything. + * + * These helpers are for the HTML/Chromium render path. The hand-built vector + * fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to + * draw their vector seal — see InvoiceDocumentService for that caveat. + */ + +/** Escape a value for interpolation into HTML text or a quoted attribute. */ +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * CSS overrides that neutralize a document's own ring/rotation styling when the + * seal is a real stamp image. Append inside a document's @@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Cleared
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}
Customer or driver name / signature / date
@@ -5762,6 +5770,8 @@ export class WarehouseInventoryService { signerDisplayName: string; signatureImageUrl: string | null; } | null; + /** The one global company stamp; null falls back to the drawn text seal. */ + stampImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5839,6 +5849,7 @@ export class WarehouseInventoryService { .seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; } .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } + ${sealImageCss()} @@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Handover
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}
${approval?.signatureImageUrl ? `` : ''}
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 9cf1ef0cd..174a32341 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -157,9 +157,6 @@ async function main() { email: 'negad-indode-demo@edr.local', contactPersonName: 'Marshalling Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: 'negad-indode-demo@edr.local', - generalManagerPhone: '251900000202', }), )); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 2e234bcf0..91848dff4 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { website: null, contactPersonName: 'First Last Mile Demo', contactPersonPhone: '251900000101', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000101', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 20d2a3f8b..4f259c28c 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -324,9 +324,6 @@ export class DemoBookingsSeeder { website: null, contactPersonName: "Train Scheduling", contactPersonPhone: "251900000001", - generalManagerName: "Demo Manager", - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: "251900000001", }, { conflictPaths: { tin: true } }, ); 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/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 512229706..dd8f9bd20 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; @@ -154,6 +155,56 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // }, // ]; +/** + * Documents required from a co-operative union or farm — the third alternative + * to the two nationality sets, not an addition to them. A co-op is always + * registered in Ethiopia and has a TIN but no business licence, so its + * registration certificate stands in for the commercial registration every + * other Ethiopian company uploads. + * + * Admin-managed like every other onboarding set: what these members must + * actually produce is a backoffice decision, edited in the file-settings editor. + */ +const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + // First on purpose: only the first field of a new set is seeded, and this is + // the paper that distinguishes a co-operative from every other company. + { + fileKey: "cooperative_registration_certificate", + fileLabel: "Co-operative Union / Farm Registration Certificate", + helpText: + "Certificate issued by the co-operative promotion agency that registered the union or farm.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 1, + }, + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 2, + }, + { + fileKey: "national_id", + fileLabel: "National ID", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 3, + }, + poaDelegationDefault(4), +]; + interface OnboardingDocumentSetting { code: string; label: string; @@ -176,6 +227,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, + // The third set: a union or farm resolves here INSTEAD of a nationality set + // (it is always Ethiopian, and holds no business licence). + { + code: "company_onboarding_documents_cooperative", + label: "Co-operative union / farm onboarding documents", + entity: "customer", + fields: COOPERATIVE_ONBOARDING_FIELDS, + }, // Legacy per-company-type codes — removed, unused by any resolver or portal // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). // { @@ -606,16 +665,15 @@ export class FileUploadSettingsSeeder { async run() { const settingRepository = this.dataSource.getRepository(FileUploadSetting); - // Seed only into an empty table: any existing rows (including - // soft-deleted ones, which would still conflict on the unique `code`) - // mean the data is admin-managed, so leave it untouched. - const existing = await settingRepository.count({ withDeleted: true }); - if (existing > 0) { - this.logger.log( - `file_upload_settings already has ${existing} rows — skipping seed`, - ); - return; - } + // Seed per CODE, not "only into an empty table". An existing row is + // admin-managed and never touched — including a soft-deleted one, which + // means the set was removed on purpose (and would still conflict on the + // unique `code`). What the table-wide check got wrong is the other half: a + // set added to this file after the first boot could never reach a database + // that already held the others, so it existed in code and nowhere else. + const existingCodes = new Set( + (await settingRepository.find({ withDeleted: true })).map((s) => s.code), + ); const allSettings: Array< OnboardingDocumentSetting & { description: string } @@ -645,20 +703,41 @@ export class FileUploadSettingsSeeder { })), ]; - // Insert setting rows only — no FileUploadField rows. Fields start empty - // and are configured from the backoffice file-settings editor; the field - // definitions above are kept as reference defaults. - await settingRepository.insert( - allSettings.map((documentSetting) => ({ - code: documentSetting.code, - label: documentSetting.label, - description: documentSetting.description, - entity: documentSetting.entity, - })), + const missing = allSettings.filter((s) => !existingCodes.has(s.code)); + if (missing.length === 0) { + this.logger.log("file upload settings up to date — nothing to seed"); + return; + } + + const inserted = await settingRepository.save( + missing.map((documentSetting) => + settingRepository.create({ + code: documentSetting.code, + label: documentSetting.label, + description: documentSetting.description, + entity: documentSetting.entity, + }), + ), ); + // A brand-new set gets exactly ONE field: its first reference default. The + // rest of the list above stays documentation — what a set actually asks for + // is a backoffice decision, edited in the file-settings editor. Seeding one + // means a set is never born empty (an empty set silently requires nothing), + // while leaving the admin a single row to extend rather than a list to prune. + const fieldRepository = this.dataSource.getRepository(FileUploadField); + const firstFields = inserted.flatMap((setting) => { + const reference = missing.find((s) => s.code === setting.code)?.fields[0]; + return reference + ? [fieldRepository.create({ ...reference, settingId: setting.id })] + : []; + }); + if (firstFields.length > 0) await fieldRepository.save(firstFields); + this.logger.log( - `Seeded ${allSettings.length} file upload settings with empty fields`, + `Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing + .map((s) => s.code) + .join(", ")}`, ); } } 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 47fde9fb4..f0b719d63 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1187,6 +1187,32 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:dropdown:manage", "Manage dropdown settings", ), + perm( + "b4b00002-0001-4000-8000-000000000001", + "edr_freight_app:settings:stamp:view", + "View the company stamp", + ), + perm( + "b4b00002-0001-4000-8000-000000000002", + "edr_freight_app:settings:stamp:manage", + "Manage the company stamp", + ), + // The per-officer approval teeter (ማህተም) — an individual's own stamp + + // signature, not the company seal. It used to ride on settings:stamp:*, which + // now gates the ONE company stamp; this key was split out when the two were + // untangled. `settings:invoice_stamp:*` retired at the same time: it gated the + // company stamp before the fold and is deliberately left orphaned in any DB + // that already seeded it (the seeder upserts by key and never deletes). + perm( + "b4b00003-0001-4000-8000-000000000001", + "edr_freight_app:settings:teeter:view", + "View own approval teeter and signature", + ), + perm( + "b4b00003-0001-4000-8000-000000000002", + "edr_freight_app:settings:teeter:manage", + "Manage own approval teeter and signature", + ), perm( "b4c00001-0001-4000-8000-000000000001", "edr_freight_app:audit:view", @@ -1304,7 +1330,10 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "Edit contract templates & articles", ), // Granular split of contract-template access. `view` opens the sidebar page; - // `read` is API-read-only for other pages that display template data. + // `read` is API-read-only for other pages that display template data — and is + // NOT written out here: deriveReadPermissions mints the `:read` twin of every + // `:view` key, so a hand-written one duplicates the key (Postgres 21000 on the + // seeder's ON CONFLICT (key) insert) and carries a v4 id where twins are v5. perm( "b4e00001-0001-4000-8000-000000000003", "edr_freight_app:settings:contract_templates:create", @@ -1320,11 +1349,6 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:delete", "Delete bulk contract templates", ), - perm( - "b4e00001-0001-4000-8000-000000000006", - "edr_freight_app:settings:contract_templates:read", - "Read contract template data (API only)", - ), perm( "b4f00001-0001-4000-8000-000000000001", "edr_freight_app:settings:support_content:view", @@ -1627,11 +1651,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) => @@ -1876,6 +1913,19 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + // The ONE company stamp/seal, applied to every generated document + // (invoices, receipts, warehouse papers, the EDR side of contracts). + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, + // and NOT the company seal above. Retired: `invoiceStamp`, which used to + // gate the company stamp before the two were untangled. + teeter: { + view: "edr_freight_app:settings:teeter:view", + manage: "edr_freight_app:settings:teeter:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", @@ -1894,9 +1944,6 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:support_content:manage", }, }, - audit: { - view: "edr_freight_app:audit:view", - }, support: { agentView: "edr_freight_app:support:agent_view", agentSend: "edr_freight_app:support:agent_send", @@ -2132,6 +2179,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, @@ -2287,7 +2335,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.payments.view, ]), // Director additionally manages train scheduling + rail fleet (same block the - // operation officer/chief hold), on top of the approval-chain role preset. + // operation officer/chief hold), on top of the approval-chain role preset, + // and carries the same full warehouse authority the chief tier holds. director: dedupe([ ...ROLE_PERMISSION_PRESETS.director, FREIGHT_PERMS.trainScheduling.view, @@ -2296,9 +2345,22 @@ 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, + // Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher, + // the director also owns the allocation and fee rules themselves. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseAllocationRules), + ...Object.values(FREIGHT_PERMS.warehouseFeeRules), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), 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-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 654f4f77f..a658ec13d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder { website: null, contactPersonName: 'Paid Mile Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000202', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 00d5e0ddb..64f91da72 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -41,9 +41,9 @@ 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 AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS } from "./lib/permissions"; @@ -51,6 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -117,6 +118,20 @@ import { findActiveSidebarLabel, } from "@/components/layout/sidebar-sections"; +/** + * The per-shipment clearance detail page is the shared destination of three + * hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review), + * none of which are gated on `bookings:clearance_view`. Gating the detail on + * that key alone bounced reviewers back to their landing page (Bookings) the + * moment they opened a row, so accept any key that can reach a hub. + */ +const CLEARANCE_DETAIL_PERMS = [ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.opsClearanceReview, +]; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -189,6 +204,7 @@ const App = () => { } /> } /> } /> + } /> {/* Dev/testing page for the mock AI booking assistant. */} { + } @@ -321,9 +335,7 @@ const App = () => { + } @@ -773,14 +785,28 @@ const App = () => { } /> + {/* + The ONE company stamp, for every generated document. The per-officer + teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings + now lives at /user-management/teeter-and-signature — it is a different + thing (an individual's approval stamp), and pairing the two here was + the duplication. + */} - + + } /> + {/* Old URL kept alive so existing links/bookmarks do not 404. */} + } + /> String(group.Description ?? "").trim()) + // eTrade returns null entries in this array, not just a null array. + .map((group) => String(group?.Description ?? "").trim()) .filter(Boolean); const displayTradeName = diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx index 9b5cf91f2..7f5a4af23 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -142,6 +142,21 @@ export function BookingSchedulingWindowCard({ schedule?.reference ?? (schedule ? "Assigned train" : null); + // Before the batch engine allocates, the only train on the booking is the one + // the customer picked at day-commit — staff review that during Operation + // Review, so it is labelled as a request, not as a confirmed allocation. + const isRequested = schedule?.isRequested === true; + const trainRowLabel = isRequested ? "Requested train" : "Scheduled on train"; + + // A train that has already left (or is past its planned departure) can no + // longer carry this booking, so accepting onto it would be wrong. Called out + // here because this card sits above the staff-actions toolbar. + const departureIso = + schedule?.actualDepartureAt ?? schedule?.scheduledDepartureDate ?? null; + const hasDeparted = schedule?.actualDepartureAt + ? true + : departureIso !== null && new Date(departureIso).getTime() <= nowMs; + return ( {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/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index de8ff1953..d29e045c4 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -170,12 +170,13 @@ export function ContractCustomerCard({ /> - + {/* Whoever the eTrade licence names as the business's manager. */} + diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index d86e0079c..65c4f25e3 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -43,9 +43,17 @@ export const FIELD_LABELS: Record = { contactPersonPosition: "Contact position", contactPersonEmail: "Contact email", contactPersonPhone: "Contact phone", - generalManagerName: "General manager", - generalManagerEmail: "GM email", - generalManagerPhone: "GM phone", + ownerName: "Owner name", + ownerEmail: "Owner email", + ownerPhone: "Owner phone", + poaDeclared: "Has a Power of Attorney", + poaPassportNumber: "PoA passport number", + // Nothing writes these any more — the general manager was removed — but + // change requests filed before that still carry them, and without a label + // the reviewer sees a raw attribute key. + generalManagerName: "General manager (retired)", + generalManagerEmail: "GM email (retired)", + generalManagerPhone: "GM phone (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", @@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string { nationality: c.nationality, contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, - generalManagerName: c.generalManagerName ?? attrs.generalManagerName, - generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, - generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + ownerName: c.ownerName ?? attrs.ownerName, + ownerEmail: c.ownerEmail ?? attrs.ownerEmail, + ownerPhone: c.ownerPhone ?? attrs.ownerPhone, }; const v = key in map ? map[key] : (c[key] ?? attrs[key]); return v === null || v === undefined || v === "" ? "—" : String(v); diff --git a/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx new file mode 100644 index 000000000..b4a201fd1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx @@ -0,0 +1,112 @@ +import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface PersonField { + label: string; + value?: string | null; +} + +export interface PersonCardProps { + /** OWNER / POA / CONTACT PERSON — the person's role, not their name. */ + title: string; + icon?: ReactNode; + /** + * Identity state. `undefined` = this person has no identity check at all + * (contact person), so no badge is rendered rather than a misleading "not + * verified" one. + */ + verified?: boolean; + /** Extra pills after the verification badge (e.g. "Verifies for this company"). */ + badges?: ReactNode; + /** Rendered between the header and the fields — alerts, match warnings. */ + notice?: ReactNode; + fields: PersonField[]; + /** Shown when the API returned nothing for every field. */ + emptyMessage: string; + /** Attachments or anything else that belongs to this person. */ + children?: ReactNode; +} + +/** + * One person in the customer's people column: owner, power of attorney, contact + * person. Empty fields are dropped rather than rendered as "—", so a field the + * API stops sending simply disappears instead of leaving a dead row behind. + */ +export function PersonCard({ + title, + icon, + verified, + badges, + notice, + fields, + emptyMessage, + children, +}: PersonCardProps) { + const filled = fields.filter( + (f) => f.value != null && String(f.value).trim(), + ); + + return ( + + + + {icon} + + {title} + + {verified !== undefined && + (verified ? ( + + Fayda verified + + ) : ( + + Not verified + + ))} + {badges} + + + {notice} + + {filled.length > 0 ? ( + + {filled.map((f) => ( + + + {f.label} + + + {f.value} + + + ))} + + ) : ( + + {emptyMessage} + + )} + + {children && ( + <> + + {children} + + )} + + + ); +} + +export default PersonCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 81f25fcb2..daeb11311 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -24,4 +24,9 @@ export { type ResetPasswordActionProps, } from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { + PersonCard, + type PersonCardProps, + type PersonField, +} from "./PersonCard"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 9aa11043b..9b75f35f5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -184,13 +184,6 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage dropdown options used across the platform", }, }, - { - prefix: "/dashboard/audit-logs", - meta: { - title: "Audit Logs", - subtitle: "Request and entity-level activity recorded across the freight API", - }, - }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { 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 83e80d11e..95608b6db 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 @@ -19,6 +19,7 @@ import { PackageOpen, Paperclip, Receipt, + Stamp, ScrollText, Send, Settings, @@ -485,6 +486,15 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.settings.dropdown.view, }, + { + // One entry, one stamp. The former "Stamp settings" entry here pointed + // at the per-officer teeter (ማህተም), not a company seal — it moved to + // /user-management/teeter-and-signature. + label: "Company stamp", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", @@ -505,7 +515,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Audit logs", href: "/dashboard/audit-logs", icon: , - permission: FREIGHT_PERMS.audit.view, + permission: FREIGHT_PERMS.auditLog.view, }, { label: "Configuration", diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 0a70b3aec..b13ba4291 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; -import { FileSignature, Loader2, Stamp } from "lucide-react"; +import { FileSignature, Loader2 } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; -import { StampUpload } from "@/components/contracts/StampUpload"; import { Card, CardContent, @@ -27,9 +26,13 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in user view and update the reusable signature and company - * stamp stored on their profile — managed independently of each other. Both - * are offered when signing a booking contract. + * Lets the signed-in user view and update the reusable signature stored on + * their profile, offered for approval when signing a contract. + * + * Signature only — there is no per-employee stamp. EDR seals with ONE global + * company stamp, managed under Settings and applied server-side, so a staff + * member never uploads or picks a stamp. (Customers do upload their own, in + * the portal — that is a different card.) */ export function MySignatureCard() { const { user } = useAuth(); @@ -39,10 +42,8 @@ export function MySignatureCard() { const saveMutation = useMutation(api.signatures.save.mutationOptions()); const [signatureOpen, setSignatureOpen] = useState(false); - const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); - const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; @@ -60,7 +61,6 @@ export function MySignatureCard() { { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Stamp untouched — it is managed by its own dialog. }, { onSuccess: () => { @@ -72,38 +72,16 @@ export function MySignatureCard() { ); }; - const openStampDialog = () => { - setStampData(saved?.stampImageUrl ?? null); - setStampOpen(true); - }; - - const saveStamp = () => { - if (!stampData) return; - saveMutation.mutate( - { - signerDisplayName: savedName || defaultName, - // Signature untouched — stamp-only update. - stampImageBase64: stampData, - }, - { - onSuccess: () => { - toast.success("Stamp saved"); - setStampOpen(false); - }, - onError: () => toast.error("Failed to save stamp"), - }, - ); - }; - return ( - Signature & Stamp + Signature - This signature can be reused to sign booking contracts. + This signature can be reused to sign booking contracts. The EDR + company stamp is applied automatically — you do not upload one. @@ -112,54 +90,29 @@ export function MySignatureCard() {
) : ( - <> -
- {saved?.signatureImageUrl ? ( - <> -
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

- - ) : ( -

- You have not saved a signature yet. +

+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName}

- )} - -
- -
- {saved?.stampImageUrl ? ( - <> -
- My saved company stamp -
-

Company stamp

- - ) : ( -

- You have not uploaded a company stamp yet. -

- )} - -
- + + ) : ( +

+ You have not saved a signature yet. +

+ )} + +
)} @@ -203,39 +156,6 @@ export function MySignatureCard() { - - - - - Company stamp - - Upload your official company stamp or seal as an image. It is - stored on your profile and applied next to your signature on - contracts. - - - - - - - - - ); } 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 ? ( +