mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 05:43:39 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
84
apps/edr-freight-api/src/modules/audit/audit-actor.ts
Normal file
84
apps/edr-freight-api/src/modules/audit/audit-actor.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
140
apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts
Normal file
140
apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts
Normal file
@@ -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<string, CompiledRoute[]>();
|
||||
|
||||
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();
|
||||
641
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal file
641
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE).
|
||||
*
|
||||
* Shape: "<METHOD> <path>": [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<Record<string, AuditEndpointMeta>> = {
|
||||
// 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"],
|
||||
};
|
||||
@@ -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<AuditLog> {
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogRepository: Repository<AuditLog>,
|
||||
) {
|
||||
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<AuditLog>): Promise<void> {
|
||||
await this.auditLogRepository.insert(
|
||||
entry as QueryDeepPartialEntity<AuditLog>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AuditLog> = {};
|
||||
|
||||
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<string[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
46
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
|
||||
/**
|
||||
* Read-only view over the audit trail.
|
||||
*
|
||||
* Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than
|
||||
* the broad `admin` key, so reading the trail can be granted without also
|
||||
* granting write access to everything else.
|
||||
*
|
||||
* There is deliberately no write, update or delete endpoint here — rows are
|
||||
* created only by `AuditInterceptor`, and an audit trail that can be edited
|
||||
* through the API is not an audit trail.
|
||||
*/
|
||||
@ApiTags('audit')
|
||||
@ApiBearerAuth()
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
|
||||
})
|
||||
list(@Query() query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
||||
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<string[]> {
|
||||
return this.auditService.listTypes();
|
||||
}
|
||||
}
|
||||
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal file
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal file
@@ -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<unknown> {
|
||||
// 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<RequestWithUser>();
|
||||
|
||||
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<Response>();
|
||||
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<string, unknown> | null,
|
||||
startedAt: number,
|
||||
outcome: {
|
||||
isSuccess: boolean;
|
||||
statusCode: number | null;
|
||||
errorMessage: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
34
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
34
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditInterceptor } from './audit.interceptor';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
/**
|
||||
* Backoffice audit trail.
|
||||
*
|
||||
* `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every
|
||||
* route in the application without touching the 488 mutating handlers
|
||||
* individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is
|
||||
* audited as soon as it appears in that map, and unknown routes are skipped
|
||||
* rather than recorded with an empty title.
|
||||
*
|
||||
* Global so other modules can inject `AuditService` to record domain events
|
||||
* that do not map cleanly onto an HTTP request.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
controllers: [AuditController],
|
||||
providers: [
|
||||
AuditLogRepository,
|
||||
AuditService,
|
||||
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
|
||||
],
|
||||
exports: [AuditService, AuditLogRepository],
|
||||
})
|
||||
export class AuditModule {}
|
||||
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal file
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal file
@@ -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<string, unknown>;
|
||||
return (
|
||||
typeof candidate.originalname === 'string' &&
|
||||
(typeof candidate.mimetype === 'string' || typeof candidate.size === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
function describeFile(value: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>);
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown> | null {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
71
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
71
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly auditLogRepository: AuditLogRepository) {}
|
||||
|
||||
/**
|
||||
* Persist one audit row, swallowing any failure.
|
||||
*
|
||||
* An audit write must never turn a successful business action into an error
|
||||
* for the user: if this table is full, misconfigured or mid-migration,
|
||||
* contract approvals still need to work. Failures are logged so the gap is
|
||||
* visible in application logs rather than silent.
|
||||
*/
|
||||
async record(entry: Partial<AuditLog>): Promise<void> {
|
||||
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<PaginatedResponse<AuditLog>> {
|
||||
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<string[]> {
|
||||
return this.auditLogRepository.distinctTypes();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
Reference in New Issue
Block a user