mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -44,6 +44,8 @@ jobs:
|
||||
"passenger-portal"
|
||||
"passenger-backoffice"
|
||||
"payment-api"
|
||||
"synapse"
|
||||
"element-web"
|
||||
)
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -85,6 +87,10 @@ jobs:
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
|
||||
# synapse / element-web have no per-service filter line: their only
|
||||
# source is infrastructure/matrix/, already caught by GLOBAL_PATTERN
|
||||
# above (which redeploys every service), so a dedicated line here
|
||||
# would never fire.
|
||||
|
||||
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
|
||||
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
- name: Resolve project and build env file
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
freight-api|freight-portal|freight-backoffice|gps-tracker)
|
||||
freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web)
|
||||
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -53,3 +53,12 @@ RUNNING_LOCALLY.md
|
||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||
integration/.it-shards.yaml
|
||||
|
||||
# private keys / certificates (EIMS INSA credentials and anything like them) — never commit
|
||||
*.key
|
||||
*.pem
|
||||
*.pem.txt
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
secrets/
|
||||
certs/
|
||||
|
||||
388
CLAUDE.md
388
CLAUDE.md
@@ -1,102 +1,362 @@
|
||||
# EDR Platform — Developer Guide
|
||||
|
||||
> This file is the contract. If something here contradicts the code, the code is the
|
||||
> truth and this file is a bug — fix it in the same PR.
|
||||
|
||||
**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
|
||||
you to the right module or page without a repo-wide grep.
|
||||
|
||||
## Overview
|
||||
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
||||
Management and Passenger Management applications, a payment microservice, plus shared
|
||||
types, NestJS utilities, and React component libraries.
|
||||
|
||||
The freight domain is the largest and most active area. Its core flow is:
|
||||
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
||||
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
||||
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
||||
(warehouse/yard/zone) hang off the warehouse stage.
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Package name | Purpose | Port |
|
||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
The two domains are **not built the same way**. Check which stack you are in before
|
||||
copying a pattern across:
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
|
||||
| App | Package name | Stack | Default port |
|
||||
| ------------------------------ | --------------------------- | ---------------------- | ------------ |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
|
||||
|
||||
Those are the **fallbacks compiled into the code**, not what you will be running. Every
|
||||
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
|
||||
apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
|
||||
the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
|
||||
`./wt ports` for who currently holds what.
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||
packages (see `pnpm-workspace.yaml`).
|
||||
|
||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
||||
|
||||
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||
| Package | Location | Purpose |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
|
||||
| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
|
||||
| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
|
||||
| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
|
||||
| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
|
||||
| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
|
||||
|
||||
The three `config/*` packages are nested one level deeper than the rest — `packages/config`
|
||||
itself is not a package.
|
||||
|
||||
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
||||
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
||||
|
||||
```bash
|
||||
pnpm turbo build --filter=@edr/types
|
||||
```
|
||||
|
||||
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Run only freight API + web |
|
||||
| `pnpm dev:passenger` | Run only passenger API + web |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||
| `pnpm dev:freight:api` | Freight API only |
|
||||
| `pnpm dev:freight:portal` | Freight portal only |
|
||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||
| `pnpm dev:passenger` | Passenger API + web |
|
||||
| `pnpm dev:payment` | Payment API |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests (turbo) |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| `pnpm lint` | **Does not work** — see below |
|
||||
|
||||
## Standards
|
||||
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
|
||||
`turbo run lint` dies with `eslint: not found` even though every package declares a
|
||||
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
|
||||
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
|
||||
"lint clean".
|
||||
|
||||
- **TypeScript strict mode** is enabled in every package and app.
|
||||
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
|
||||
- **Conventional commits** are enforced via commitlint on every commit.
|
||||
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
|
||||
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
|
||||
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
|
||||
`--config` explicitly there.
|
||||
|
||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||
```
|
||||
|
||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||
|
||||
## Environment & database
|
||||
|
||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||
no port `5433`/`5434` is published anywhere in the repo.
|
||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||
remote database.
|
||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||
per-connection in a pool `connect` handler instead. See
|
||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||
through API calls or message queues.
|
||||
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
|
||||
freight tables in `freight`.
|
||||
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
|
||||
skill (below) or write a short Node script using `pg` and run it from
|
||||
`apps/edr-freight-api`, where `pg` resolves.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are non-negotiable. Everything else is a strong default.
|
||||
|
||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||
reach for `any` to make an error go away.
|
||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||
All schema changes go through migrations.
|
||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
|
||||
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
|
||||
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
|
||||
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
|
||||
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
|
||||
- **Controllers** never contain business logic.
|
||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||
`deletedAt` (soft delete).
|
||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||
TypeScript properties are `camelCase`.
|
||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||
offer the safe version.
|
||||
|
||||
## Auth
|
||||
## Architecture
|
||||
|
||||
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
|
||||
### NestJS module shape
|
||||
|
||||
When auth integration is needed, use placeholder TODO comments:
|
||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
|
||||
|
||||
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
|
||||
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
|
||||
### Data access — the real model
|
||||
|
||||
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
|
||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||
|
||||
## Port Assignments
|
||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
|
||||
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
|
||||
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
|
||||
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
|
||||
|
||||
- `edr-freight-api`: 3001
|
||||
- `edr-freight-web/portal`: 5173
|
||||
- `edr-freight-web/backoffice`: 5183
|
||||
- `edr-passenger-api`: 3002
|
||||
- `edr-payment-api`: 3003
|
||||
- `edr-passenger-web/portal`: 5174
|
||||
- `edr-passenger-web/backoffice`: 5184
|
||||
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||
It carries one obligation:
|
||||
|
||||
## Database Layout
|
||||
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||
|
||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||
so they join the caller's transaction.
|
||||
|
||||
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||
no timeout and will wait forever.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
|
||||
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
|
||||
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
|
||||
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
|
||||
and breaks ties non-deterministically. Check before adding one:
|
||||
|
||||
```bash
|
||||
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
|
||||
```
|
||||
|
||||
The prefix must be unused *and* higher than the newest recorded row. Note the
|
||||
`freight.migrations` table has far more rows (~309) than this folder has files — most
|
||||
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
|
||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||
backfills guarded by `WHERE col IS NULL`.
|
||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||
re-introduce the outage it fixed.
|
||||
|
||||
### Auth
|
||||
|
||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||
|
||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||
Add a permission there before referencing it.
|
||||
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
|
||||
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
|
||||
`backoffice` for employees, `portal` for customers. Without it the API 403s with
|
||||
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
|
||||
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||
|
||||
Ownership checks are separate from permission checks. A staff user passes
|
||||
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
|
||||
check the installed version before copying a snippet.
|
||||
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||
freight web apps. Prefer it over re-implementing a component.
|
||||
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||
keep the synchronous version — their bodies are already parsed JSON.
|
||||
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||
|
||||
## Notifications
|
||||
|
||||
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||
SMS and email still send, because they address the company's phone and email directly. Check
|
||||
this before debugging a "missing notification".
|
||||
|
||||
## PDF generation
|
||||
|
||||
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||
assume a headless browser exists.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
|
||||
`<feature>.{module,controller,service,repository}.ts` files.
|
||||
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||
4. The service injects the repository class (not `Repository<T>` directly).
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||
6. Register the module in the app's `app.module.ts`.
|
||||
|
||||
## Adding a new shared component to `@edr/ui-common`
|
||||
|
||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||
2. Export from `src/index.ts`.
|
||||
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
|
||||
3. Component is a functional component with a `ComponentNameProps` interface
|
||||
(named-exported alongside the default).
|
||||
|
||||
## Definition of done
|
||||
|
||||
A change is done when **all** of these hold. State explicitly which you ran.
|
||||
|
||||
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
|
||||
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
|
||||
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
|
||||
dev database without error.
|
||||
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
|
||||
something the new code reads — applied to the dev database, since watch mode will not run it.
|
||||
4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
|
||||
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
|
||||
then run the specs covering what you touched and confirm you introduced no new failure.
|
||||
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
|
||||
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
|
||||
type-check is your only unused-code signal.
|
||||
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||
endpoint, or ran the query. If you could not, say so plainly.
|
||||
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||
the summary. Never describe unverified work as done.
|
||||
|
||||
### Hooks do not run
|
||||
|
||||
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||
hand; do not assume the hook caught it.
|
||||
|
||||
## Known traps
|
||||
|
||||
| Trap | What happens | What to do |
|
||||
| --- | --- | --- |
|
||||
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
|
||||
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
|
||||
|
||||
## Project skills
|
||||
|
||||
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||
|
||||
| Skill | Use for |
|
||||
| --- | --- |
|
||||
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||
|
||||
## Working style
|
||||
|
||||
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||
from a filename.
|
||||
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||
trade-off before changing files.
|
||||
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||
- **Branch from `dev`; PRs target `dev`.**
|
||||
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||
|
||||
313
CLAUDE_NEW.md
313
CLAUDE_NEW.md
@@ -1,313 +0,0 @@
|
||||
# EDR Platform — Developer Guide
|
||||
|
||||
> This file is the contract. If something here contradicts the code, the code is the
|
||||
> truth and this file is a bug — fix it in the same PR.
|
||||
|
||||
## Overview
|
||||
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
||||
Management and Passenger Management applications, a payment microservice, plus shared
|
||||
types, NestJS utilities, and React component libraries.
|
||||
|
||||
The freight domain is the largest and most active area. Its core flow is:
|
||||
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
||||
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
||||
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
||||
(warehouse/yard/zone) hang off the warehouse stage.
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Package name | Purpose | Default port |
|
||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||
packages (see `pnpm-workspace.yaml`).
|
||||
|
||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||
|
||||
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
||||
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
||||
|
||||
```bash
|
||||
pnpm turbo build --filter=@edr/types
|
||||
```
|
||||
|
||||
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------- | ---------------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||
| `pnpm dev:freight:api` | Freight API only |
|
||||
| `pnpm dev:freight:portal` | Freight portal only |
|
||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||
| `pnpm dev:passenger` | Passenger API + web |
|
||||
| `pnpm dev:payment` | Payment API |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests (turbo) |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
|
||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||
```
|
||||
|
||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||
|
||||
## Environment & database
|
||||
|
||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||
no port `5433`/`5434` is published anywhere in the repo.
|
||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||
remote database.
|
||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||
per-connection in a pool `connect` handler instead. See
|
||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||
through API calls or message queues.
|
||||
- `psql` is not installed on the dev machine. To query the database, write a short Node
|
||||
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are non-negotiable. Everything else is a strong default.
|
||||
|
||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||
reach for `any` to make an error go away.
|
||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||
All schema changes go through TypeORM migrations.
|
||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||
`deletedAt` (soft delete).
|
||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||
TypeScript properties are `camelCase`.
|
||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||
offer the safe version.
|
||||
|
||||
## Architecture
|
||||
|
||||
### NestJS module shape
|
||||
|
||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||
|
||||
### Data access — the real model
|
||||
|
||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||
|
||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
|
||||
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
|
||||
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
|
||||
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
|
||||
|
||||
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||
It carries one obligation:
|
||||
|
||||
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||
|
||||
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||
so they join the caller's transaction.
|
||||
|
||||
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||
no timeout and will wait forever.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it.
|
||||
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
|
||||
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
|
||||
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
|
||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||
backfills guarded by `WHERE col IS NULL`.
|
||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||
re-introduce the outage it fixed.
|
||||
|
||||
### Auth
|
||||
|
||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||
|
||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||
Add a permission there before referencing it.
|
||||
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||
|
||||
Ownership checks are separate from permission checks. A staff user passes
|
||||
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version
|
||||
before copying a snippet.
|
||||
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||
freight web apps. Prefer it over re-implementing a component.
|
||||
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||
keep the synchronous version — their bodies are already parsed JSON.
|
||||
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||
|
||||
## Notifications
|
||||
|
||||
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||
SMS and email still send, because they address the company's phone and email directly. Check
|
||||
this before debugging a "missing notification".
|
||||
|
||||
## PDF generation
|
||||
|
||||
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||
assume a headless browser exists.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
|
||||
`<feature>.{module,controller,service,repository}.ts` files.
|
||||
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||
4. The service injects the repository class (not `Repository<T>` directly).
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||
6. Register the module in the app's `app.module.ts`.
|
||||
|
||||
## Adding a new shared component to `@edr/ui-common`
|
||||
|
||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||
2. Export from `src/index.ts`.
|
||||
3. Component is a functional component with a `ComponentNameProps` interface
|
||||
(named-exported alongside the default).
|
||||
|
||||
## Definition of done
|
||||
|
||||
A change is done when **all** of these hold. State explicitly which you ran.
|
||||
|
||||
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
|
||||
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
|
||||
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
|
||||
dev database without error.
|
||||
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
|
||||
something the new code reads — applied to the dev database, since watch mode will not run it.
|
||||
4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**,
|
||||
so a fully green suite is not the bar. Run the specs covering what you touched and confirm
|
||||
you introduced no new failure.
|
||||
5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these
|
||||
automatically (see below), so run them yourself.
|
||||
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||
endpoint, or ran the query. If you could not, say so plainly.
|
||||
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||
the summary. Never describe unverified work as done.
|
||||
|
||||
### Hooks do not run
|
||||
|
||||
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||
hand; do not assume the hook caught it.
|
||||
|
||||
## Known traps
|
||||
|
||||
| Trap | What happens | What to do |
|
||||
| --- | --- | --- |
|
||||
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||
|
||||
## Project skills
|
||||
|
||||
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||
|
||||
| Skill | Use for |
|
||||
| --- | --- |
|
||||
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||
|
||||
## Working style
|
||||
|
||||
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||
from a filename.
|
||||
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||
trade-off before changing files.
|
||||
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||
- **Branch from `dev`; PRs target `dev`.**
|
||||
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||
@@ -1,4 +1,10 @@
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
|
||||
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
|
||||
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
|
||||
# (canned verified profile, no eSignet call). Leave unset in production.
|
||||
ENV=
|
||||
|
||||
PORT=3001
|
||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||
@@ -76,6 +82,12 @@ SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||
|
||||
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
|
||||
# Unset = every employee. A key matching no organization returns no users.
|
||||
# Dev seed key: edr_freight
|
||||
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
|
||||
FREIGHT_ORG_KEY=edr_freight
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
@@ -126,3 +138,103 @@ EMAIL_QUEUE=email_queue
|
||||
# Shared secret for service-to-service calls (payment microservice <-> freight).
|
||||
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
|
||||
SERVICE_AUTH_TOKEN=change-me
|
||||
|
||||
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
|
||||
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
|
||||
EIMS_ENABLED=false
|
||||
EIMS_BASE_URL=https://core.mor.gov.et
|
||||
EIMS_CLIENT_ID=
|
||||
EIMS_CLIENT_SECRET=
|
||||
EIMS_API_KEY=
|
||||
EIMS_TIN=
|
||||
# Source-system identity comes from the access token's systemNumber/systemType claims.
|
||||
# Setting these turns them into expected-value checks: a mismatch against the token fails
|
||||
# fast rather than one side silently winning. Leave empty to take the gateway's word.
|
||||
EIMS_SYSTEM_NUMBER=
|
||||
EIMS_SYSTEM_TYPE=
|
||||
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
|
||||
# patterns are gitignored, but a path outside the working tree is safer still.
|
||||
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
|
||||
EIMS_PRIVATE_KEY_PATH=
|
||||
EIMS_CERTIFICATE_PATH=
|
||||
# Optional tuning
|
||||
EIMS_HTTP_TIMEOUT_MS=30000
|
||||
EIMS_TOKEN_SKEW_SECONDS=45
|
||||
|
||||
# ── EIMS invoice registration (required only to register invoices) ─────────
|
||||
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
|
||||
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
|
||||
EIMS_SELLER_LEGAL_NAME=
|
||||
EIMS_SELLER_VAT_NUMBER=
|
||||
EIMS_SELLER_PHONE=
|
||||
EIMS_SELLER_EMAIL=
|
||||
EIMS_SELLER_REGION=
|
||||
EIMS_SELLER_WEREDA=
|
||||
# Optional seller address parts; sent as null when unset.
|
||||
EIMS_SELLER_CITY=
|
||||
EIMS_SELLER_SUBCITY=
|
||||
EIMS_SELLER_HOUSE_NUMBER=
|
||||
EIMS_SELLER_LOCALITY=
|
||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||
# locally, naming the missing variables, until these are set.
|
||||
# Required, and deliberately unset here: the choice is a tax position, not a default.
|
||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
|
||||
EIMS_TAX_CODE=
|
||||
EIMS_TAX_RATE_PERCENT=0
|
||||
EIMS_EXCISE_TAX_VALUE=0
|
||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
|
||||
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
|
||||
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
|
||||
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
|
||||
EIMS_TAX_CODE_BY_CHARGE_TYPE=
|
||||
EIMS_TAX_RATE_BY_CHARGE_TYPE=
|
||||
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
|
||||
EIMS_EXCISE_BY_CHARGE_TYPE=
|
||||
EIMS_DISCOUNT_BY_CHARGE_TYPE=
|
||||
# Document classification and payment presentation.
|
||||
EIMS_TRANSACTION_TYPE=B2B
|
||||
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||
EIMS_NATURE_OF_SUPPLIES=service
|
||||
EIMS_PAYMENT_MODE=CASH
|
||||
EIMS_PAYMENT_TERM=IMMIDIATE
|
||||
EIMS_UNIT_DEFAULT=PCS
|
||||
# MoR numeric country code for the buyer; our companies store the country name.
|
||||
EIMS_BUYER_COUNTRY_CODE=
|
||||
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
|
||||
# An unmapped region fails locally rather than being filed with a guess.
|
||||
EIMS_BUYER_REGION_CODES=Addis Ababa=13
|
||||
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
|
||||
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
|
||||
EIMS_BUYER_WEREDA_CODES=
|
||||
EIMS_CASHIER_NAME=
|
||||
EIMS_SALESPERSON_NAME=
|
||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
|
||||
# filing is. Both must be true before anything is submitted automatically.
|
||||
EIMS_AUTO_SUBMIT=false
|
||||
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
||||
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
||||
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
||||
# ── Internal chat (Matrix/Element) ──────────────────────────────────────────
|
||||
# Disabled by default; /chat/sso and the nightly room/membership reconcile are
|
||||
# no-ops until enabled. See infrastructure/matrix/.
|
||||
MATRIX_ENABLED=false
|
||||
# Synapse URL reachable from this container (docker-compose service DNS in
|
||||
# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et).
|
||||
MATRIX_BASE_URL=http://localhost:8008
|
||||
# Synapse's own public_baseurl — what Element itself is configured to call.
|
||||
# Only used to seed the sso.html handoff page's localStorage.
|
||||
MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et
|
||||
MATRIX_CHAT_WEB_URL=https://chat.edr.et
|
||||
MATRIX_SERVER_NAME=matrix.edr.et
|
||||
# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET —
|
||||
# this is the whole trust boundary for the SSO handoff.
|
||||
MATRIX_JWT_SECRET=
|
||||
# access_token of a Synapse server-admin account. Bootstrap it once via
|
||||
# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that
|
||||
# file's comments) — this app never touches the shared secret itself.
|
||||
MATRIX_ADMIN_TOKEN=
|
||||
|
||||
9
apps/edr-freight-api/.q.mjs
Normal file
9
apps/edr-freight-api/.q.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import pg from 'pg';
|
||||
import fs from 'fs';
|
||||
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
|
||||
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
|
||||
await c.connect();
|
||||
const sql = process.argv[2];
|
||||
const r = await c.query(sql);
|
||||
console.log(JSON.stringify(r.rows,null,1));
|
||||
await c.end();
|
||||
639
apps/edr-freight-api/data/audit-endpoints.js
Normal file
639
apps/edr-freight-api/data/audit-endpoints.js
Normal file
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const AUDIT_ENDPOINTS = {
|
||||
// Approval Rule
|
||||
"POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"],
|
||||
"PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"],
|
||||
"DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"],
|
||||
"POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"],
|
||||
"POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"],
|
||||
|
||||
// Booking
|
||||
"POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"],
|
||||
"POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"],
|
||||
"DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"],
|
||||
"POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"],
|
||||
"DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"],
|
||||
"POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"],
|
||||
"DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"],
|
||||
"POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||
|
||||
// Cargo
|
||||
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
|
||||
"PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"],
|
||||
"DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"],
|
||||
"POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"],
|
||||
"POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"],
|
||||
"POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"],
|
||||
|
||||
// Cargo Type
|
||||
"POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"],
|
||||
"PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"],
|
||||
"DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"],
|
||||
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
|
||||
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
|
||||
|
||||
// Company
|
||||
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
|
||||
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
|
||||
"POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"],
|
||||
"PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"],
|
||||
"DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"],
|
||||
"POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"],
|
||||
"POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"],
|
||||
"POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"],
|
||||
"POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"],
|
||||
"POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"],
|
||||
"POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"],
|
||||
"DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"],
|
||||
"POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"],
|
||||
"POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"],
|
||||
"PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"],
|
||||
"POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"],
|
||||
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
|
||||
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
|
||||
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
|
||||
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
|
||||
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
|
||||
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
|
||||
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
|
||||
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
|
||||
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
|
||||
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
|
||||
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
|
||||
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
|
||||
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
|
||||
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
|
||||
|
||||
// Compliance
|
||||
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
|
||||
"PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"],
|
||||
"DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"],
|
||||
|
||||
// Consignment
|
||||
"POST /api/consignments": ["Create a new consignment", "POST", "Consignment"],
|
||||
|
||||
// Container
|
||||
"POST /api/containers": ["Create a new container", "POST", "Container"],
|
||||
"PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"],
|
||||
"DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"],
|
||||
"POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"],
|
||||
"POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"],
|
||||
|
||||
// Container Type
|
||||
"POST /api/container-types": ["Create a container type", "POST", "Container Type"],
|
||||
"PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"],
|
||||
"DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"],
|
||||
"POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"],
|
||||
"POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"],
|
||||
|
||||
// Contract
|
||||
"POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"],
|
||||
"PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"],
|
||||
"DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"],
|
||||
"POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"],
|
||||
"PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"],
|
||||
"POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"],
|
||||
"POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"],
|
||||
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
|
||||
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
|
||||
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
|
||||
|
||||
// Contract Template
|
||||
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
|
||||
"PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"],
|
||||
"DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"],
|
||||
"POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"],
|
||||
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
|
||||
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
||||
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
||||
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
|
||||
// Driver
|
||||
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
||||
"PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"],
|
||||
"DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"],
|
||||
"POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"],
|
||||
"DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"],
|
||||
|
||||
// Dropdown Setting
|
||||
"POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"],
|
||||
"PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"],
|
||||
"DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"],
|
||||
"POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"],
|
||||
"PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"],
|
||||
"PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"],
|
||||
"DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"],
|
||||
|
||||
// EIMS Invoice
|
||||
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
|
||||
|
||||
// Exchange Setting
|
||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||
|
||||
// Facility
|
||||
"POST /api/facilities": ["Create a new facility", "POST", "Facility"],
|
||||
"PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"],
|
||||
"DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"],
|
||||
|
||||
// Fayda Verification
|
||||
"POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"],
|
||||
|
||||
// File Upload Setting
|
||||
"POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"],
|
||||
"PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"],
|
||||
"DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"],
|
||||
"POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"],
|
||||
"PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"],
|
||||
"PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"],
|
||||
"DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"],
|
||||
|
||||
// First Mile
|
||||
"POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"],
|
||||
"PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"],
|
||||
"DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"],
|
||||
"POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"],
|
||||
"POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"],
|
||||
"POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"],
|
||||
"POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"],
|
||||
|
||||
// Fuel
|
||||
"POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"],
|
||||
|
||||
// GPS Tracking
|
||||
"POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"],
|
||||
"PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"],
|
||||
"DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"],
|
||||
|
||||
// Import Operation
|
||||
"POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
|
||||
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
|
||||
|
||||
// Incident
|
||||
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
|
||||
"PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"],
|
||||
"DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"],
|
||||
|
||||
// Interchange Document
|
||||
"PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"],
|
||||
"PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"],
|
||||
"POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"],
|
||||
|
||||
// Last Mile
|
||||
"POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"],
|
||||
"PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"],
|
||||
"DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"],
|
||||
"POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"],
|
||||
"POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"],
|
||||
|
||||
// Last Mile Request
|
||||
"POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"],
|
||||
"POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"],
|
||||
"POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"],
|
||||
"POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"],
|
||||
|
||||
// Locomotive
|
||||
"POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"],
|
||||
"PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"],
|
||||
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
|
||||
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
|
||||
|
||||
// Maintenance
|
||||
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
|
||||
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
|
||||
"DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"],
|
||||
"POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"],
|
||||
"PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"],
|
||||
"DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"],
|
||||
"POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"],
|
||||
"PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"],
|
||||
"POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"],
|
||||
"DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"],
|
||||
"POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"],
|
||||
"PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"],
|
||||
"DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"],
|
||||
|
||||
// Notification Inbox
|
||||
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
|
||||
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
|
||||
|
||||
// Organization User
|
||||
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
|
||||
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
|
||||
|
||||
// OTP
|
||||
"POST /api/otp/send": ["Send OTP", "POST", "OTP"],
|
||||
"POST /api/otp/verify": ["Verify OTP", "POST", "OTP"],
|
||||
|
||||
// Password Reset
|
||||
"POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"],
|
||||
"POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"],
|
||||
"POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"],
|
||||
"POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"],
|
||||
|
||||
// Payment
|
||||
"POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"],
|
||||
"POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"],
|
||||
"POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"],
|
||||
"POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"],
|
||||
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
|
||||
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
|
||||
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
|
||||
|
||||
// Priority Config
|
||||
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
|
||||
"PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"],
|
||||
"DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"],
|
||||
"POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"],
|
||||
"POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"],
|
||||
|
||||
// Priority Rule Change Request
|
||||
"POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"],
|
||||
"POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"],
|
||||
"POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"],
|
||||
|
||||
// Procurement
|
||||
"POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"],
|
||||
"PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"],
|
||||
"DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"],
|
||||
"POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"],
|
||||
"DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"],
|
||||
"POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"],
|
||||
"PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"],
|
||||
"DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"],
|
||||
|
||||
// Rate
|
||||
"POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"],
|
||||
"PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"],
|
||||
"DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"],
|
||||
"POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"],
|
||||
"POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"],
|
||||
|
||||
// Rate Change Request
|
||||
"POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"],
|
||||
"POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"],
|
||||
"POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"],
|
||||
|
||||
// Route
|
||||
"POST /api/routes": ["Create route", "POST", "Route"],
|
||||
"PATCH /api/routes/:id": ["Update route", "PATCH", "Route"],
|
||||
"DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"],
|
||||
"DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"],
|
||||
|
||||
// Schedule
|
||||
// NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52.
|
||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
|
||||
// Service Type
|
||||
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
|
||||
"PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"],
|
||||
"DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"],
|
||||
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
||||
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
||||
|
||||
// Shipping Line
|
||||
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||
|
||||
// Signature
|
||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||
|
||||
// Support Chat
|
||||
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
|
||||
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
|
||||
"POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"],
|
||||
"POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"],
|
||||
"POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"],
|
||||
|
||||
// Support Content
|
||||
"PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"],
|
||||
"POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"],
|
||||
"POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"],
|
||||
|
||||
// Train
|
||||
"POST /api/trains": ["Register a new train", "POST", "Train"],
|
||||
"PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"],
|
||||
"DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"],
|
||||
|
||||
// Train Build
|
||||
"POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"],
|
||||
"DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"],
|
||||
"POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"],
|
||||
"POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
|
||||
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
|
||||
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
|
||||
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"],
|
||||
|
||||
// Train Schedule
|
||||
"POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"],
|
||||
"DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
|
||||
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
|
||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"],
|
||||
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
||||
|
||||
// Transit Agent
|
||||
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
|
||||
"PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"],
|
||||
"DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"],
|
||||
|
||||
// Truck Type
|
||||
"POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"],
|
||||
"PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"],
|
||||
"DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"],
|
||||
|
||||
// User Trade Access
|
||||
"PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"],
|
||||
|
||||
// Vehicle
|
||||
"POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"],
|
||||
"PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"],
|
||||
"DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"],
|
||||
|
||||
// Wagon
|
||||
"POST /api/wagons": ["Create a new wagon", "POST", "Wagon"],
|
||||
"PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"],
|
||||
"DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"],
|
||||
"POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"],
|
||||
"DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"],
|
||||
"POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"],
|
||||
"POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"],
|
||||
"POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"],
|
||||
|
||||
// Wagon Transfer Request
|
||||
"POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"],
|
||||
"POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"],
|
||||
"POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"],
|
||||
"POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"],
|
||||
"POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"],
|
||||
|
||||
// Wagon Type
|
||||
"POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"],
|
||||
"PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"],
|
||||
"DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"],
|
||||
|
||||
// Warehouse
|
||||
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"],
|
||||
"DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"],
|
||||
"POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"],
|
||||
|
||||
// Warehouse Fee Invoice
|
||||
"POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"],
|
||||
"PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"],
|
||||
"POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
|
||||
"POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
|
||||
"POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"],
|
||||
|
||||
// Warehouse Inspection Report
|
||||
"PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"],
|
||||
"POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"],
|
||||
"POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"],
|
||||
|
||||
// Warehouse Inventory
|
||||
"POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"],
|
||||
"PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"],
|
||||
"PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"],
|
||||
"POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"],
|
||||
|
||||
// Warehouse Yard
|
||||
"PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"],
|
||||
"POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"],
|
||||
|
||||
// Warehouse Zone
|
||||
"PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"],
|
||||
|
||||
// Weight Limit Rule
|
||||
"POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"],
|
||||
"PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"],
|
||||
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
||||
|
||||
// Yard
|
||||
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
||||
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
||||
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
||||
"POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"],
|
||||
"POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"],
|
||||
|
||||
// Yard Distance
|
||||
"POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"],
|
||||
"PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"],
|
||||
"DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"],
|
||||
};
|
||||
|
||||
module.exports = AUDIT_ENDPOINTS;
|
||||
922
apps/edr-freight-api/docs/audit-endpoints.md
Normal file
922
apps/edr-freight-api/docs/audit-endpoints.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# Freight API — Mutating Endpoints (Audit Surface)
|
||||
|
||||
Every state-changing route in `apps/edr-freight-api` — `POST`, `PUT`, `PATCH`, `DELETE`.
|
||||
This is the candidate surface for audit logging: each row is an action a user can take
|
||||
that changes persisted state and therefore needs a who / what / when trail.
|
||||
|
||||
All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`).
|
||||
Titles come from each route's `@ApiOperation({ summary })`; where a route has none,
|
||||
the title is derived from its handler name.
|
||||
|
||||
> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in
|
||||
> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete.
|
||||
|
||||
## Summary
|
||||
|
||||
| Method | Count |
|
||||
| --- | ---: |
|
||||
| `POST` | 351 |
|
||||
| `PUT` | 8 |
|
||||
| `PATCH` | 72 |
|
||||
| `DELETE` | 61 |
|
||||
| **Total** | **492** |
|
||||
|
||||
Across **66** entities.
|
||||
|
||||
## Entity index
|
||||
|
||||
| Entity | Endpoints |
|
||||
| --- | ---: |
|
||||
| [Account](#account) | 3 |
|
||||
| [AI Assist](#ai-assist) | 1 |
|
||||
| [Approval Rule](#approval-rule) | 5 |
|
||||
| [Booking](#booking) | 57 |
|
||||
| [Cargo](#cargo) | 6 |
|
||||
| [Cargo Type](#cargo-type) | 5 |
|
||||
| [Company](#company) | 30 |
|
||||
| [Compliance](#compliance) | 3 |
|
||||
| [Consignment](#consignment) | 1 |
|
||||
| [Container](#container) | 5 |
|
||||
| [Container Type](#container-type) | 5 |
|
||||
| [Contract](#contract) | 68 |
|
||||
| [Contract Template](#contract-template) | 8 |
|
||||
| [Driver](#driver) | 5 |
|
||||
| [Dropdown Setting](#dropdown-setting) | 7 |
|
||||
| [EIMS Invoice](#eims-invoice) | 3 |
|
||||
| [Exchange Setting](#exchange-setting) | 1 |
|
||||
| [Facility](#facility) | 3 |
|
||||
| [Fayda Verification](#fayda-verification) | 1 |
|
||||
| [File Upload Setting](#file-upload-setting) | 7 |
|
||||
| [First Mile](#first-mile) | 7 |
|
||||
| [Fuel](#fuel) | 1 |
|
||||
| [GPS Tracking](#gps-tracking) | 3 |
|
||||
| [Import Operation](#import-operation) | 9 |
|
||||
| [Incident](#incident) | 3 |
|
||||
| [Interchange Document](#interchange-document) | 3 |
|
||||
| [Last Mile](#last-mile) | 10 |
|
||||
| [Last Mile Request](#last-mile-request) | 4 |
|
||||
| [Locomotive](#locomotive) | 4 |
|
||||
| [Maintenance](#maintenance) | 13 |
|
||||
| [Notification Inbox](#notification-inbox) | 2 |
|
||||
| [Organization User](#organization-user) | 2 |
|
||||
| [OTP](#otp) | 2 |
|
||||
| [Password Reset](#password-reset) | 4 |
|
||||
| [Payment](#payment) | 7 |
|
||||
| [Priority Config](#priority-config) | 5 |
|
||||
| [Priority Rule Change Request](#priority-rule-change-request) | 3 |
|
||||
| [Procurement](#procurement) | 8 |
|
||||
| [Rate](#rate) | 5 |
|
||||
| [Rate Change Request](#rate-change-request) | 3 |
|
||||
| [Route](#route) | 4 |
|
||||
| [Schedule](#schedule) | 3 |
|
||||
| [Service Type](#service-type) | 5 |
|
||||
| [Shipping Line](#shipping-line) | 3 |
|
||||
| [Signature](#signature) | 1 |
|
||||
| [Support Chat](#support-chat) | 5 |
|
||||
| [Support Content](#support-content) | 3 |
|
||||
| [Train](#train) | 3 |
|
||||
| [Train Build](#train-build) | 11 |
|
||||
| [Train Schedule](#train-schedule) | 48 |
|
||||
| [Transit Agent](#transit-agent) | 3 |
|
||||
| [Truck Type](#truck-type) | 3 |
|
||||
| [User Trade Access](#user-trade-access) | 1 |
|
||||
| [Vehicle](#vehicle) | 3 |
|
||||
| [Wagon](#wagon) | 8 |
|
||||
| [Wagon Transfer Request](#wagon-transfer-request) | 5 |
|
||||
| [Wagon Type](#wagon-type) | 3 |
|
||||
| [Warehouse](#warehouse) | 12 |
|
||||
| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 |
|
||||
| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 |
|
||||
| [Warehouse Inventory](#warehouse-inventory) | 24 |
|
||||
| [Warehouse Yard](#warehouse-yard) | 2 |
|
||||
| [Warehouse Zone](#warehouse-zone) | 1 |
|
||||
| [Weight Limit Rule](#weight-limit-rule) | 3 |
|
||||
| [Yard](#yard) | 5 |
|
||||
| [Yard Distance](#yard-distance) | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints by entity
|
||||
|
||||
### Account
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` |
|
||||
| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` |
|
||||
| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` |
|
||||
|
||||
### AI Assist
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` |
|
||||
|
||||
### Approval Rule
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` |
|
||||
| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` |
|
||||
| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` |
|
||||
| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` |
|
||||
| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` |
|
||||
|
||||
### Booking
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` |
|
||||
| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` |
|
||||
| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` |
|
||||
| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` |
|
||||
| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` |
|
||||
| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` |
|
||||
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` |
|
||||
| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` |
|
||||
| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` |
|
||||
| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` |
|
||||
| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` |
|
||||
| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` |
|
||||
| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` |
|
||||
| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` |
|
||||
| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` |
|
||||
| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` |
|
||||
| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` |
|
||||
| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` |
|
||||
| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` |
|
||||
| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` |
|
||||
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` |
|
||||
| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` |
|
||||
| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` |
|
||||
| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` |
|
||||
| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` |
|
||||
| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` |
|
||||
| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` |
|
||||
| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` |
|
||||
| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` |
|
||||
| Add a customer self-haul truck carrying 1–2 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` |
|
||||
| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` |
|
||||
| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` |
|
||||
| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` |
|
||||
| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` |
|
||||
| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` |
|
||||
| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` |
|
||||
| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` |
|
||||
| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` |
|
||||
| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` |
|
||||
| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` |
|
||||
| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` |
|
||||
| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` |
|
||||
| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` |
|
||||
| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` |
|
||||
| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` |
|
||||
| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` |
|
||||
| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` |
|
||||
| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` |
|
||||
| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` |
|
||||
| Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` |
|
||||
| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` |
|
||||
| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` |
|
||||
| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` |
|
||||
| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` |
|
||||
| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` |
|
||||
| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` |
|
||||
| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` |
|
||||
|
||||
### Cargo
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` |
|
||||
| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` |
|
||||
| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` |
|
||||
| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` |
|
||||
| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` |
|
||||
| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` |
|
||||
|
||||
### Cargo Type
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` |
|
||||
| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` |
|
||||
| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` |
|
||||
| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` |
|
||||
| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` |
|
||||
|
||||
### Company
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` |
|
||||
| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` |
|
||||
| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` |
|
||||
| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` |
|
||||
| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` |
|
||||
| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` |
|
||||
| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` |
|
||||
| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` |
|
||||
| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` |
|
||||
| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` |
|
||||
| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` |
|
||||
| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` |
|
||||
| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` |
|
||||
| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` |
|
||||
| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` |
|
||||
| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` |
|
||||
| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` |
|
||||
| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` |
|
||||
| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` |
|
||||
| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` |
|
||||
| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` |
|
||||
| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` |
|
||||
| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` |
|
||||
| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` |
|
||||
| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` |
|
||||
| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` |
|
||||
| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` |
|
||||
| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` |
|
||||
| Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right | `DELETE` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` |
|
||||
| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` |
|
||||
|
||||
### Compliance
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` |
|
||||
| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` |
|
||||
| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` |
|
||||
|
||||
### Consignment
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` |
|
||||
|
||||
### Container
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` |
|
||||
| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` |
|
||||
| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` |
|
||||
| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` |
|
||||
| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` |
|
||||
|
||||
### Container Type
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` |
|
||||
| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` |
|
||||
| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` |
|
||||
| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` |
|
||||
| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` |
|
||||
|
||||
### Contract
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` |
|
||||
| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` |
|
||||
| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` |
|
||||
| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` |
|
||||
| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` |
|
||||
| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` |
|
||||
| Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request | `POST` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` |
|
||||
| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` |
|
||||
| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` |
|
||||
| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` |
|
||||
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` |
|
||||
| GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving | `POST` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` |
|
||||
| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` |
|
||||
| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` |
|
||||
| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` |
|
||||
| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` |
|
||||
| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` |
|
||||
| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` |
|
||||
| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` |
|
||||
| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` |
|
||||
| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` |
|
||||
| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` |
|
||||
| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` |
|
||||
| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` |
|
||||
| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` |
|
||||
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` |
|
||||
| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` |
|
||||
| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` |
|
||||
| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` |
|
||||
| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` |
|
||||
| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` |
|
||||
| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` |
|
||||
| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` |
|
||||
| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` |
|
||||
| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` |
|
||||
| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` |
|
||||
| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` |
|
||||
| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` |
|
||||
| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` |
|
||||
| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` |
|
||||
| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` |
|
||||
| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` |
|
||||
| Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created) | `POST` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` |
|
||||
| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` |
|
||||
| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` |
|
||||
| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` |
|
||||
| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` |
|
||||
| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` |
|
||||
| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` |
|
||||
| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` |
|
||||
| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` |
|
||||
| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` |
|
||||
| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` |
|
||||
| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` |
|
||||
| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` |
|
||||
| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` |
|
||||
| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` |
|
||||
| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` |
|
||||
| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` |
|
||||
| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` |
|
||||
| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` |
|
||||
| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` |
|
||||
| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` |
|
||||
| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` |
|
||||
| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` |
|
||||
| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` |
|
||||
| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` |
|
||||
| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` |
|
||||
|
||||
### Contract Template
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` |
|
||||
| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` |
|
||||
| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` |
|
||||
| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` |
|
||||
| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` |
|
||||
| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` |
|
||||
| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` |
|
||||
| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` |
|
||||
|
||||
### Driver
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` |
|
||||
| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` |
|
||||
| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` |
|
||||
| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` |
|
||||
| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` |
|
||||
|
||||
### Dropdown Setting
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` |
|
||||
| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` |
|
||||
| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` |
|
||||
| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` |
|
||||
| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` |
|
||||
| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` |
|
||||
| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` |
|
||||
|
||||
### EIMS Invoice
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` |
|
||||
| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` |
|
||||
| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` |
|
||||
|
||||
### Exchange Setting
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` |
|
||||
|
||||
### Facility
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` |
|
||||
| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` |
|
||||
| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` |
|
||||
|
||||
### Fayda Verification
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` |
|
||||
|
||||
### File Upload Setting
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` |
|
||||
| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` |
|
||||
| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` |
|
||||
| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` |
|
||||
| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` |
|
||||
| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` |
|
||||
| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` |
|
||||
|
||||
### First Mile
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` |
|
||||
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` |
|
||||
| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` |
|
||||
| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` |
|
||||
| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` |
|
||||
| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` |
|
||||
| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` |
|
||||
|
||||
### Fuel
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` |
|
||||
|
||||
### GPS Tracking
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` |
|
||||
| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` |
|
||||
| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` |
|
||||
|
||||
### Import Operation
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` |
|
||||
| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` |
|
||||
| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` |
|
||||
| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` |
|
||||
| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` |
|
||||
| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` |
|
||||
| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` |
|
||||
| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` |
|
||||
| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` |
|
||||
|
||||
### Incident
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` |
|
||||
| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` |
|
||||
| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` |
|
||||
|
||||
### Interchange Document
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` |
|
||||
| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` |
|
||||
| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` |
|
||||
|
||||
### Last Mile
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` |
|
||||
| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` |
|
||||
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` |
|
||||
| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` |
|
||||
| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` |
|
||||
| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` |
|
||||
| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` |
|
||||
| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` |
|
||||
| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` |
|
||||
| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` |
|
||||
|
||||
### Last Mile Request
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature | `POST` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` |
|
||||
| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` |
|
||||
| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` |
|
||||
| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` |
|
||||
|
||||
### Locomotive
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` |
|
||||
| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` |
|
||||
| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` |
|
||||
| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` |
|
||||
|
||||
### Maintenance
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` |
|
||||
| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` |
|
||||
| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` |
|
||||
| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` |
|
||||
| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` |
|
||||
| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` |
|
||||
| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` |
|
||||
| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` |
|
||||
| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` |
|
||||
| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` |
|
||||
| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` |
|
||||
| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` |
|
||||
| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` |
|
||||
|
||||
### Notification Inbox
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` |
|
||||
| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` |
|
||||
|
||||
### Organization User
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` |
|
||||
| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` |
|
||||
|
||||
### OTP
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` |
|
||||
| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` |
|
||||
|
||||
### Password Reset
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` |
|
||||
| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` |
|
||||
| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` |
|
||||
| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` |
|
||||
|
||||
### Payment
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` |
|
||||
| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` |
|
||||
| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` |
|
||||
| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` |
|
||||
| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` |
|
||||
| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` |
|
||||
| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` |
|
||||
|
||||
### Priority Config
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` |
|
||||
| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` |
|
||||
| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` |
|
||||
| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` |
|
||||
| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` |
|
||||
|
||||
### Priority Rule Change Request
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` |
|
||||
| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` |
|
||||
| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` |
|
||||
|
||||
### Procurement
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` |
|
||||
| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` |
|
||||
| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` |
|
||||
| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` |
|
||||
| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` |
|
||||
| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` |
|
||||
| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` |
|
||||
| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` |
|
||||
|
||||
### Rate
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` |
|
||||
| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` |
|
||||
| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` |
|
||||
| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` |
|
||||
| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` |
|
||||
|
||||
### Rate Change Request
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` |
|
||||
| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` |
|
||||
| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` |
|
||||
|
||||
### Route
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` |
|
||||
| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` |
|
||||
| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` |
|
||||
| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` |
|
||||
|
||||
### Schedule
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` |
|
||||
| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` |
|
||||
| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` |
|
||||
|
||||
### Service Type
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` |
|
||||
| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` |
|
||||
| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` |
|
||||
| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` |
|
||||
| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` |
|
||||
|
||||
### Shipping Line
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` |
|
||||
| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` |
|
||||
| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` |
|
||||
|
||||
### Signature
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` |
|
||||
|
||||
### Support Chat
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` |
|
||||
| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` |
|
||||
| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` |
|
||||
| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` |
|
||||
| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` |
|
||||
|
||||
### Support Content
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` |
|
||||
| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` |
|
||||
| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` |
|
||||
|
||||
### Train
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` |
|
||||
| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` |
|
||||
| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` |
|
||||
|
||||
### Train Build
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` |
|
||||
| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` |
|
||||
| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` |
|
||||
| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` |
|
||||
| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` |
|
||||
| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` |
|
||||
| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` |
|
||||
| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` |
|
||||
| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` |
|
||||
| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` |
|
||||
| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` |
|
||||
|
||||
### Train Schedule
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` |
|
||||
| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` |
|
||||
| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` |
|
||||
| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` |
|
||||
| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` |
|
||||
| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` |
|
||||
| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` |
|
||||
| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` |
|
||||
| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` |
|
||||
| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` |
|
||||
| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` |
|
||||
| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` |
|
||||
| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` |
|
||||
| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` |
|
||||
| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` |
|
||||
| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` |
|
||||
| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` |
|
||||
| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` |
|
||||
| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` |
|
||||
| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` |
|
||||
| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` |
|
||||
| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` |
|
||||
| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` |
|
||||
| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` |
|
||||
| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` |
|
||||
| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` |
|
||||
| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` |
|
||||
| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` |
|
||||
| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` |
|
||||
| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` |
|
||||
| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` |
|
||||
| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` |
|
||||
| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` |
|
||||
| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` |
|
||||
| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` |
|
||||
| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` |
|
||||
| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` |
|
||||
| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` |
|
||||
| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` |
|
||||
| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` |
|
||||
| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` |
|
||||
| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` |
|
||||
| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` |
|
||||
| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` |
|
||||
| Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window | `PATCH` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` |
|
||||
| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` |
|
||||
| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` |
|
||||
| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` |
|
||||
|
||||
### Transit Agent
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` |
|
||||
| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` |
|
||||
| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` |
|
||||
|
||||
### Truck Type
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` |
|
||||
| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` |
|
||||
| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` |
|
||||
|
||||
### User Trade Access
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` |
|
||||
|
||||
### Vehicle
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` |
|
||||
| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` |
|
||||
| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` |
|
||||
|
||||
### Wagon
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` |
|
||||
| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` |
|
||||
| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` |
|
||||
| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` |
|
||||
| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` |
|
||||
| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` |
|
||||
| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` |
|
||||
| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` |
|
||||
|
||||
### Wagon Transfer Request
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` |
|
||||
| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` |
|
||||
| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` |
|
||||
| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` |
|
||||
| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` |
|
||||
|
||||
### Wagon Type
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` |
|
||||
| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` |
|
||||
| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` |
|
||||
|
||||
### Warehouse
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` |
|
||||
| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` |
|
||||
| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` |
|
||||
| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` |
|
||||
| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` |
|
||||
| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` |
|
||||
| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` |
|
||||
| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` |
|
||||
| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` |
|
||||
| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` |
|
||||
| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` |
|
||||
| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` |
|
||||
|
||||
### Warehouse Fee Invoice
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` |
|
||||
| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` |
|
||||
| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` |
|
||||
| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` |
|
||||
| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` |
|
||||
|
||||
### Warehouse Inspection Report
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` |
|
||||
| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` |
|
||||
| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` |
|
||||
|
||||
### Warehouse Inventory
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` |
|
||||
| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` |
|
||||
| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` |
|
||||
| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` |
|
||||
| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` |
|
||||
| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` |
|
||||
| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` |
|
||||
| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` |
|
||||
| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` |
|
||||
| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` |
|
||||
| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` |
|
||||
| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` |
|
||||
| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` |
|
||||
| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` |
|
||||
| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` |
|
||||
| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` |
|
||||
| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` |
|
||||
| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` |
|
||||
| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` |
|
||||
| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` |
|
||||
| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` |
|
||||
| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` |
|
||||
| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` |
|
||||
| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` |
|
||||
|
||||
### Warehouse Yard
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` |
|
||||
| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` |
|
||||
|
||||
### Warehouse Zone
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` |
|
||||
|
||||
### Weight Limit Rule
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` |
|
||||
| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` |
|
||||
| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` |
|
||||
|
||||
### Yard
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` |
|
||||
| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` |
|
||||
| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` |
|
||||
| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` |
|
||||
| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` |
|
||||
|
||||
### Yard Distance
|
||||
|
||||
| Title | Method | Endpoint | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` |
|
||||
| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` |
|
||||
| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` |
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
|
||||
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -59,7 +60,6 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
@@ -69,6 +69,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jose": "^5.10.0",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import { MezgebModule } from "@tria-plc/auditlog";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
import faydaConfig from "./config/fayda.config";
|
||||
import eimsConfig from "./config/eims.config";
|
||||
import chatConfig from "./config/chat.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||
@@ -40,6 +41,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
|
||||
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
@@ -48,7 +51,11 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
@@ -66,6 +73,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { SupportContentSeeder } from "./seed/support-content.seeder";
|
||||
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
@@ -76,13 +84,15 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
|
||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { EimsModule } from "./modules/eims/eims.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
@@ -108,7 +118,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware
|
||||
// and deleted ./logger.middleware, so the branch's import is dropped here.
|
||||
import { RequestLogMiddleware } from "@edr/api-common";
|
||||
import { ChatModule } from "./modules/chat/chat.module";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||
|
||||
@@ -126,6 +139,8 @@ if (!process.env.APPLICATION_NAME) {
|
||||
telebirrConfig,
|
||||
rabbitmqConfig,
|
||||
faydaConfig,
|
||||
eimsConfig,
|
||||
chatConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -163,19 +178,6 @@ if (!process.env.APPLICATION_NAME) {
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
|
||||
// Must come after TypeOrmModule above so it picks up this app's DataSource.
|
||||
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
|
||||
// does: the dev broker only provisions the `edr` user on the `payment`
|
||||
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
|
||||
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
|
||||
MezgebModule.forRoot({
|
||||
applicationName: "freight-api",
|
||||
rmqUrl:
|
||||
process.env.RABBITMQ_URL ??
|
||||
process.env.PAYMENT_RABBITMQ_URL ??
|
||||
"amqp://localhost:5672",
|
||||
}),
|
||||
SharedAuthModule,
|
||||
IamModule.forRoot({
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
@@ -207,6 +209,8 @@ if (!process.env.APPLICATION_NAME) {
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CompaniesModule,
|
||||
ShippingLineCompaniesModule,
|
||||
ShippingLineBookingCompletionModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
@@ -215,7 +219,11 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
PaymentSettingsModule,
|
||||
StampSettingsModule,
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
@@ -247,16 +255,20 @@ if (!process.env.APPLICATION_NAME) {
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
ChatModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
SupportContentSeeder,
|
||||
// YardFacilitiesSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
FreightNotificationPermissionsSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
// FreightStaffUsersSeeder,
|
||||
@@ -271,8 +283,8 @@ if (!process.env.APPLICATION_NAME) {
|
||||
// WarehouseDemoSeeder,
|
||||
// ExportDjiboutiInterchangeDemoSeeder,
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
// ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
// PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
// Feeds position-TYPE grants to the synchronous permission checks — without
|
||||
// it, staff whose permissions live on their position type resolve to none.
|
||||
@@ -285,8 +297,10 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly supportContentSeeder: SupportContentSeeder,
|
||||
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
@@ -328,10 +342,24 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
// freightNotificationPermissions → seeds the <module>:get_notification
|
||||
// keys and backfills them onto whoever
|
||||
// already holds each desk's anchor
|
||||
// permission. Runs LAST in this block so
|
||||
// it sees a freshly-seeded catalog and
|
||||
// freshly-seeded positions. Unlike the
|
||||
// seeders above it is NOT gated behind
|
||||
// SEED_EDR_ORG — without it every staff
|
||||
// notification resolves to no one.
|
||||
await this.freightNotificationPermissionsSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
|
||||
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
|
||||
// it fills an empty table once and never touches admin edits afterwards.
|
||||
await this.supportContentSeeder.run();
|
||||
|
||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||
// Dire Dawa). Idempotent; creates no yards.
|
||||
// await this.yardFacilitiesSeeder.run();
|
||||
@@ -364,7 +392,9 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
// FIRST: opens the request log context every later middleware/guard/service
|
||||
// writes into via logCtx(). Anything applied above it logs into the void.
|
||||
consumer.apply(RequestLogMiddleware).forRoutes("*");
|
||||
consumer
|
||||
.apply(LoginAudienceMiddleware)
|
||||
.forRoutes(
|
||||
|
||||
@@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) =>
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync);
|
||||
|
||||
/**
|
||||
* The document-review countdown in the backoffice header. Its own permission so
|
||||
* it can be granted to exactly the position types that decide operation
|
||||
@@ -90,6 +92,10 @@ export const TrainSchedulingReschedule = () =>
|
||||
export const TrainSchedulingRulesManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
|
||||
|
||||
/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */
|
||||
export const TrainSchedulingEditTrainNumber = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber);
|
||||
|
||||
/**
|
||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||
|
||||
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Dev/staging bypass gate for OTP, payment and Fayda verification.
|
||||
*
|
||||
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
|
||||
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
|
||||
* production, so this is always false there.
|
||||
*/
|
||||
export function isBypassEnv(): boolean {
|
||||
return ["dev", "staging"].includes(process.env.ENV ?? "");
|
||||
}
|
||||
|
||||
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
|
||||
export const DEV_BYPASS_OTP = "000000";
|
||||
30
apps/edr-freight-api/src/common/document-upload.options.ts
Normal file
30
apps/edr-freight-api/src/common/document-upload.options.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
|
||||
|
||||
/**
|
||||
* Ceiling for a single uploaded document, in bytes.
|
||||
*
|
||||
* Mirrors the 50MB `max_size_mb` the file-upload settings hand the portal, so
|
||||
* the client-side gate and the server-side cap agree. Raising this alone is not
|
||||
* enough to accept a 50MB upload: the reverse proxy in front of the API applies
|
||||
* its own `client_max_body_size`, and nginx's 1MB default rejects the request
|
||||
* with a 413 before it ever reaches Nest (see docs/uploads.md).
|
||||
*/
|
||||
export const DOCUMENT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/** Upper bound on parts in one multipart document post. */
|
||||
export const DOCUMENT_UPLOAD_MAX_FILES = 20;
|
||||
|
||||
/**
|
||||
* Multer caps for the document upload routes.
|
||||
*
|
||||
* Without an explicit `fileSize`, multer's default is unlimited and every byte
|
||||
* is buffered in memory, so an oversized post is absorbed in full before
|
||||
* anything can reject it. With the limit set, multer stops reading the socket
|
||||
* at the ceiling instead.
|
||||
*/
|
||||
export const documentUploadMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: DOCUMENT_UPLOAD_MAX_BYTES,
|
||||
files: DOCUMENT_UPLOAD_MAX_FILES,
|
||||
},
|
||||
};
|
||||
@@ -41,4 +41,16 @@ export class PaginationQueryDto {
|
||||
@Transform(({ value }) => String(value).toUpperCase())
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* Column to sort by, as a public field name (not a raw SQL column). The
|
||||
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
|
||||
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
|
||||
* An unrecognized value falls back silently rather than 400ing, so a stale
|
||||
* bookmark or shared link never breaks.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
|
||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
|
||||
const source = db([]);
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(source, {
|
||||
id: 'b-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
exportHandoverMode: 'DIRECT_TO_TRAIN',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// Direct short-circuits before querying — there is no inventory to look for.
|
||||
expect(source.query as jest.Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still gates a warehouse export booking', async () => {
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(db([]), {
|
||||
id: 'b-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
exportHandoverMode: 'WAREHOUSE',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
|
||||
export interface ExportLoadGateBooking {
|
||||
id: string;
|
||||
tradeDirection?: string | null;
|
||||
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
|
||||
exportHandoverMode?: string | null;
|
||||
}
|
||||
|
||||
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
|
||||
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
|
||||
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
|
||||
export const WAREHOUSE = 'WAREHOUSE';
|
||||
|
||||
/**
|
||||
* Export cargo may not be loaded onto its train until it has physically reached
|
||||
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
|
||||
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||
* fallback older rows use.
|
||||
*
|
||||
* Export has a second, warehouse-free shape: the customer's truck loads straight
|
||||
* onto the wagon. That cargo is never received and never GRN'd, so a booking
|
||||
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
|
||||
* attested by the carriage acceptance sheet instead.
|
||||
*/
|
||||
export async function assertExportReceivedWithGrn(
|
||||
db: DataSource | EntityManager,
|
||||
booking: ExportLoadGateBooking,
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'EXPORT') return;
|
||||
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
|
||||
|
||||
const [row] = await db.query(
|
||||
`SELECT 1
|
||||
|
||||
199
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal file
199
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { Repository } from "typeorm";
|
||||
import {
|
||||
BaseRepository,
|
||||
RequestLogMiddleware,
|
||||
getLogContext,
|
||||
logCtx,
|
||||
runWithLogContext,
|
||||
} from "@edr/api-common";
|
||||
|
||||
describe("logCtx", () => {
|
||||
it("is a no-op outside a request", () => {
|
||||
expect(() => logCtx({ bookingId: "b1" })).not.toThrow();
|
||||
expect(getLogContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects data points across the request and isolates concurrent ones", async () => {
|
||||
const collect = async (id: string) =>
|
||||
runWithLogContext({ requestId: id }, async () => {
|
||||
logCtx({ bookingId: id });
|
||||
await Promise.resolve();
|
||||
logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" });
|
||||
logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" });
|
||||
logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" });
|
||||
logCtx(1, { path: "smsSent", mode: "count" });
|
||||
logCtx(1, { path: "smsSent", mode: "count" });
|
||||
logCtx("PAID", { path: "payment.state", mode: "set" });
|
||||
logCtx({ ignored: true }, (ctx) => {
|
||||
ctx.custom = "yes";
|
||||
});
|
||||
return getLogContext();
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([collect("r1"), collect("r2")]);
|
||||
|
||||
expect(a).toEqual({
|
||||
requestId: "r1",
|
||||
bookingId: "r1",
|
||||
booking: { status: { from: "DRAFT", to: "SUBMITTED" } },
|
||||
wagons: [{ wagonId: "w1" }, { wagonId: "w2" }],
|
||||
smsSent: 2,
|
||||
payment: { state: "PAID" },
|
||||
custom: "yes",
|
||||
});
|
||||
expect(b?.requestId).toBe("r2");
|
||||
expect(b?.bookingId).toBe("r2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequestLogMiddleware", () => {
|
||||
it("emits one canonical JSON line carrying the collected context", () => {
|
||||
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
|
||||
// "[Nest] … LOG [request]" prefix in front of it.
|
||||
const lines: string[] = [];
|
||||
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
lines.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||
|
||||
const listeners: Record<string, () => void> = {};
|
||||
const req = {
|
||||
method: "POST",
|
||||
url: "/api/bookings/1/submit",
|
||||
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||
baseUrl: "/api/bookings",
|
||||
route: { path: "/:id/submit" },
|
||||
headers: {
|
||||
"user-agent": "jest",
|
||||
"x-request-id": "req-42",
|
||||
authorization: "Bearer tok",
|
||||
"x-client-app": "freight-backoffice",
|
||||
"current-project-id": "proj-3",
|
||||
},
|
||||
ip: "10.0.0.1",
|
||||
query: { dry: "1" },
|
||||
user: {
|
||||
id: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
status: "ACTIVE",
|
||||
username: "nati",
|
||||
email: "nati@example.com",
|
||||
phoneNumber: "0911000000",
|
||||
name: { en: "Nati" },
|
||||
roles: [{ key: "freight_operations" }],
|
||||
permissions: [{ key: "a" }, { key: "b" }],
|
||||
employee: {
|
||||
id: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
position: {
|
||||
id: "pos-5",
|
||||
key: "ops_officer",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
positionType: { key: "operations" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: 409,
|
||||
writableEnded: true,
|
||||
setHeader: jest.fn(),
|
||||
on: (event: string, fn: () => void) => {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
};
|
||||
|
||||
new RequestLogMiddleware().use(req, res, () => {
|
||||
logCtx({ bookingId: "b-1" });
|
||||
logCtx("REJECTED", { path: "booking.outcome", mode: "set" });
|
||||
});
|
||||
listeners.finish();
|
||||
listeners.close(); // aborts/close after finish must not double-log
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].endsWith("\n")).toBe(true);
|
||||
expect(lines[0].startsWith("{")).toBe(true);
|
||||
expect(JSON.parse(lines[0])).toMatchObject({
|
||||
level: "warn",
|
||||
logger: "request",
|
||||
type: "http_request",
|
||||
requestId: "req-42",
|
||||
method: "POST",
|
||||
route: "/api/bookings/:id/submit",
|
||||
url: "/api/bookings/1/submit?dry=1",
|
||||
status: 409,
|
||||
userId: "u-7",
|
||||
ip: "10.0.0.1",
|
||||
userAgent: "jest",
|
||||
query: { dry: "1" },
|
||||
bookingId: "b-1",
|
||||
booking: { outcome: "REJECTED" },
|
||||
});
|
||||
expect(JSON.parse(lines[0]).auth).toEqual({
|
||||
authenticated: true,
|
||||
hasBearer: true,
|
||||
clientApp: "freight-backoffice",
|
||||
userId: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
userStatus: "ACTIVE",
|
||||
roles: ["freight_operations"],
|
||||
permissionCount: 2,
|
||||
employeeId: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
positionId: "pos-5",
|
||||
positionKey: "ops_officer",
|
||||
positionType: "operations",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
projectId: "proj-3",
|
||||
});
|
||||
// No personal data reaches the line, whatever the token carried.
|
||||
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseRepository write trail", () => {
|
||||
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
|
||||
constructor(repo: Repository<{ id: string; status?: string }>) {
|
||||
super(repo);
|
||||
}
|
||||
}
|
||||
|
||||
const typeormRepo = {
|
||||
metadata: { tableName: "booking" },
|
||||
create: (data: unknown) => data,
|
||||
save: async (data: unknown) => data,
|
||||
update: async () => undefined,
|
||||
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
|
||||
softDelete: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
} as unknown as Repository<{ id: string; status?: string }>;
|
||||
|
||||
it("records creates, status changes and deletes without any service opting in", async () => {
|
||||
const ctx = await runWithLogContext({}, async () => {
|
||||
const repo = new TestRepo(typeormRepo);
|
||||
await repo.create({ id: "b-1" });
|
||||
await repo.update("b-1", { status: "SUBMITTED" });
|
||||
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
|
||||
await repo.softDelete("b-1");
|
||||
return getLogContext();
|
||||
});
|
||||
|
||||
expect(ctx).toEqual({
|
||||
db: { created: { booking: 1 }, updated: { booking: 2 } },
|
||||
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
|
||||
deleted: [{ entity: "booking", id: "b-1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export interface FacetBucket {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One `GROUP BY` query per faceted column, each with every OTHER active
|
||||
* filter applied but its OWN predicate omitted. That omission is the point:
|
||||
* with `status=SUBMITTED` selected, the status facet still reports
|
||||
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
|
||||
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
|
||||
* not a pill, and stays applied in every facet.
|
||||
*
|
||||
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
|
||||
* have real cardinality; beyond 50 the frontend should fall back to a
|
||||
* typeahead instead of a checkbox list. Never facet a column whose popover
|
||||
* would need its own search box (references, plate numbers, free text).
|
||||
*
|
||||
* @param base builds a FRESH query builder (soft-delete guard only,
|
||||
* no filters) — called once per facet column.
|
||||
* @param applyFilters applies every filter to `qb`, using `omit` to skip
|
||||
* one column's own predicate.
|
||||
* @param columns facet key -> "alias.column" SQL reference.
|
||||
*/
|
||||
export async function computeFacets<T extends ObjectLiteral>(
|
||||
base: () => SelectQueryBuilder<T>,
|
||||
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
|
||||
columns: Record<string, string>,
|
||||
): Promise<Record<string, FacetBucket[]>> {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(columns).map(async ([key, column]) => {
|
||||
const qb = base();
|
||||
applyFilters(qb, key);
|
||||
const rows = await qb
|
||||
.select(column, 'value')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.andWhere(`${column} IS NOT NULL`)
|
||||
.groupBy(column)
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(50)
|
||||
.getRawMany<{ value: string; count: number }>();
|
||||
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
/**
|
||||
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||
* plain `String(name)` there yields "[object Object]" in an audit trail.
|
||||
*/
|
||||
export interface IamUserRow {
|
||||
name?: Record<string, string> | string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Best display name for a user row: English label → any locale → login → email. */
|
||||
export function pickUserName(user: IamUserRow): string | null {
|
||||
const { name } = user;
|
||||
if (typeof name === "string" && name.trim()) return name.trim();
|
||||
if (name && typeof name === "object") {
|
||||
const localized =
|
||||
name.en ??
|
||||
Object.values(name).find((v) => typeof v === "string" && v.trim());
|
||||
if (localized?.trim()) return localized.trim();
|
||||
}
|
||||
return user.username?.trim() || user.email?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display names for a set of IAM user ids — one query for the whole set.
|
||||
* `iam.users` is owned by the auth system and has no entity here, so it is read
|
||||
* directly. A miss is not an error: the caller still holds the id and can fall
|
||||
* back to it.
|
||||
*/
|
||||
export async function resolveIamUserNames(
|
||||
dataSource: DataSource,
|
||||
userIds: (string | null | undefined)[],
|
||||
): Promise<Map<string, string>> {
|
||||
const resolved = new Map<string, string>();
|
||||
const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
|
||||
if (ids.length === 0) return resolved;
|
||||
|
||||
const rows = (await dataSource.query(
|
||||
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||
[ids],
|
||||
)) as Array<IamUserRow & { id: string }>;
|
||||
for (const row of rows) {
|
||||
const name = pickUserName(row);
|
||||
if (name) resolved.set(row.id, name);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util';
|
||||
|
||||
/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */
|
||||
function fakeQb() {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||
const qb = {
|
||||
alias: 'contract',
|
||||
orderBy(...args: unknown[]) {
|
||||
calls.push({ method: 'orderBy', args });
|
||||
return qb;
|
||||
},
|
||||
addOrderBy(...args: unknown[]) {
|
||||
calls.push({ method: 'addOrderBy', args });
|
||||
return qb;
|
||||
},
|
||||
};
|
||||
return { qb: qb as unknown as SelectQueryBuilder<any>, calls };
|
||||
}
|
||||
|
||||
const SORTABLE = {
|
||||
createdAt: 'contract.createdAt',
|
||||
contractValidUntil: 'contract.contractValidUntil',
|
||||
};
|
||||
|
||||
describe('applySort', () => {
|
||||
it('resolves a whitelisted sortBy to its column', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt');
|
||||
expect(calls[0]).toEqual({
|
||||
method: 'orderBy',
|
||||
args: ['contract.contractValidUntil', 'ASC'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default column for an unknown sortBy instead of throwing', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
// A stale bookmark or shared link naming a removed/renamed column must
|
||||
// never 400 — it should silently behave as if sortBy were absent.
|
||||
expect(() =>
|
||||
applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'),
|
||||
).not.toThrow();
|
||||
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||
});
|
||||
|
||||
it('defaults sortOrder to DESC when absent or not ASC', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, {}, SORTABLE, 'createdAt');
|
||||
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||
});
|
||||
|
||||
it('always appends an id ASC tiebreaker', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt');
|
||||
expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePagination / buildPaginationMeta', () => {
|
||||
it('clamps page to >= 1 and pageSize to the configured max', () => {
|
||||
const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 });
|
||||
expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 });
|
||||
});
|
||||
|
||||
it('computes hasNextPage/hasPreviousPage from total', () => {
|
||||
const meta = buildPaginationMeta(45, 2, 20);
|
||||
expect(meta).toEqual({
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 45,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,3 +83,32 @@ export function paginateArray<T>(
|
||||
meta: buildPaginationMeta(rows.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a
|
||||
* whitelist — never interpolate `sortBy` into a query builder directly, it is
|
||||
* unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)`
|
||||
* is a SQL-injection primitive (see the deleted `findAllWithFilters` methods
|
||||
* on drivers/vehicles repositories, which had exactly that bug).
|
||||
*
|
||||
* An unknown `sortBy` falls back to `fallback` instead of throwing — a stale
|
||||
* bookmark or shared link should never 400.
|
||||
*
|
||||
* Always appends `id ASC` as a tiebreaker: sorting by a non-unique column
|
||||
* (status, createdAt on bulk-imported rows) without one can drop or
|
||||
* duplicate rows across pages once LIMIT/OFFSET is involved.
|
||||
*
|
||||
* @param sortable public sort key -> "alias.column" SQL reference. Also
|
||||
* doubles as the Swagger enum / frontend's sortable-column list.
|
||||
* @param fallback a key that must exist in `sortable`.
|
||||
*/
|
||||
export function applySort<T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' },
|
||||
sortable: Record<string, string>,
|
||||
fallback: string,
|
||||
): SelectQueryBuilder<T> {
|
||||
const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback];
|
||||
qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC');
|
||||
return qb.addOrderBy(`${qb.alias}.id`, 'ASC');
|
||||
}
|
||||
|
||||
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export interface ChatConfig {
|
||||
enabled: boolean;
|
||||
/** Synapse base URL reachable from this container (client + admin APIs). */
|
||||
baseUrl: string;
|
||||
/** Synapse's public_baseurl — what Element itself is configured to call. Only
|
||||
* used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */
|
||||
publicBaseUrl: string;
|
||||
/** Public Element Web origin — the SSO handoff link points here. */
|
||||
webUrl: string;
|
||||
/** Matrix server_name — the `:domain` half of every MXID. */
|
||||
serverName: string;
|
||||
/** HS256 secret. Must exactly match Synapse's jwt_config.secret. */
|
||||
jwtSecret: string;
|
||||
/** Bearer token for a Synapse server admin account (room/user provisioning). */
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
'MATRIX_BASE_URL',
|
||||
'MATRIX_PUBLIC_BASE_URL',
|
||||
'MATRIX_CHAT_WEB_URL',
|
||||
'MATRIX_SERVER_NAME',
|
||||
'MATRIX_JWT_SECRET',
|
||||
'MATRIX_ADMIN_TOKEN',
|
||||
] as const;
|
||||
|
||||
export default registerAs('chat', (): ChatConfig => {
|
||||
const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
publicBaseUrl: '',
|
||||
webUrl: '',
|
||||
serverName: '',
|
||||
jwtSecret: '',
|
||||
adminToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''),
|
||||
publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''),
|
||||
webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''),
|
||||
serverName: process.env.MATRIX_SERVER_NAME!,
|
||||
jwtSecret: process.env.MATRIX_JWT_SECRET!,
|
||||
adminToken: process.env.MATRIX_ADMIN_TOKEN!,
|
||||
};
|
||||
});
|
||||
@@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
|
||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
|
||||
|
||||
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
|
||||
// the glob below only matches this app's own src/**/*.entity.ts.
|
||||
const auditEntities = [AuditLog, AuditLogCommand];
|
||||
|
||||
const iamEntities = [
|
||||
UnitSetting,
|
||||
@@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions {
|
||||
entities: [
|
||||
__dirname + "/../**/*.entity.{ts,js}",
|
||||
...iamEntities,
|
||||
...auditEntities,
|
||||
],
|
||||
migrations: [],
|
||||
};
|
||||
@@ -215,8 +209,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}));
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}),
|
||||
);
|
||||
|
||||
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import eimsConfigFactory from "./eims.config";
|
||||
|
||||
const REQUIRED = {
|
||||
EIMS_ENABLED: "true",
|
||||
EIMS_CLIENT_ID: "cid",
|
||||
EIMS_CLIENT_SECRET: "secret",
|
||||
EIMS_API_KEY: "apikey",
|
||||
EIMS_TIN: "0000000000",
|
||||
};
|
||||
|
||||
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
|
||||
const prior: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
prior[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("eims.config — private key / certificate resolution", () => {
|
||||
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a PEM with real newlines untouched", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws naming all three key/cert options when none are set", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY_PATH: undefined,
|
||||
EIMS_PRIVATE_KEY_BASE64: undefined,
|
||||
EIMS_PRIVATE_KEY: undefined,
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
},
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).toThrow(
|
||||
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("is satisfied by any single one of the three key options", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
|
||||
it("resolves a known region/wereda/zone with no env var set at all", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const cfg = eimsConfigFactory();
|
||||
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
|
||||
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
|
||||
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var entry overrides the baked-in code for the same name", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_REGION_CODES: "Somali=99",
|
||||
},
|
||||
() => {
|
||||
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_CITY_CODES: "Fafen=01",
|
||||
},
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerCityCodes;
|
||||
expect(codes.Fafen).toBe("01");
|
||||
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
|
||||
expect(codes.Bole).toBe("01");
|
||||
expect(codes.Arada).toBe("01");
|
||||
expect(codes.Kirkos).toBe("01");
|
||||
expect(codes.Yeka).toBe("01");
|
||||
expect(codes["Nifas Silk Lafto"]).toBe("13");
|
||||
expect(codes["Nefas Silk-Lafto"]).toBe("13");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
291
apps/edr-freight-api/src/config/eims.config.ts
Normal file
291
apps/edr-freight-api/src/config/eims.config.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
|
||||
|
||||
/**
|
||||
* Ethiopian MoR EIMS e-invoicing gateway.
|
||||
*
|
||||
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
|
||||
* service throws a clear error on use, so a deployment without credentials still boots.
|
||||
*
|
||||
* Secrets (client secret, API key) and the credential file paths live only here and are never
|
||||
* logged — validation reports missing variable *names*, never their values.
|
||||
*/
|
||||
export interface EimsConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
apiKey: string;
|
||||
tin: string;
|
||||
/**
|
||||
* Optional *expectations* for the source-system identity, not inputs.
|
||||
*
|
||||
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
|
||||
* that authenticated, and those are what registration uses. When these are set they are compared
|
||||
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
|
||||
* take whatever the gateway says.
|
||||
*/
|
||||
systemNumber: string;
|
||||
systemType: string;
|
||||
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
|
||||
privateKeyPath: string;
|
||||
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
||||
certificatePath: string;
|
||||
/**
|
||||
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
|
||||
* container that can't be given a host bind mount can still receive it as a plain env var.
|
||||
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
|
||||
* > `privateKeyPath`.
|
||||
*/
|
||||
privateKeyBase64: string;
|
||||
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
|
||||
certificateBase64: string;
|
||||
/**
|
||||
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
|
||||
* three inline forms, and the hardest for a broken transport step to mangle since there's no
|
||||
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
|
||||
*/
|
||||
privateKeyPem: string;
|
||||
/** Inline alternative to `certificateBase64`, same precedence rule. */
|
||||
certificatePem: string;
|
||||
httpTimeoutMs: number;
|
||||
/** Re-authenticate this many ms before the access token actually expires. */
|
||||
tokenSkewMs: number;
|
||||
/**
|
||||
* Automatic submission of issued invoices, off by default.
|
||||
*
|
||||
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
|
||||
* action — but enabling it starts filing real documents with the tax authority, which is
|
||||
* irreversible from our side. It therefore needs its own deliberate switch, separate from
|
||||
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
|
||||
*/
|
||||
autoSubmit: boolean;
|
||||
autoSubmitCron: string;
|
||||
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
|
||||
autoSubmitMaxAgeDays: number;
|
||||
/**
|
||||
* Seller identity and tax/business treatment for the invoice document.
|
||||
*
|
||||
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
|
||||
* codebase, and the app models no tax at all. Values are required at registration time and are
|
||||
* validated there rather than at boot, so a deployment can run with EIMS enabled for
|
||||
* authentication before finance has signed off on the tax treatment.
|
||||
*/
|
||||
invoice: EimsInvoiceConfig;
|
||||
}
|
||||
|
||||
export interface EimsInvoiceConfig {
|
||||
sellerLegalName: string;
|
||||
sellerVatNumber: string;
|
||||
sellerPhone: string;
|
||||
sellerEmail: string;
|
||||
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
|
||||
sellerRegion: string;
|
||||
sellerWereda: string;
|
||||
sellerCity: string | null;
|
||||
sellerSubCity: string | null;
|
||||
sellerHouseNumber: string | null;
|
||||
sellerLocality: string | null;
|
||||
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
|
||||
taxCode: string;
|
||||
taxRatePercent: number | null;
|
||||
exciseTaxValue: number | null;
|
||||
incomeWithholdValue: number | null;
|
||||
transactionWithholdValue: number | null;
|
||||
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
|
||||
transactionType: string;
|
||||
natureOfSupplies: string;
|
||||
paymentMode: string;
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
/**
|
||||
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
||||
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
||||
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
||||
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
||||
*/
|
||||
buyerCountryCode: string | null;
|
||||
/**
|
||||
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
||||
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
||||
* this is not validated against a fixed digit pattern, only looked up by name.
|
||||
*/
|
||||
buyerCountryCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||
* locally rather than being filed with a guessed one.
|
||||
*/
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||
* mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
|
||||
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
|
||||
* cannot express that. Values are raw strings; the context builder parses/validates them.
|
||||
*/
|
||||
taxCodeByChargeType: Record<string, string>;
|
||||
taxRateByChargeType: Record<string, string>;
|
||||
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
|
||||
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
|
||||
exciseByChargeType: Record<string, string>;
|
||||
discountByChargeType: Record<string, string>;
|
||||
cashierName: string | null;
|
||||
salesPersonName: string | null;
|
||||
/**
|
||||
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
|
||||
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
|
||||
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
|
||||
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
|
||||
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
|
||||
*/
|
||||
buyerIdType: string | null;
|
||||
buyerIdNumber: string | null;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
||||
|
||||
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
|
||||
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||
const REQUIRED_ANY_OF: string[][] = [
|
||||
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
|
||||
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
|
||||
];
|
||||
|
||||
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
|
||||
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const pair of (raw ?? "").split(",")) {
|
||||
const [name, code] = pair.split("=");
|
||||
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
|
||||
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
|
||||
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
|
||||
const normalizePem = (raw: string | undefined): string => {
|
||||
if (!raw) return "";
|
||||
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
|
||||
};
|
||||
|
||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export default registerAs("eims", (): EimsConfig => {
|
||||
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
|
||||
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
|
||||
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
|
||||
const tokenSkewMs =
|
||||
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
|
||||
|
||||
const base: EimsConfig = {
|
||||
enabled,
|
||||
baseUrl,
|
||||
clientId: process.env.EIMS_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
|
||||
apiKey: process.env.EIMS_API_KEY ?? "",
|
||||
tin: process.env.EIMS_TIN ?? "",
|
||||
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
|
||||
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
||||
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
||||
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
||||
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
|
||||
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
|
||||
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
|
||||
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
|
||||
httpTimeoutMs,
|
||||
tokenSkewMs,
|
||||
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
|
||||
// misconfiguration from filing a burst of bad documents before anyone notices.
|
||||
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
|
||||
autoSubmitMaxAgeDays: positiveInt(
|
||||
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
|
||||
3,
|
||||
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
|
||||
),
|
||||
invoice: {
|
||||
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
|
||||
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
|
||||
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
|
||||
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
|
||||
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
|
||||
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
|
||||
sellerCity: process.env.EIMS_SELLER_CITY || null,
|
||||
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
|
||||
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
|
||||
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
|
||||
taxCode: process.env.EIMS_TAX_CODE ?? "",
|
||||
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
|
||||
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
|
||||
incomeWithholdValue: optionalNumber(
|
||||
process.env.EIMS_INCOME_WITHHOLD_VALUE,
|
||||
"EIMS_INCOME_WITHHOLD_VALUE",
|
||||
),
|
||||
transactionWithholdValue: optionalNumber(
|
||||
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
|
||||
"EIMS_TRANSACTION_WITHHOLD_VALUE",
|
||||
),
|
||||
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
|
||||
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
|
||||
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
|
||||
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
|
||||
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
|
||||
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
|
||||
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
|
||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
|
||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
|
||||
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
|
||||
},
|
||||
};
|
||||
|
||||
if (!enabled) return base;
|
||||
|
||||
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
for (const vars of REQUIRED_ANY_OF) {
|
||||
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return base;
|
||||
});
|
||||
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
|
||||
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
|
||||
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
|
||||
*
|
||||
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
|
||||
* someone hunted down the code and added it to an env var by hand — happened three times in one
|
||||
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
|
||||
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
|
||||
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
|
||||
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
|
||||
* not all ~1000 real woredas), extend as new gaps surface.
|
||||
*
|
||||
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
|
||||
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
|
||||
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
|
||||
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
|
||||
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
|
||||
*
|
||||
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
|
||||
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
|
||||
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
|
||||
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
|
||||
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
|
||||
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
|
||||
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
|
||||
*/
|
||||
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
|
||||
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
|
||||
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
|
||||
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
|
||||
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
|
||||
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
|
||||
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
|
||||
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
|
||||
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
|
||||
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
|
||||
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
|
||||
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
|
||||
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
|
||||
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
|
||||
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
|
||||
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
|
||||
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
|
||||
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
|
||||
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
|
||||
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
|
||||
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
|
||||
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
|
||||
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
|
||||
["Amhara", "Awi", "Injibara", "03", "09", "01"],
|
||||
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
|
||||
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
|
||||
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
|
||||
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
|
||||
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
|
||||
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
|
||||
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
|
||||
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
|
||||
["Oromia", "Arsi", "Asella", "04", "05", "01"],
|
||||
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
|
||||
["Oromia", "Bale", "Robe", "04", "07", "01"],
|
||||
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
|
||||
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
|
||||
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
|
||||
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
|
||||
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
|
||||
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
|
||||
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
|
||||
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
|
||||
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
|
||||
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
|
||||
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
|
||||
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
|
||||
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
|
||||
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
|
||||
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
|
||||
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
|
||||
["Somali", "Fafan", "Awbare", "05", "01", "03"],
|
||||
["Somali", "Sitti", "Shinile", "05", "02", "01"],
|
||||
["Somali", "Erer", "Fiq", "05", "03", "01"],
|
||||
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
|
||||
["Somali", "Nogob", "Segeg", "05", "05", "01"],
|
||||
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
|
||||
["Somali", "Shabelle", "Gode", "05", "07", "01"],
|
||||
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
|
||||
["Somali", "Liben", "Filtu", "05", "09", "01"],
|
||||
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
|
||||
["Somali", "Dollo", "Warder", "05", "11", "01"],
|
||||
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
|
||||
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
|
||||
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
|
||||
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
|
||||
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
|
||||
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
|
||||
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
|
||||
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
|
||||
["Gambela", "Nuer", "Lare", "08", "02", "01"],
|
||||
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
|
||||
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
|
||||
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
|
||||
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
|
||||
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
|
||||
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
|
||||
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
|
||||
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
|
||||
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
|
||||
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
|
||||
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
|
||||
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
|
||||
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
|
||||
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
|
||||
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
|
||||
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
|
||||
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
|
||||
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
|
||||
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
|
||||
];
|
||||
|
||||
/** First occurrence wins on a name collision — see the class comment. */
|
||||
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of ROWS) {
|
||||
const [name, code] = pick(row);
|
||||
if (!(name in map)) map[name] = code;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
|
||||
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
|
||||
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
|
||||
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
|
||||
|
||||
/**
|
||||
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
|
||||
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
|
||||
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
|
||||
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
|
||||
* same code rather than wait on a fuller table.
|
||||
*/
|
||||
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
|
||||
["Bole", "Bole Sub-City"],
|
||||
["Kirkos", "Kirkos Sub-City"],
|
||||
["Nifas Silk Lafto", "Nifas Silk Lafto"],
|
||||
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
|
||||
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
|
||||
["Yeka", "Yeka Sub-City"],
|
||||
["Arada", "Arada Sub-City"],
|
||||
];
|
||||
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
|
||||
const row = ROWS.find((r) => r[1] === csvZoneName);
|
||||
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
|
||||
}
|
||||
@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder";
|
||||
|
||||
/**
|
||||
* The EDR side of a contract is sealed with the ONE global company stamp, read
|
||||
* live at render time; the client side keeps whatever stamp the customer
|
||||
* uploaded. These specs pin that asymmetry — the standing rule is that
|
||||
* centralizing the EDR seal must not touch customer stamps.
|
||||
*/
|
||||
describe("ContractViewModelBuilder.attachProviderStamp", () => {
|
||||
const STAMP = "data:image/png;base64,RURS";
|
||||
|
||||
const build = (stampImageUrl: string | null = STAMP) => {
|
||||
const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl);
|
||||
const builder = Object.create(
|
||||
ContractViewModelBuilder.prototype,
|
||||
) as ContractViewModelBuilder;
|
||||
Object.assign(builder, { stampSettings: { getStampImageUrl } });
|
||||
return { builder, getStampImageUrl };
|
||||
};
|
||||
|
||||
const sig = (role: "STAFF" | "CUSTOMER", extra: Partial<ContractSignatureView> = {}) =>
|
||||
({
|
||||
role,
|
||||
signerDisplayName: `${role} signer`,
|
||||
signedAt: "1 January 2026",
|
||||
signatureImageUrl: "https://minio.local/sig.png",
|
||||
...extra,
|
||||
}) as ContractSignatureView;
|
||||
|
||||
it("stamps the EDR side with the global stamp", async () => {
|
||||
const { builder } = build();
|
||||
const signatures = [sig("STAFF")];
|
||||
|
||||
await builder.attachProviderStamp(signatures);
|
||||
|
||||
expect(signatures[0]!.stampImageUrl).toBe(STAMP);
|
||||
});
|
||||
|
||||
it("leaves the customer side untouched", async () => {
|
||||
const { builder } = build();
|
||||
const customerStamp = "data:image/png;base64,Q1VTVA==";
|
||||
const signatures = [
|
||||
sig("CUSTOMER", { stampImageUrl: customerStamp }),
|
||||
sig("STAFF"),
|
||||
];
|
||||
|
||||
await builder.attachProviderStamp(signatures);
|
||||
|
||||
expect(signatures[0]!.stampImageUrl).toBe(customerStamp);
|
||||
expect(signatures[1]!.stampImageUrl).toBe(STAMP);
|
||||
});
|
||||
|
||||
it("does not read the stamp at all when EDR has not signed yet", async () => {
|
||||
const { builder, getStampImageUrl } = build();
|
||||
const signatures = [sig("CUSTOMER")];
|
||||
|
||||
await builder.attachProviderStamp(signatures);
|
||||
|
||||
expect(getStampImageUrl).not.toHaveBeenCalled();
|
||||
expect(signatures[0]!.stampImageUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders unstamped rather than failing when no stamp is configured", async () => {
|
||||
const { builder } = build(null);
|
||||
const signatures = [sig("STAFF")];
|
||||
|
||||
await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined();
|
||||
expect(signatures[0]!.stampImageUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the stamp once for every EDR signature row", async () => {
|
||||
const { builder, getStampImageUrl } = build();
|
||||
const signatures = [sig("STAFF"), sig("STAFF")];
|
||||
|
||||
await builder.attachProviderStamp(signatures);
|
||||
|
||||
expect(getStampImageUrl).toHaveBeenCalledTimes(1);
|
||||
expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]);
|
||||
});
|
||||
|
||||
it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => {
|
||||
const { builder } = build();
|
||||
Object.assign(builder, {
|
||||
bookingsRepository: {
|
||||
findContractSignatures: jest.fn().mockResolvedValue([
|
||||
{ signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() },
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const views = await (
|
||||
builder as unknown as {
|
||||
loadSignatures(id: string): Promise<ContractSignatureView[]>;
|
||||
}
|
||||
).loadSignatures("b-1");
|
||||
|
||||
expect(views[0]!.stampImageUrl).toBe(STAMP);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
|
||||
PER_CONTAINER: 'per container',
|
||||
PER_KM: 'per km',
|
||||
PER_TON_KM: 'per ton per km',
|
||||
PER_LITER: 'per liter',
|
||||
PER_INVOICE: 'per invoice',
|
||||
FLAT: 'flat',
|
||||
};
|
||||
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
||||
DEMURRAGE: 'Demurrage / wagon detention',
|
||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||
FUEL: 'Fuel surcharge',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||
// direction belong on its schedule, labeled with their leg.
|
||||
if (rate.trigger === 'FUEL') {
|
||||
if (this.fuelDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.fuelRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||
surcharges.push(this.surchargeRow(rate));
|
||||
}
|
||||
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
const want =
|
||||
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||
return rate.tradeDirection === want;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||
* customer only ever sees the final price.
|
||||
*/
|
||||
private fuelRow(rate: Rate): RateScheduleRow {
|
||||
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||
const destination =
|
||||
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||
const perLiter = rate.rateUnit === 'PER_LITER';
|
||||
return {
|
||||
route: `Fuel surcharge (${origin} → ${destination})`,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(
|
||||
perLiter
|
||||
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
|
||||
: rate.rateValue,
|
||||
),
|
||||
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
private surchargeRow(rate: Rate): RateScheduleRow {
|
||||
return {
|
||||
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
|
||||
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
|
||||
import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
export interface ContractSignatureView {
|
||||
@@ -16,6 +18,11 @@ export interface ContractSignatureView {
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
/**
|
||||
* Round company seal shown beside the signature. Populated for the EDR
|
||||
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
|
||||
*/
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +112,8 @@ export interface ContractViewModel {
|
||||
hasCustomerSignature: boolean;
|
||||
hasStaffSignature: boolean;
|
||||
dynamicTemplate?: ContractDynamicTemplateView;
|
||||
/** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */
|
||||
logoImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -114,6 +123,8 @@ export class ContractViewModelBuilder {
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
@@ -131,6 +142,7 @@ export class ContractViewModelBuilder {
|
||||
template.freight,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
@@ -187,6 +199,7 @@ export class ContractViewModelBuilder {
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
logoImageUrl,
|
||||
};
|
||||
|
||||
return { booking, view };
|
||||
@@ -194,7 +207,30 @@ export class ContractViewModelBuilder {
|
||||
|
||||
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
return rows.map((s) => this.toSignatureView(s));
|
||||
const views = rows.map((s) => this.toSignatureView(s));
|
||||
await this.attachProviderStamp(views);
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the EDR side of the contract with the ONE global company stamp
|
||||
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
|
||||
* stored per signature and the seal is read live at render time. The client
|
||||
* side is left alone: a customer's own stamp is their business.
|
||||
*
|
||||
* Read live and deliberately not snapshotted, so replacing the company stamp
|
||||
* re-seals contracts on their next render. `getStampImageUrl()` never throws
|
||||
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
|
||||
* signature inliner skips.
|
||||
*/
|
||||
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
|
||||
const staff = signatures.filter((s) => s.role === 'STAFF');
|
||||
if (staff.length === 0) return;
|
||||
|
||||
const stampImageUrl = await this.stampSettings.getStampImageUrl();
|
||||
for (const sig of staff) {
|
||||
sig.stampImageUrl = stampImageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
toSignatureView(row: BookingContractSignature): ContractSignatureView {
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
letter-spacing: 0.08em;
|
||||
width: 72px;
|
||||
}
|
||||
.logo-mark img {
|
||||
display: block;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.kicker {
|
||||
color: #0e5b45;
|
||||
font-family: Arial, sans-serif;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Services</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<main class="contract">
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Contract</p>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
main { padding: 32px 40px; }
|
||||
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
|
||||
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
|
||||
.logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; }
|
||||
.kicker { margin: 0; font-weight: 700; }
|
||||
.muted { margin: 0; color: #666; }
|
||||
h1 { font-size: 20px; margin: 24px 0 4px; }
|
||||
@@ -32,7 +33,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Last-Mile Delivery Contract</p>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("HTTP");
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const start = Date.now();
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
this.logger.log(
|
||||
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,25 @@ import {
|
||||
ResponseTransformInterceptor,
|
||||
createValidationPipe,
|
||||
} from "@edr/api-common";
|
||||
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
/**
|
||||
* JSON body ceiling. Signing posts the signature AND the company stamp as
|
||||
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
|
||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||
* image with a 413 "request entity too large".
|
||||
* JSON body ceiling. Customer signing posts the signature AND the customer's
|
||||
* own company stamp as base64 in one JSON body, and base64 inflates bytes by
|
||||
* ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which
|
||||
* rejected any real stamp image with a 413 "request entity too large".
|
||||
* (Staff signing posts only a signature: EDR's seal is the one global stamp,
|
||||
* read server-side. Uploading that stamp under Settings goes through this same
|
||||
* ceiling, so the headroom is still needed on both counts.)
|
||||
*
|
||||
* Sized to clear the 50MB per-document ceiling
|
||||
* (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the
|
||||
* surrounding JSON. Note that the reverse proxy applies its own
|
||||
* `client_max_body_size` and rejects oversized bodies before Nest sees them —
|
||||
* raising this alone does not lift the limit end to end (see docs/uploads.md).
|
||||
*/
|
||||
const JSON_BODY_LIMIT = "20mb";
|
||||
const JSON_BODY_LIMIT = "100mb";
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
@@ -161,11 +169,6 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||
|
||||
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
|
||||
// (app.module.ts) emits and persists them via the AuditLogController /
|
||||
// AuditLogCommandController @EventPattern handlers. Same queue config the
|
||||
// client side uses, reused from the package so the two never drift apart.
|
||||
app.connectMicroservice(getAuditLoggerConfig());
|
||||
await app.startAllMicroservices();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
const CONTAINER_CODES = [
|
||||
'IMPORT_CONTAINER_CUSTOMS',
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
'EXPORT_CONTAINER_CUSTOMS',
|
||||
'EXPORT_CONTAINER_NO_CUSTOMS',
|
||||
'INTERCITY_CONTAINER',
|
||||
];
|
||||
|
||||
const BULK_CODES = [
|
||||
'IMPORT_BULK_CUSTOMS',
|
||||
'IMPORT_BULK_NO_CUSTOMS',
|
||||
'EXPORT_BULK_CUSTOMS',
|
||||
'EXPORT_BULK_NO_CUSTOMS',
|
||||
'INTERCITY_BULK',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bulk contract templates become staff-created, keyed by (cargo type, customs
|
||||
* clearing) instead of the fixed direction codes. The five container templates
|
||||
* stay seeded and become undeletable system rows; the five seeded bulk rows are
|
||||
* retired (soft-deleted). cargo_types gains has_contract_template, marking
|
||||
* which bulk commodities may carry their own template.
|
||||
*/
|
||||
export class BulkContractTemplates3320000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id),
|
||||
ADD COLUMN IF NOT EXISTS with_customs boolean,
|
||||
ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
// Generated bulk codes (BULK_<cargo code>_NO_CUSTOMS) outgrow varchar(40).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ALTER COLUMN code TYPE varchar(80)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`,
|
||||
[CONTAINER_CODES],
|
||||
);
|
||||
|
||||
// Retire the fixed bulk templates; staff recreate them per cargo type.
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET deleted_at = now()
|
||||
WHERE code = ANY($1) AND deleted_at IS NULL`,
|
||||
[BULK_CODES],
|
||||
);
|
||||
|
||||
// Code stays unique among live rows only, so a deleted combo can be
|
||||
// recreated under the same generated code.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code
|
||||
ON freight.contract_templates (code) WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// One template per (bulk cargo type, customs option) — the "same
|
||||
// combination" rule, enforced even under concurrent creates.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
|
||||
ON freight.contract_templates (cargo_type_id, with_customs)
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD CONSTRAINT uq_contract_templates_code UNIQUE (code)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`,
|
||||
[BULK_CODES],
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS with_customs,
|
||||
DROP COLUMN IF EXISTS is_system
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* EIMS registration state.
|
||||
*
|
||||
* `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice
|
||||
* consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the
|
||||
* database-level guarantee that one IRN can never be recorded against two invoices, independent of
|
||||
* application logic.
|
||||
*
|
||||
* `freight.eims_system_state` is a single row per MoR system number holding the sequence the
|
||||
* gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn`
|
||||
* of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter
|
||||
* and the IRN chain stay consistent under concurrent submissions.
|
||||
*
|
||||
* The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and
|
||||
* the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves
|
||||
* evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set
|
||||
* when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later
|
||||
* document for this system number would chain to a stale `PreviousIrn` and registration stops until
|
||||
* a human resolves it.
|
||||
*
|
||||
* `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string
|
||||
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
|
||||
* verbatim so a compliance value is never mangled by a parse.
|
||||
*/
|
||||
export class EimsInvoiceRegistration3330000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||
ADD COLUMN IF NOT EXISTS eims_irn varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint,
|
||||
ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS eims_last_error jsonb
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn
|
||||
ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.eims_system_state (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
system_number varchar(32) NOT NULL UNIQUE,
|
||||
next_invoice_counter bigint NOT NULL DEFAULT 1,
|
||||
previous_irn varchar(64),
|
||||
in_flight_invoice_id uuid,
|
||||
in_flight_counter bigint,
|
||||
blocked_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_status,
|
||||
DROP COLUMN IF EXISTS eims_irn,
|
||||
DROP COLUMN IF EXISTS eims_invoice_counter,
|
||||
DROP COLUMN IF EXISTS eims_submitted_at,
|
||||
DROP COLUMN IF EXISTS eims_ack_date,
|
||||
DROP COLUMN IF EXISTS eims_last_error
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* EIMS document numbering.
|
||||
*
|
||||
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
|
||||
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
|
||||
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
|
||||
* on the invoice so a filed document can be traced back to it.
|
||||
*/
|
||||
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
DROP COLUMN IF EXISTS next_document_number,
|
||||
DROP COLUMN IF EXISTS in_flight_document_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Editable customer-facing copy for the portal's public pages (/help, /faq,
|
||||
* /terms, /privacy) plus the shared support-contact block, with an append-only
|
||||
* version log behind it.
|
||||
*
|
||||
* `payload` is opaque jsonb: the five documents have genuinely different shapes
|
||||
* and the help page's blocks change with the copy, so typed columns would mean
|
||||
* a migration per wording tweak. The shape is enforced by per-slug DTOs on
|
||||
* write instead.
|
||||
*
|
||||
* No rows are inserted here — `SupportContentSeeder` fills the table on first
|
||||
* boot and skips whenever it is non-empty, so a redeploy never overwrites
|
||||
* admin edits the way a migration-embedded INSERT eventually would.
|
||||
*/
|
||||
export class SupportContent3350000000000 implements MigrationInterface {
|
||||
name = "SupportContent3350000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_documents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug varchar(32) NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug
|
||||
ON freight.support_documents (slug);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_document_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id uuid NOT NULL
|
||||
REFERENCES freight.support_documents(id) ON DELETE CASCADE,
|
||||
version integer NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
actor_id uuid,
|
||||
note varchar(255),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
|
||||
// Closes the concurrent-save race: two editors saving at once cannot both
|
||||
// claim the same version number.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version
|
||||
ON freight.support_document_versions (document_id, version);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document
|
||||
ON freight.support_document_versions (document_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.support_document_versions;`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Converts the HELP document from its original fixed-block shape
|
||||
* (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form
|
||||
* `sections[]` builder, where every block is a heading plus markdown plus
|
||||
* attached media.
|
||||
*
|
||||
* Only rows still in the old shape are touched — detected by the presence of a
|
||||
* `channels` key — so this is a no-op on any environment seeded after the
|
||||
* change, and re-running it does nothing.
|
||||
*
|
||||
* The payload literal is inlined rather than imported from
|
||||
* `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing
|
||||
* forever, and that constant will keep moving.
|
||||
*
|
||||
* The rewrite also bumps `version` and writes a matching history row. The live
|
||||
* row's version always having a matching entry in
|
||||
* `support_document_versions` is the invariant the history list and rollback
|
||||
* both depend on, and a silent payload swap would break it.
|
||||
*/
|
||||
const HELP_SECTIONS = [
|
||||
{
|
||||
id: "help-walkthrough",
|
||||
heading: "Portal walkthrough",
|
||||
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.",
|
||||
media: [
|
||||
{
|
||||
id: "help-walkthrough-video",
|
||||
kind: "video",
|
||||
src: "/assets/edr-portal-guide.webm",
|
||||
caption: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help-chat",
|
||||
heading: "Chat with our team",
|
||||
body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)",
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: "help-contact",
|
||||
heading: "Contact us",
|
||||
body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.",
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: "help-topics",
|
||||
heading: "Common topics",
|
||||
body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: "help-checklist",
|
||||
heading: "What to include when you contact us",
|
||||
body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.",
|
||||
media: [],
|
||||
},
|
||||
];
|
||||
|
||||
export class SupportHelpSections3360000000000 implements MigrationInterface {
|
||||
name = "SupportHelpSections3360000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const rows: { id: string; version: number; payload: Record<string, unknown> }[] =
|
||||
await queryRunner.query(`
|
||||
SELECT id, version, payload
|
||||
FROM freight.support_documents
|
||||
WHERE slug = 'HELP' AND payload ? 'channels'
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
const payload = {
|
||||
title: row.payload.title ?? "Help & Support",
|
||||
subtitle:
|
||||
row.payload.subtitle ??
|
||||
"Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.",
|
||||
sections: HELP_SECTIONS,
|
||||
};
|
||||
const version = row.version + 1;
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.support_documents
|
||||
SET payload = $1::jsonb, version = $2, updated_at = now()
|
||||
WHERE id = $3`,
|
||||
[JSON.stringify(payload), version, row.id],
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.support_document_versions
|
||||
(document_id, version, payload, actor_id, note)
|
||||
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
|
||||
[
|
||||
row.id,
|
||||
version,
|
||||
JSON.stringify(payload),
|
||||
"Converted help page to free-form sections",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not reversible: the old fixed blocks cannot be recovered from markdown
|
||||
* sections an editor may since have rewritten. The version history holds the
|
||||
* pre-conversion payload if it is ever genuinely needed.
|
||||
*/
|
||||
public async down(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Export cargo reaches a train two ways, and until now only one was modelled.
|
||||
*
|
||||
* DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes
|
||||
* straight onto the wagon. It never enters a warehouse, so no GRN is ever
|
||||
* raised; the Carriage Acceptance Sheet is the only document handed over.
|
||||
*
|
||||
* WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is
|
||||
* the existing flow and stays gated on the GRN.
|
||||
*
|
||||
* NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill.
|
||||
*/
|
||||
export class BookingExportHandoverMode3370000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Folds each help section's `media[]` array into its markdown body.
|
||||
*
|
||||
* Attachments used to hang off the section as a separate list, rendered after
|
||||
* the text — which meant an author could not put a picture next to the sentence
|
||||
* it illustrates, and had two different places to manage media. They are now
|
||||
* embedded with markdown's image syntax, and the renderer picks `<img>` or
|
||||
* `<video>` from the file extension.
|
||||
*
|
||||
* Existing entries are converted rather than dropped: an uploaded object
|
||||
* becomes ``, and a shipped asset path or external URL
|
||||
* is kept verbatim. Sections are left alone once they have no `media` key, so
|
||||
* re-running does nothing.
|
||||
*/
|
||||
interface LegacyMedia {
|
||||
src: string;
|
||||
caption?: string | null;
|
||||
}
|
||||
|
||||
interface LegacySection {
|
||||
id: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
media?: LegacyMedia[];
|
||||
}
|
||||
|
||||
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
|
||||
function toMarkdown(item: LegacyMedia): string {
|
||||
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
|
||||
const target = isStoredObject ? `minio:${item.src}` : item.src;
|
||||
return ``;
|
||||
}
|
||||
|
||||
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
|
||||
name = "SupportHelpInlineMedia3370000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const rows: {
|
||||
id: string;
|
||||
version: number;
|
||||
payload: { sections?: LegacySection[] } & Record<string, unknown>;
|
||||
}[] = await queryRunner.query(`
|
||||
SELECT id, version, payload
|
||||
FROM freight.support_documents
|
||||
WHERE slug = 'HELP'
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
const sections = row.payload.sections ?? [];
|
||||
if (!sections.some((section) => section.media !== undefined)) continue;
|
||||
|
||||
const payload = {
|
||||
...row.payload,
|
||||
sections: sections.map(({ media, ...section }) => {
|
||||
const embeds = (media ?? []).map(toMarkdown);
|
||||
return {
|
||||
...section,
|
||||
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
const version = row.version + 1;
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.support_documents
|
||||
SET payload = $1::jsonb, version = $2, updated_at = now()
|
||||
WHERE id = $3`,
|
||||
[JSON.stringify(payload), version, row.id],
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.support_document_versions
|
||||
(document_id, version, payload, actor_id, note)
|
||||
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
|
||||
[
|
||||
row.id,
|
||||
version,
|
||||
JSON.stringify(payload),
|
||||
"Moved attachments inline into the section text",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Not reversible — see the note on SupportHelpSections3360000000000. */
|
||||
public async down(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Raises the per-field document ceiling from 10MB to 50MB.
|
||||
*
|
||||
* `max_size_mb` is what the portal enforces client-side (SmartFileInput blocks
|
||||
* the file and shows "File size exceeds the limit of NMB"), so the seeded 10
|
||||
* was the visible limit for every existing form even after the server-side caps
|
||||
* were lifted. The seeder only writes these rows on first insert, so deployed
|
||||
* environments keep their old value until this runs.
|
||||
*
|
||||
* Only rows still sitting at the old default are touched — a field an admin has
|
||||
* deliberately tuned to something else keeps that value.
|
||||
*/
|
||||
export class RaiseDocumentUploadSizeLimit3380000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "RaiseDocumentUploadSizeLimit3380000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.file_upload_fields
|
||||
ALTER COLUMN max_size_mb SET DEFAULT 50
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.file_upload_fields
|
||||
SET max_size_mb = 50
|
||||
WHERE max_size_mb = 10
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the column default only. The old per-row values are not
|
||||
* recoverable (10 and an admin-chosen 10 are indistinguishable after `up`),
|
||||
* and shrinking a customer's limit back down would reject documents they have
|
||||
* already uploaded, so the rows are deliberately left at 50.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.file_upload_fields
|
||||
ALTER COLUMN max_size_mb SET DEFAULT 10
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Backoffice audit trail for every state-changing freight endpoint.
|
||||
*
|
||||
* No `updated_at` / `deleted_at` columns, unlike every other table here: audit
|
||||
* rows are insert-only evidence. A soft-delete column would let an actor erase
|
||||
* their own trail and TypeORM would then hide those rows from default queries
|
||||
* silently — see the entity comment.
|
||||
*
|
||||
* `user_id` intentionally carries NO foreign key to the `iam` schema.
|
||||
* Cross-schema FKs are forbidden platform-wide, and one here would let user
|
||||
* deletion cascade away the record of what that user did.
|
||||
*
|
||||
* DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race
|
||||
* `migrationsRun` against each other on the shared dev database.
|
||||
*/
|
||||
export class CreateAuditLogs3390000000000 implements MigrationInterface {
|
||||
name = "CreateAuditLogs3390000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.audit_logs (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
title varchar(255) NOT NULL,
|
||||
method varchar(10) NOT NULL,
|
||||
url text NOT NULL,
|
||||
route_path varchar(255),
|
||||
type varchar(50) NOT NULL,
|
||||
is_success boolean NOT NULL,
|
||||
user_id uuid,
|
||||
resource_id varchar(64),
|
||||
request jsonb,
|
||||
status_code smallint,
|
||||
error_message text,
|
||||
user_name varchar(150),
|
||||
user_role varchar(100),
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
request_id varchar(64),
|
||||
duration_ms integer,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_audit_logs" PRIMARY KEY (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Every audit query is time-bounded, so created_at leads each index.
|
||||
// DESC matches the "newest first" read path the controller exposes.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at"
|
||||
ON freight.audit_logs (created_at DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at"
|
||||
ON freight.audit_logs (user_id, created_at DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at"
|
||||
ON freight.audit_logs (type, created_at DESC)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id"
|
||||
ON freight.audit_logs (type, resource_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at"
|
||||
ON freight.audit_logs (route_path, created_at DESC)
|
||||
`);
|
||||
// Failures are a small slice of the table but carry the security signal
|
||||
// (403s especially), so they get their own partial index.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures"
|
||||
ON freight.audit_logs (created_at DESC)
|
||||
WHERE is_success = false
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Onboarding revamp: one company, one verified identity.
|
||||
*
|
||||
* The general manager is removed outright — it named who to talk to and gated
|
||||
* nothing — and the company's people become its **owner** (whoever the eTrade
|
||||
* licence names as the business's manager) and its **Power of Attorney**.
|
||||
* Exactly one of them is identity-verified, chosen by the company's own answer
|
||||
* to "does anyone hold power of attorney for you?", stored as
|
||||
* `attributes.poaDeclared`.
|
||||
*
|
||||
* The order below matters — each step depends on data a later step destroys:
|
||||
*
|
||||
* 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall
|
||||
* through to `attributes->>'generalManagerEmail'`, and `companies.email` was
|
||||
* only ever written for a Fayda-verified owner — so every foreign company
|
||||
* had none and was reached solely through that fallback. Promote it to the
|
||||
* column before the key is stripped, or those companies stop receiving mail
|
||||
* in silence.
|
||||
* 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required
|
||||
* onboarding fields; without this every already-onboarded company would
|
||||
* report three missing fields the moment it opened its settings page.
|
||||
* 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It
|
||||
* is gone, so the companies holding it must be re-expressed:
|
||||
* - NOT a freight forwarder → "no PoA" (the owner represents themselves,
|
||||
* nothing to delegate). Their PoA details are cleared.
|
||||
* - A freight forwarder → "yes" and details KEPT. A forwarder signs on
|
||||
* other companies' behalf, so a representative is non-negotiable and the
|
||||
* waiver no longer exists. These companies will be asked for a
|
||||
* delegation paper they were previously excused — an intentional,
|
||||
* visible consequence, not an oversight. Count them before deploying.
|
||||
* 4. Derive the declaration for everyone else, from whether PoA details exist.
|
||||
* 5. Move drafts off the deleted "personnel" wizard step.
|
||||
* 6. Only now drop the columns and strip the retired attribute keys.
|
||||
*
|
||||
* Irreversible by design: `down()` restores the columns' shape but cannot
|
||||
* recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would
|
||||
* be a guess.
|
||||
*/
|
||||
export class RemoveGeneralManager3390000000000 implements MigrationInterface {
|
||||
name = "RemoveGeneralManager3390000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. Rescue the notification address before the key it lives under is gone.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET email = COALESCE(
|
||||
NULLIF(email, ''),
|
||||
NULLIF(attributes->>'ownerEmail', ''),
|
||||
NULLIF(attributes->>'generalManagerEmail', ''),
|
||||
NULLIF(attributes->>'contactPersonEmail', '')
|
||||
)
|
||||
WHERE COALESCE(email, '') = ''
|
||||
`);
|
||||
|
||||
// 2. Backfill the owner from the best source each company actually has:
|
||||
// its Fayda-verified owner claims (already under owner*), then the general
|
||||
// manager it named, then its contact person. A company with none of these
|
||||
// never finished onboarding, and will be asked on its next visit.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET attributes = attributes
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'ownerName', COALESCE(
|
||||
NULLIF(attributes->>'ownerName', ''),
|
||||
NULLIF(attributes->>'generalManagerName', ''),
|
||||
NULLIF(attributes->>'contactPersonName', '')
|
||||
),
|
||||
'ownerEmail', COALESCE(
|
||||
NULLIF(attributes->>'ownerEmail', ''),
|
||||
NULLIF(attributes->>'generalManagerEmail', ''),
|
||||
NULLIF(attributes->>'contactPersonEmail', ''),
|
||||
NULLIF(email, '')
|
||||
),
|
||||
'ownerPhone', COALESCE(
|
||||
NULLIF(attributes->>'ownerPhone', ''),
|
||||
NULLIF(attributes->>'generalManagerPhone', ''),
|
||||
NULLIF(attributes->>'contactPersonPhone', ''),
|
||||
NULLIF(phone, '')
|
||||
)
|
||||
))
|
||||
WHERE attributes IS NOT NULL
|
||||
`);
|
||||
|
||||
// 2b. Capture eTrade's manager for the owner-vs-licence check the
|
||||
// backoffice now makes. Nothing stored it before, so the best we have is
|
||||
// the owner name itself — which makes existing companies read as "matches"
|
||||
// rather than as a false mismatch on data nobody ever compared. The value
|
||||
// is refreshed for real on the company's next eTrade lookup.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET attributes = jsonb_set(
|
||||
attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName')
|
||||
)
|
||||
WHERE COALESCE(attributes->>'ownerName', '') <> ''
|
||||
AND attributes->>'etradeManagerName' IS NULL
|
||||
AND COALESCE(licence_number, '') <> ''
|
||||
`);
|
||||
|
||||
// 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA".
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail'
|
||||
- 'poaLocation' - 'poaAddress' - 'poaFaydaSub'
|
||||
- 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender')
|
||||
|| jsonb_build_object('poaDeclared', 'no')
|
||||
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id
|
||||
AND cp.type = 'freight_forwarder'
|
||||
AND cp.deleted_at IS NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 3b. Forwarders keep their representative and lose the waiver.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes')
|
||||
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id
|
||||
AND cp.type = 'freight_forwarder'
|
||||
AND cp.deleted_at IS NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 4. Everyone else: "yes" if a representative was named or the company is a
|
||||
// forwarder, "no" if it finished onboarding without one. A company still
|
||||
// mid-onboarding is left unanswered — it will be asked, which is the point.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|
||||
|| jsonb_build_object('poaDeclared', 'yes')
|
||||
WHERE c.attributes->>'poaDeclared' IS NULL
|
||||
AND (
|
||||
COALESCE(c.attributes->>'poaName', '') <> ''
|
||||
OR COALESCE(c.attributes->>'poaPhone', '') <> ''
|
||||
OR COALESCE(c.attributes->>'poaEmail', '') <> ''
|
||||
OR COALESCE(c.attributes->>'poaLocation', '') <> ''
|
||||
OR COALESCE(c.attributes->>'poaAddress', '') <> ''
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = c.id
|
||||
AND cp.type = 'freight_forwarder'
|
||||
AND cp.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|
||||
|| jsonb_build_object('poaDeclared', 'no')
|
||||
WHERE c.attributes->>'poaDeclared' IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = c.id
|
||||
AND ep.onboarding_completed = true
|
||||
AND ep.deleted_at IS NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 5. The "personnel" (general manager) wizard step no longer exists; a
|
||||
// draft resting on it would fall back to the very first step and make the
|
||||
// customer walk the whole wizard again.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles
|
||||
SET onboarding_step = 'owner'
|
||||
WHERE onboarding_step = 'personnel'
|
||||
`);
|
||||
|
||||
// 6. Retire the general manager and the flag it shared the model with.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail'
|
||||
- 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub'
|
||||
- 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone'
|
||||
- 'gmAddress' - 'gmBirthdate' - 'gmGender'
|
||||
- 'poaSameAsOwner'
|
||||
WHERE attributes IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS general_manager_name,
|
||||
DROP COLUMN IF EXISTS general_manager_email,
|
||||
DROP COLUMN IF EXISTS general_manager_phone
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the columns' shape only. The values, the `gm*` attributes and the
|
||||
* `poaSameAsOwner` flag are not recoverable — this migration folded them into
|
||||
* `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS general_manager_name varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS general_manager_email varchar(150),
|
||||
ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles
|
||||
SET onboarding_step = 'personnel'
|
||||
WHERE onboarding_step = 'owner'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company stamp/seal image stamped onto
|
||||
* generated invoice/receipt PDFs (see StampSettingsService /
|
||||
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
|
||||
* app never inserts more than one row.
|
||||
*/
|
||||
export class StampSettings3400000000000 implements MigrationInterface {
|
||||
name = "StampSettings3400000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stamp_file_id uuid REFERENCES freight.files(id),
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagons the requester specifically asked for. A transfer request stays
|
||||
* count-driven (`quantity` is what must be delivered), but a requester who
|
||||
* picked wagons off the yard desk now records WHICH ones — OCC sees the numbers
|
||||
* on the queue and the fulfil picker pre-selects them.
|
||||
*
|
||||
* Stored as a uuid[] column rather than a join table: the list is read and
|
||||
* written whole, never queried by wagon, and a preference carries no lifecycle
|
||||
* of its own (no FK — a purged wagon simply drops out of the display).
|
||||
*/
|
||||
export class TransferRequestPreferredWagons3400000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[]
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
DROP COLUMN IF EXISTS preferred_wagon_ids
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Voyage number for one departure.
|
||||
*
|
||||
* `train_number` already exists on a schedule (the run number), but operations
|
||||
* also quote a VOYAGE number — the sailing/run identifier yards and customs use
|
||||
* for a specific departure. It belongs on the schedule, not the built train: one
|
||||
* train serves many departures and each carries its own voyage.
|
||||
*
|
||||
* Nullable and un-indexed: it is display/reference data typed by staff, not a
|
||||
* lookup key, and older schedules simply have none.
|
||||
*/
|
||||
export class ScheduleVoyageNumber3410000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS voyage_number varchar(20)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS voyage_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk contract templates gain trade direction, so the unique key becomes
|
||||
* (cargo type, direction, customs option) instead of (cargo type, customs).
|
||||
*
|
||||
* Intercity is domestic and crosses no border, so it has no customs variant at
|
||||
* all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs.
|
||||
* The unique index coalesces that NULL so two intercity templates for the same
|
||||
* cargo type still collide (plain NULLs never do).
|
||||
*
|
||||
* No backfill: staff-created bulk templates are keyed by cargo_type_id and no
|
||||
* such row exists yet — the seeded direction-keyed bulk rows were retired by
|
||||
* 3320000000000 and carry a NULL cargo_type_id. The five system container
|
||||
* templates are untouched: cargo_type_id IS NULL keeps them out of both the
|
||||
* index and the check.
|
||||
*/
|
||||
export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD COLUMN IF NOT EXISTS trade_direction varchar(20)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||
ON freight.contract_templates
|
||||
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||
cargo_type_id IS NULL
|
||||
OR (
|
||||
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
|
||||
ON freight.contract_templates (cargo_type_id, with_customs)
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fuel surcharge, sold per lane + commodity:
|
||||
*
|
||||
* - cargo_types.has_fuel marks the commodities that incur it (same shape as
|
||||
* has_lashing — the booking's cargo type flag is what fires the charge).
|
||||
* - rates.base_liters carries the liters a PER_LITER fuel rate bills
|
||||
* (price = base_liters × rate_value, once per booking). NULL on every other
|
||||
* rate shape, including PER_WAGON fuel rates (wagons × rate_value).
|
||||
* - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
|
||||
* per origin → destination leg like customs clearance and container return.
|
||||
*/
|
||||
export class FuelSurcharge3430000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Shipping lines — carriers registered by backoffice staff who sign in to the
|
||||
* portal directly.
|
||||
*
|
||||
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
|
||||
* business licence, eTrade record, operational profile or onboarding state, so
|
||||
* it shares none of the customer columns. `user_id` sits on the company row
|
||||
* itself because the company IS the account — there is no contact-person row.
|
||||
*
|
||||
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
|
||||
* this API reads but never owns.
|
||||
*/
|
||||
export class ShippingLineCompany3440000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_companies_status_enum
|
||||
AS ENUM ('active', 'suspended');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
name varchar(200) NOT NULL,
|
||||
scac_code varchar(4),
|
||||
imo_number varchar(20),
|
||||
bic_code varchar(20),
|
||||
email varchar(150) NOT NULL,
|
||||
phone_number varchar(30),
|
||||
status freight.shipping_line_companies_status_enum
|
||||
NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// One login per shipping line. Partial so a soft-deleted row frees its
|
||||
// account for re-registration rather than blocking it forever.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
|
||||
ON freight.shipping_line_companies (user_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// SCAC identifies the carrier globally — two live lines cannot share one.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
|
||||
ON freight.shipping_line_companies (scac_code)
|
||||
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
|
||||
ON freight.shipping_line_companies (lower(email))
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
|
||||
ON freight.shipping_line_companies (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines book rail capacity directly, without a contract.
|
||||
*
|
||||
* A booking has always been owned by `company_id` (a customer `companies` row),
|
||||
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
|
||||
* company — it carries no TIN, licence or operational profiles. So it gets its
|
||||
* own nullable owner column rather than a synthetic company row.
|
||||
*
|
||||
* Exactly one of the two is set: `company_id` for a customer booking,
|
||||
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
|
||||
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
|
||||
* backfilling and every customer query filtering on `company_id` behaves
|
||||
* exactly as before. Government bookings already bill to a seeded government
|
||||
* company, so they satisfy the CHECK unchanged.
|
||||
*
|
||||
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
|
||||
* is cargo metadata naming the carrier line that moves the goods
|
||||
* (`freight.shipping_lines`, reference data). This column points at
|
||||
* `freight.shipping_line_companies` — the portal account — and is unrelated.
|
||||
*/
|
||||
export class BookingShippingLine3450000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
|
||||
ON freight.bookings (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
|
||||
// tables, so a shipping-line booking could not be inserted at all. Relax
|
||||
// them to nullable; their foreign keys are left in place and keep validating
|
||||
// every non-NULL value, so a customer booking is constrained exactly as
|
||||
// before. The CHECK below is what now guarantees an owner is present.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Route and service are inherited from the contract on a customer booking.
|
||||
// A shipping line initiates before any of that is known — the bare booking
|
||||
// exists only to hang documents off — so these are relaxed too and filled
|
||||
// in when the booking is completed. Existing rows all have values, and the
|
||||
// customer paths still always set them.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
|
||||
`);
|
||||
|
||||
// No FK: kept consistent with how the column is populated at the service
|
||||
// layer, and avoids a lock on shipping_line_companies during deploy.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_single_owner
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.bookings
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
for (const column of [
|
||||
"company_id",
|
||||
"company_profile_id",
|
||||
"origin_yard_id",
|
||||
"destination_yard_id",
|
||||
"service_type_id",
|
||||
"freight_type",
|
||||
]) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
|
||||
* live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
|
||||
* `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
|
||||
* prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
|
||||
* accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
|
||||
* and the system-wide reservation stuck in-flight with no block/alert (see
|
||||
* `EimsInvoiceRegistrationService` for the accompanying code fix).
|
||||
*
|
||||
* Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
|
||||
* length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
|
||||
* real production shape — guessing another fixed bound risks the exact same failure again.
|
||||
*/
|
||||
export class WidenEimsIrn3450000000000 implements MigrationInterface {
|
||||
name = "WidenEimsIrn3450000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ALTER COLUMN eims_irn TYPE text
|
||||
`);
|
||||
}
|
||||
|
||||
/** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ALTER COLUMN eims_irn TYPE varchar(64)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
|
||||
export class AddEimsSignedQr3460000000000 implements MigrationInterface {
|
||||
name = "AddEimsSignedQr3460000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_signed_qr text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_signed_qr
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines consume services before paying for them.
|
||||
*
|
||||
* A shipping line books rail capacity and the booking proceeds with no payment
|
||||
* gate at all — unlike a customer booking, which cannot advance until its
|
||||
* PREPAID invoice settles. What the line owes is instead recorded here as a
|
||||
* credit: one row per booking, priced once and never recalculated. Finance
|
||||
* later selects a batch of unbilled credits, generates a single invoice for
|
||||
* them, and the line pays that invoice through the normal CBE flow. When the
|
||||
* invoice settles, its credits are marked paid and stop counting as debt.
|
||||
*
|
||||
* This is deliberately NOT a wallet or a stored balance. There is no money in
|
||||
* the system to draw down: a credit is a debt the line already incurred, so
|
||||
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
|
||||
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
|
||||
*
|
||||
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
|
||||
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
|
||||
* shipping line is not a `companies` row and never will be, so an invoice
|
||||
* billed to one has no customer to point at. Both FKs stay in place and keep
|
||||
* validating every non-NULL value, so a customer invoice is constrained
|
||||
* exactly as before; the CHECK below is what now guarantees a payer exists.
|
||||
*/
|
||||
export class ShippingLineCredits3460000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Invoices: allow a shipping-line payer ────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
|
||||
ON freight.invoices (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
|
||||
// answer "who owes this?" the same way. Existing rows all have company_id
|
||||
// and a NULL shipping_line_company_id, so nothing needs backfilling.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD CONSTRAINT chk_invoices_single_payer
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── The credit ledger ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
|
||||
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
shipping_line_company_id uuid NOT NULL,
|
||||
booking_id uuid NOT NULL,
|
||||
amount numeric(14,2) NOT NULL,
|
||||
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
|
||||
status freight.shipping_line_credits_status_enum
|
||||
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
|
||||
description character varying(255),
|
||||
invoice_id uuid,
|
||||
billed_at timestamp with time zone,
|
||||
paid_at timestamp with time zone,
|
||||
cancelled_at timestamp with time zone,
|
||||
cancellation_reason character varying(255),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
|
||||
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
|
||||
-- The state machine, enforced in the DB rather than trusted to the
|
||||
-- service: an UNBILLED credit has no invoice, and anything past
|
||||
-- UNBILLED must name the invoice it was billed on. Without this a
|
||||
-- half-applied batch could leave BILLED rows with a NULL invoice_id
|
||||
-- and silently vanish from both the unbilled list and the invoice.
|
||||
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
|
||||
(status = 'UNBILLED' AND invoice_id IS NULL)
|
||||
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
|
||||
OR status = 'CANCELLED'
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_booking
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// SET NULL rather than CASCADE: deleting an invoice must never delete the
|
||||
// record of what was owed. The row would then violate the link CHECK, so a
|
||||
// credit whose invoice is removed has to be walked back to UNBILLED
|
||||
// explicitly — which is the correct, visible outcome.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_invoice
|
||||
FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices(id) ON DELETE SET NULL
|
||||
`);
|
||||
|
||||
// One live credit per booking. Partial so a soft-deleted or cancelled row
|
||||
// does not block re-pricing a booking that was voided and rebooked.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
|
||||
ON freight.shipping_line_credits (booking_id)
|
||||
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
|
||||
`);
|
||||
|
||||
// Drives the two hot reads: finance's unbilled worklist per line, and the
|
||||
// outstanding total on the shipping-line detail page.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
|
||||
ON freight.shipping_line_credits (shipping_line_company_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
|
||||
ON freight.shipping_line_credits (invoice_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.shipping_line_credits
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.invoices
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
|
||||
export class EimsCancellation3470000000000 implements MigrationInterface {
|
||||
name = "EimsCancellation3470000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
|
||||
ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_cancelled_at,
|
||||
DROP COLUMN IF EXISTS eims_cancellation_date,
|
||||
DROP COLUMN IF EXISTS eims_cancellation_reason_code,
|
||||
DROP COLUMN IF EXISTS eims_cancellation_remark
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-shipping-line rates.
|
||||
*
|
||||
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
|
||||
* and negotiates its own prices, so the rate table gains an owner column:
|
||||
* `shipping_line_company_id` NULL = the standard rate every customer pays,
|
||||
* NOT NULL = a rate that only that line's bookings resolve.
|
||||
*
|
||||
* Points at `freight.shipping_line_companies` (the portal account that owns the
|
||||
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
|
||||
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
|
||||
* already keys off it. Both stay independent.
|
||||
*
|
||||
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
|
||||
* that line's rate for the lane, and is hard-blocked when none exists (the
|
||||
* standard rate is deliberately not a fallback — see RuleEngineService).
|
||||
*
|
||||
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
|
||||
* standard-rate lookups behave exactly as before.
|
||||
*/
|
||||
export class ShippingLineRates3470000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_shipping_line_company"
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// Rate resolution always filters by owner, so the lookups this column
|
||||
// participates in are (owner, lane) — indexed together with rate_type,
|
||||
// which every lookup also pins.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
|
||||
ON freight.rates (shipping_line_company_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
|
||||
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
|
||||
// A shipping line sells import freight only — the export leg is contracted
|
||||
// through the customer, not the carrier. Enforced here so a line rate can
|
||||
// never be filed against an export lane regardless of which API path wrote
|
||||
// it. Surcharges carry no direction and are unaffected.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
shipping_line_company_id IS NULL OR
|
||||
trade_direction IS NULL OR trade_direction = 'IMPORT'
|
||||
)
|
||||
`);
|
||||
|
||||
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
|
||||
// rate collides with the standard rate for the same lane — same rate_type,
|
||||
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
|
||||
// (the standard rate) collapses to the zero uuid like every other nullable
|
||||
// scope column, so existing rows keep their current uniqueness exactly.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restore the pre-owner pattern index (as left by LastMileRateBands).
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
|
||||
* (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
|
||||
* varchar(64) on the next successful registration.
|
||||
*/
|
||||
export class WidenPreviousIrn3480000000000 implements MigrationInterface {
|
||||
name = "WidenPreviousIrn3480000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ALTER COLUMN previous_irn TYPE text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ALTER COLUMN previous_irn TYPE varchar(64)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/** `freight.eims_receipts` — see `EimsReceipt` entity. */
|
||||
export class EimsReceipts3490000000000 implements MigrationInterface {
|
||||
name = "EimsReceipts3490000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.eims_receipts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
|
||||
kind varchar(16) NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||
receipt_number varchar(64) NOT NULL,
|
||||
rrn text,
|
||||
qr text,
|
||||
ack_status varchar(8),
|
||||
submitted_at timestamptz,
|
||||
last_error jsonb,
|
||||
request jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (see LogoSettingsService). Same single-row shape as
|
||||
* stamp_settings; the app never inserts more than one row.
|
||||
*/
|
||||
export class LogoSettings3500000000000 implements MigrationInterface {
|
||||
name = "LogoSettings3500000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.logo_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
logo_file_id uuid REFERENCES freight.files(id),
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A train schedule can be dedicated to one shipping line.
|
||||
*
|
||||
* NULL = a normal train, visible and bookable to customers as before. Set =
|
||||
* the departure exists for that shipping line alone: it is excluded from every
|
||||
* customer-facing read (booking windows, day pools, portal home cards) and
|
||||
* surfaces only in the assigned line's portal (home page + booking detail).
|
||||
*/
|
||||
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
|
||||
ON freight.train_schedules (shipping_line_company_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Default the daily booking desk to 24 hours: window_close_hour equal to
|
||||
* window_open_hour means the desk never pauses overnight. Aligns the column
|
||||
* default and the existing global-rules row; per-schedule overrides keep
|
||||
* whatever staff set on them.
|
||||
*/
|
||||
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 8
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = window_open_hour
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_close_hour SET DEFAULT 17
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_scheduling_global_rules
|
||||
SET window_close_hour = 17
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Maker–checker for manual actions on shipping-line credit invoices.
|
||||
*
|
||||
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
|
||||
* manual paths exist for finance: recording an offline payment (MARK_PAID)
|
||||
* and voiding an invoice raised in error (CANCEL, which releases its credits
|
||||
* back to the unbilled pool). Both erase or move real debt, so neither is a
|
||||
* single-person action: one permission raises the request, a different
|
||||
* permission — held by a chief, and never the requester themselves — approves
|
||||
* or rejects it. Rows are never deleted; decided requests are the audit trail.
|
||||
*
|
||||
* One PENDING row per invoice at a time (partial unique index): a second
|
||||
* request while one is undecided is a coordination failure, not a workflow.
|
||||
*/
|
||||
export class ShippingLineInvoiceApprovals3530000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
|
||||
AS ENUM ('MARK_PAID', 'CANCEL');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
|
||||
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
|
||||
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
requested_by uuid NOT NULL,
|
||||
reason varchar(500) NOT NULL,
|
||||
payment_reference varchar(255),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id, status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per invoice.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
|
||||
ON freight.shipping_line_invoice_approvals (invoice_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Empty containers ride an export departure back to Djibouti, so a return now
|
||||
* records which train schedule carries it and on which wagon slot. Size is
|
||||
* captured too: the wagon rule is one 40ft OR two 20ft per wagon, which cannot
|
||||
* be enforced without knowing the box size.
|
||||
*/
|
||||
export class EmptyReturnTrainLoad3540000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS container_size character varying(10),
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS wagon_sequence_no integer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_empty_container_returns_train_schedule_id
|
||||
ON freight.empty_container_returns (train_schedule_id)
|
||||
WHERE train_schedule_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_empty_container_returns_train_schedule_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS container_size,
|
||||
DROP COLUMN IF EXISTS train_schedule_id,
|
||||
DROP COLUMN IF EXISTS wagon_sequence_no
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Supports the Stripe-style pill filter bar: every list it lands on filters
|
||||
* and sorts server-side now. `@Index` decorators alone do nothing —
|
||||
* `synchronize: false` means an index exists only if a migration created it
|
||||
* (see the `RepairSynchronizeDrift`-style gaps this closes).
|
||||
*
|
||||
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
|
||||
* and wagons have no plain `status` index — `idx_bookings_route_day` and
|
||||
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
|
||||
* not a standalone `WHERE status = $1`, and `status` is the single
|
||||
* most-filtered column on both lists (bookings: 37 values).
|
||||
*
|
||||
* `(created_at DESC, id ASC)` partials match the default sort + id
|
||||
* tiebreaker `applySort` now appends everywhere, and none of these tables
|
||||
* had a created_at index at all.
|
||||
*/
|
||||
export class FilterableListIndexes3540000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_status
|
||||
ON freight.bookings USING btree (status)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_status
|
||||
ON freight.wagons USING btree (status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
|
||||
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
|
||||
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
|
||||
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
|
||||
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint,
|
||||
* distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original
|
||||
* invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`.
|
||||
*/
|
||||
export class EimsDebitCreditNotes3550000000000 implements MigrationInterface {
|
||||
name = "EimsDebitCreditNotes3550000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV',
|
||||
ADD COLUMN IF NOT EXISTS eims_reason text,
|
||||
ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_document_type,
|
||||
DROP COLUMN IF EXISTS eims_reason,
|
||||
DROP COLUMN IF EXISTS related_invoice_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table controlling whether Finance may settle invoices by hand,
|
||||
* per currency (see ManualPaymentSettingsService). Defaults preserve the
|
||||
* pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual
|
||||
* settlement is the new capability and must be switched on deliberately (OFF).
|
||||
*/
|
||||
export class ManualPaymentSettings3560000000000 implements MigrationInterface {
|
||||
name = "ManualPaymentSettings3560000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.manual_payment_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
etb_enabled boolean NOT NULL DEFAULT false,
|
||||
usd_enabled boolean NOT NULL DEFAULT true,
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled)
|
||||
SELECT false, true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.manual_payment_settings;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Which desks work at which yard — the input to yard access scoping.
|
||||
*
|
||||
* Many-to-many: a position (what the user-management tree calls a department)
|
||||
* can cover several yards, and a yard is staffed by several positions. The
|
||||
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||
*
|
||||
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||
* soft-deleted position silently drops out of scope rather than granting it.
|
||||
*
|
||||
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||
* same pair later.
|
||||
*/
|
||||
export class YardPositions3560000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||
position_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
|
||||
ON freight.yard_positions (yard_id, position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||
ON freight.yard_positions (position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Approval gate for consolidated (shared-wagon) bookings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A CONSOLIDATED booking does not: it shares one physical
|
||||
* wagon with another customer's booking, which means two customers' cargo, two
|
||||
* invoices and two liabilities riding the same wagon. That pairing is a
|
||||
* commercial decision, so it is reviewed by a person before Operations sees it.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves (booking_id +
|
||||
* partner_booking_id) so an approver can never approve one side of a shared
|
||||
* wagon and leave the other pending. Rows are never deleted; decided rows are
|
||||
* the audit trail of who approved which pairing and when.
|
||||
*
|
||||
* One PENDING row per booking at a time (partial unique index on each side of
|
||||
* the pair): a second request while one is undecided is a coordination failure,
|
||||
* not a workflow.
|
||||
*/
|
||||
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.consolidation_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
-- Who put the pairing up for review (the GL user who completed it) and
|
||||
-- who decided it. Both are recorded: the point of the gate is that they
|
||||
-- are different people.
|
||||
requested_by uuid,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
-- Snapshot of what was approved, so the audit trail still reads
|
||||
-- correctly after the bookings themselves move on.
|
||||
scheduled_date timestamptz,
|
||||
booking_reference varchar(50),
|
||||
partner_booking_reference varchar(50),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status
|
||||
ON freight.consolidation_approvals (booking_id, status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
|
||||
ON freight.consolidation_approvals (status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per booking — on EITHER side of the pair, so the same
|
||||
// wagon can never collect two pending requests from its two halves.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
|
||||
ON freight.consolidation_approvals (booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
|
||||
ON freight.consolidation_approvals (partner_booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||
* scoping.
|
||||
*
|
||||
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||
* database. A migration is the one path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||
* the freight application row is absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT gen_random_uuid(),
|
||||
$1::varchar,
|
||||
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||
* delete trips the position/role permission foreign keys — and a half-removed
|
||||
* permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
YardViewAllPermission3570000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
|
||||
*
|
||||
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
|
||||
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
|
||||
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
|
||||
* batch (webhook never arrived) can be found and reconciled by conversation id.
|
||||
*/
|
||||
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
|
||||
name = "EimsBulkRegistration3580000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
DROP COLUMN IF EXISTS in_flight_conversation_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_bulk_conversation_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
|
||||
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
|
||||
* currency and its own payable invoice.
|
||||
*/
|
||||
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
|
||||
name = 'BookingClearanceCharge3590000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"type" character varying(20) NOT NULL,
|
||||
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
|
||||
"file_record_id" uuid,
|
||||
"amount" numeric(14,2),
|
||||
"currency" character varying(8),
|
||||
"invoice_id" uuid,
|
||||
"uploaded_by_staff_id" uuid,
|
||||
"uploaded_at" timestamptz,
|
||||
"billed_by_staff_id" uuid,
|
||||
"billed_at" timestamptz,
|
||||
"paid_at" timestamptz,
|
||||
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
|
||||
ON "freight"."booking_clearance_charge" ("booking_id", "type")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Per-booking clearance action history — drives the History tab. */
|
||||
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
|
||||
name = 'BookingClearanceEvent3600000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"action" character varying(64) NOT NULL,
|
||||
"label" character varying(500) NOT NULL,
|
||||
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
|
||||
"actor_id" uuid,
|
||||
"actor_name" character varying(150),
|
||||
"metadata" jsonb,
|
||||
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
|
||||
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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();
|
||||
689
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal file
689
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal file
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* 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/ — 517 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/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "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"],
|
||||
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
||||
"POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "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"],
|
||||
|
||||
// Chat
|
||||
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
|
||||
|
||||
// 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"],
|
||||
"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"],
|
||||
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
|
||||
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "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"],
|
||||
"POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "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"],
|
||||
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "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"],
|
||||
"POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "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"],
|
||||
|
||||
// Logo Setting
|
||||
"PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"],
|
||||
"DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"],
|
||||
|
||||
// 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"],
|
||||
"POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"],
|
||||
|
||||
// Payment Setting
|
||||
"PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"],
|
||||
|
||||
// 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 (rule-engine lookup list — a code/label bookings reference,
|
||||
// not an account)
|
||||
"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"],
|
||||
|
||||
// Shipping Line Booking
|
||||
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
||||
|
||||
// Shipping Line Credit
|
||||
"POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"],
|
||||
"POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"],
|
||||
"POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"],
|
||||
"POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"],
|
||||
"POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"],
|
||||
"POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"],
|
||||
|
||||
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||
|
||||
// Signature
|
||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||
|
||||
// Stamp Setting
|
||||
"PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"],
|
||||
"DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"],
|
||||
|
||||
// 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"],
|
||||
"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"],
|
||||
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "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
|
||||
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
|
||||
// every change to it is evidence of who widened or narrowed someone's reach)
|
||||
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
|
||||
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
|
||||
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AuditService } from "./audit.service";
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
|
||||
@ApiTags("audit")
|
||||
@Controller("audit")
|
||||
@BookingStaff(FREIGHT_PERMS.audit.view)
|
||||
/**
|
||||
* Read-only view over the audit trail.
|
||||
*
|
||||
* Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than
|
||||
* the broad `admin` key, so reading the trail can be granted without also
|
||||
* granting write access to everything else.
|
||||
*
|
||||
* There is deliberately no write, update or delete endpoint here — rows are
|
||||
* created only by `AuditInterceptor`, and an audit trail that can be edited
|
||||
* through the API is not an audit trail.
|
||||
*/
|
||||
@ApiTags('audit')
|
||||
@ApiBearerAuth()
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get("logs")
|
||||
@ApiOperation({ summary: "List freight-api audit log commands" })
|
||||
@ApiQuery({ name: "skip", type: Number, required: false })
|
||||
@ApiQuery({ name: "take", type: Number, required: false })
|
||||
list(@Query("skip") skip?: string, @Query("take") take?: string) {
|
||||
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
|
||||
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
|
||||
// — reading it here instead of a hardcoded literal means this can't
|
||||
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
|
||||
// actually is at runtime.
|
||||
const application =
|
||||
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
|
||||
return this.auditService.list(
|
||||
application,
|
||||
skip !== undefined ? parseInt(skip, 10) : undefined,
|
||||
take !== undefined ? parseInt(take, 10) : undefined,
|
||||
);
|
||||
@Get('logs')
|
||||
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
|
||||
})
|
||||
list(@Query() query: AuditLogQueryDto): Promise<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;
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuditController } from "./audit.controller";
|
||||
import { AuditService } from "./audit.service";
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditInterceptor } from './audit.interceptor';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
/**
|
||||
* Backoffice audit trail.
|
||||
*
|
||||
* `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every
|
||||
* route in the application without touching the 488 mutating handlers
|
||||
* individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is
|
||||
* audited as soon as it appears in that map, and unknown routes are skipped
|
||||
* rather than recorded with an empty title.
|
||||
*
|
||||
* Global so other modules can inject `AuditService` to record domain events
|
||||
* that do not map cleanly onto an HTTP request.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
providers: [
|
||||
AuditLogRepository,
|
||||
AuditService,
|
||||
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
|
||||
],
|
||||
exports: [AuditService, AuditLogRepository],
|
||||
})
|
||||
export class AuditModule {}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -1,70 +1,71 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
|
||||
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
export interface AuditLogListResult {
|
||||
count: number;
|
||||
items: AuditLogCommand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
|
||||
* @BookingStaff — the package's own AuditLogCommandController (mounted at
|
||||
* /api/audit-log-commands) ships with no guards at all, so it can't be used
|
||||
* directly for a permission-gated UI. Query mirrors the package's
|
||||
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLogCommand)
|
||||
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
|
||||
) {}
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
async list(
|
||||
application: string,
|
||||
skip = 0,
|
||||
take = 10,
|
||||
): Promise<AuditLogListResult> {
|
||||
const [items, count] = await this.auditLogCommandRepository
|
||||
.createQueryBuilder("audit_log_commands")
|
||||
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
|
||||
{ application },
|
||||
)
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
|
||||
{ status: "Commit" },
|
||||
)
|
||||
// Backoffice-only view: portal (customer-facing) writes carry the same
|
||||
// request-header set by every axios call from that app — see
|
||||
// login-audience.middleware.ts. Rows with no linked auditLog (child/
|
||||
// event commands with no request context) stay visible; they aren't
|
||||
// attributable to any frontend, so they're not portal noise either.
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
|
||||
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
|
||||
)
|
||||
.select([
|
||||
"audit_log_commands.id",
|
||||
"audit_log_commands.createdAt",
|
||||
"audit_log_commands.deletedAt",
|
||||
"audit_log_commands.entityName",
|
||||
"audit_log_commands.queryMethod",
|
||||
"audit_log_commands.changes",
|
||||
"audit_log_commands.payload",
|
||||
"auditLog.id",
|
||||
"auditLog.user",
|
||||
])
|
||||
.addOrderBy("audit_log_commands.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
constructor(private readonly auditLogRepository: AuditLogRepository) {}
|
||||
|
||||
return { count, items };
|
||||
/**
|
||||
* Persist one audit row, swallowing any failure.
|
||||
*
|
||||
* An audit write must never turn a successful business action into an error
|
||||
* for the user: if this table is full, misconfigured or mid-migration,
|
||||
* contract approvals still need to work. Failures are logged so the gap is
|
||||
* visible in application logs rather than silent.
|
||||
*/
|
||||
async record(entry: Partial<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;
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import {
|
||||
ForgotPasswordService,
|
||||
RESET_LINK_TTL_MS,
|
||||
type ResetTicket,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
@@ -86,28 +87,113 @@ export class CustomerResetService {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
return this.sendResetLinkToUser(resolved.userId, channel, {
|
||||
scope: `company ${companyId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and deliver a reset link to a specific IAM account.
|
||||
*
|
||||
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
||||
* that resolve their target differently can reuse it: a customer is found via
|
||||
* the company's primary contact, while a shipping line has no contact row at
|
||||
* all and resolves straight off its own record. Everything below the lookup —
|
||||
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
||||
* undelivered-link diagnostic — is identical for both and must stay that way.
|
||||
*
|
||||
* `scope` only labels the log line with whatever the caller resolved from.
|
||||
*
|
||||
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
||||
* the default gate requires an existing active credential (so a reset cannot
|
||||
* revive a suspended account), but an account that has never set a password
|
||||
* has no credential row yet and would be excluded from its own activation
|
||||
* link. Callers pass it only when the account is expected to be
|
||||
* password-less — see ShippingLineCompaniesService.
|
||||
*/
|
||||
async sendResetLinkToUser(
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): Promise<SentResetLink | null> {
|
||||
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
|
||||
return sent[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ticket, several channels. Minting retires every earlier ticket for the
|
||||
* user (`mintResetTicket`), so sending email and SMS as two separate mints
|
||||
* makes the first link dead on arrival — the same link must go to both.
|
||||
* Returns one entry per channel that was actually sent (unreachable channels
|
||||
* are skipped, not errors).
|
||||
*/
|
||||
async sendResetLinkToUserOnChannels(
|
||||
userId: string,
|
||||
channels: ResetChannel[],
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): Promise<SentResetLink[]> {
|
||||
const user = options?.allowWithoutCredential
|
||||
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`User ${userId} is not an active account${
|
||||
options?.allowWithoutCredential
|
||||
? ""
|
||||
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
||||
}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Mint once, before any send: a failed send leaves an unused ticket that
|
||||
// simply expires, whereas sending a link before the ticket exists would
|
||||
// hand the customer a URL that is dead on arrival.
|
||||
let ticket: ResetTicket | null = null;
|
||||
const sent: SentResetLink[] = [];
|
||||
for (const channel of channels) {
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) continue;
|
||||
// The gateway silently drops foreign numbers — treat like a missing phone
|
||||
// rather than reporting "link sent" for a message that will never arrive.
|
||||
// The backoffice disables the channel up front via `phoneIsDomestic`; this
|
||||
// guards direct API calls.
|
||||
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||
this.logger.warn(
|
||||
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ticket ??= await this.forgotPasswordService.mintResetTicket(
|
||||
user.id,
|
||||
RESET_LINK_TTL_MS,
|
||||
);
|
||||
const result = await this.deliverResetLink(
|
||||
target,
|
||||
user.id,
|
||||
channel,
|
||||
ticket,
|
||||
options?.scope,
|
||||
);
|
||||
if (result) sent.push(result);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail: send the already-minted ticket to a resolved target → report.
|
||||
*/
|
||||
private async deliverResetLink(
|
||||
target: OtpTarget,
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
ticket: ResetTicket,
|
||||
scope?: string,
|
||||
): Promise<SentResetLink | null> {
|
||||
|
||||
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
||||
// it like a missing phone rather than reporting "link sent" for a message
|
||||
// that will never arrive. The backoffice disables the channel up front via
|
||||
// `phoneIsDomestic`; this guards direct API calls.
|
||||
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||
this.logger.warn(
|
||||
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
const ticket = await this.forgotPasswordService.mintResetTicket(
|
||||
userId,
|
||||
RESET_LINK_TTL_MS,
|
||||
);
|
||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||
|
||||
@@ -127,7 +213,22 @@ export class CustomerResetService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} queued=${queued}`,
|
||||
);
|
||||
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
||||
// read access to the log stream can set the password for the account named
|
||||
// on the same line — including on sends that succeeded, not just failures.
|
||||
// Kept deliberately: log aggregation is the debugging path for flaky
|
||||
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
||||
// that is ever revisited, gate this on an env flag rather than deleting it,
|
||||
// so dev keeps its workflow.
|
||||
this.logger.warn(
|
||||
`reset-link.cleartext channel=${channel} user=${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} link=${link}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
|
||||
@@ -89,6 +89,28 @@ export class ForgotPasswordService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active account by id, WITHOUT requiring an existing credential.
|
||||
*
|
||||
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
|
||||
* is right for a *reset*: it stops a staff-triggered link from reactivating a
|
||||
* suspended account. But an account that has never set a password has no
|
||||
* credential row yet, so that join excludes exactly the accounts a first-time
|
||||
* *activation* link is for — shipping lines are created deliberately without
|
||||
* one (see ShippingLineCompaniesService.register).
|
||||
*
|
||||
* The `isActive` gate is kept; only the credential requirement is dropped.
|
||||
*/
|
||||
async resolveActivatableUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.where("u.isActive = true")
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.orderBy("u.createdAt", "DESC")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
@@ -190,7 +212,9 @@ export class ForgotPasswordService {
|
||||
* is the proof).
|
||||
*/
|
||||
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
|
||||
const code = randomBytes(24).toString("base64url");
|
||||
// Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
|
||||
// no "_" — gateways substitute a space and the link arrives broken.
|
||||
const code = randomBytes(24).toString("hex");
|
||||
const verificationCode = await hashPassword(code);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -233,9 +257,22 @@ export class ForgotPasswordService {
|
||||
"This password-reset link is invalid or has expired. Request a new one.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
// Credential-less on purpose: this resolves links for *setting* a password,
|
||||
// which includes first-time activation of an account that has never had one
|
||||
// (shipping lines are created without a credential row). Requiring one here
|
||||
// rejected a perfectly valid activation link before its token was ever
|
||||
// checked. The ticket checks below are what actually authorise the reset.
|
||||
const user = await this.resolveActivatableUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !identifier) throw invalid;
|
||||
if (!user || !identifier) {
|
||||
// Logged because the early return above bypasses the rejection warning
|
||||
// below — without this, an account that fails the lookup produces no
|
||||
// diagnostic at all and looks identical to a bad token.
|
||||
this.logger.warn(
|
||||
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
|
||||
);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
const verification = await this.dataSource
|
||||
.getRepository(UserVerification)
|
||||
|
||||
@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
exports: [CustomerResetService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
export class ListUsersService {
|
||||
constructor(
|
||||
@InjectRepository(User) private readonly users: Repository<User>,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
||||
@@ -40,6 +42,29 @@ export class ListUsersService {
|
||||
])
|
||||
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
// Restrict to one IAM organization when configured. The org key differs per
|
||||
// environment (dev seeds `edr_freight`, production uses the registered
|
||||
// company key), so this is config rather than a constant. An unset key
|
||||
// means no restriction; a key matching no organization matches no user —
|
||||
// failing closed rather than silently widening to every org.
|
||||
const orgKey = this.config.get<string>('FREIGHT_ORG_KEY');
|
||||
if (orgKey) {
|
||||
// EXISTS, not a join: a user with several employee rows would otherwise
|
||||
// be returned once per row, duplicating them in the list and inflating
|
||||
// `getManyAndCount`'s total.
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
FROM iam.employees emp
|
||||
JOIN iam.organizations org ON org.id = emp.organization_id
|
||||
WHERE emp.user_id = "user".id
|
||||
AND org.key = :orgKey
|
||||
AND org.deleted_at IS NULL
|
||||
)`,
|
||||
{ orgKey },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.userType) {
|
||||
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ export class BackofficeService {
|
||||
|
||||
/**
|
||||
* IAM user ids of every current employee across all organizations — used by
|
||||
* the notification recipients resolver's `allBackoffice` selector.
|
||||
* support chat for staff room membership. Notifications deliberately do NOT
|
||||
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
|
||||
*/
|
||||
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||
const employees = await this.employeeRepository.find({
|
||||
@@ -63,24 +64,67 @@ export class BackofficeService {
|
||||
* IAM user ids of current employees (any org) holding ANY of the given
|
||||
* permission keys — used by the notification recipients resolver's
|
||||
* `permissionKeys` selector for department/role-scoped targeting.
|
||||
*
|
||||
* This MUST agree with the request-time guard (`hasFreightPermission`,
|
||||
* common/freight-permission.util.ts), which counts four grant carriers plus
|
||||
* the super_admin bypass. Counting fewer silently drops legitimate
|
||||
* recipients: an earlier version joined only direct position permissions, on
|
||||
* which `bookings:view` resolved to 2 users — against 21 through position
|
||||
* TYPES, which is where admin-created positions actually keep their grants.
|
||||
*
|
||||
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
|
||||
* declares its inverse side against PositionType, so a relation join emits
|
||||
* `ptp.position_type_id = position.id` and silently matches nothing. Same
|
||||
* approach as FreightMeService's position-type lookups.
|
||||
*/
|
||||
async getEmployeeUserIdsByPermission(
|
||||
permissionKeys: string[],
|
||||
): Promise<string[]> {
|
||||
if (!permissionKeys.length) return [];
|
||||
const rows: { userId: string | null }[] = await this.employeeRepository
|
||||
.createQueryBuilder("employee")
|
||||
.innerJoin("employee.employeePositions", "employeePosition")
|
||||
.innerJoin("employeePosition.position", "position")
|
||||
.innerJoin("position.positionPermission", "positionPermission")
|
||||
.innerJoin("positionPermission.permission", "permission")
|
||||
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
||||
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
||||
.select("DISTINCT employee.user_id", "userId")
|
||||
.getRawMany();
|
||||
return rows
|
||||
.map((r) => r.userId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const rows: { userId: string }[] = await this.dataSource.query(
|
||||
`WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
|
||||
-- 1. IAM role grants (user_roles -> role_permissions).
|
||||
SELECT e.user_id AS "userId"
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND rp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 2. Direct position grants. A delegate keeps their own position AND
|
||||
-- gains the one they stand in for, so both columns count.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.position_permissions pp
|
||||
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND pp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 3. Position TYPE grants — where admin-created positions keep theirs.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.positions p
|
||||
ON p.id IN (ep.position_id, ep.delegatee_position_id)
|
||||
JOIN iam.position_type_permissions ptp
|
||||
ON ptp.position_type_id = p.position_type_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND ptp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 4. super_admin passes every freight permission check, so mirror that
|
||||
-- here or admins go blind on desks nobody else has been granted yet.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.roles r ON r.id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND r.key = 'super_admin'`,
|
||||
[permissionKeys],
|
||||
);
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
import { IssueMemoDto } from "./dto/issue-memo.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@@ -25,6 +39,8 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
@@ -50,17 +66,84 @@ export class BillingController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/summary")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
|
||||
})
|
||||
async collectedSummary(
|
||||
@Query() query: FilterInvoiceDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.billingService.collectedSummary({
|
||||
...query,
|
||||
tradeDirections: allowed ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/:id")
|
||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("offline-usd")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
|
||||
})
|
||||
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findOfflineUsdPaginated(query);
|
||||
}
|
||||
|
||||
@Post("invoices/:id/confirm-offline")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
|
||||
})
|
||||
confirmOffline(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body("reference") reference: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.billingService.confirmOfflinePayment(id, file, {
|
||||
reference: reference?.trim() || null,
|
||||
userId: resolveAuthUserId(user),
|
||||
userName: actorLabel(user) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("invoices/:id/memo")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
|
||||
})
|
||||
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
|
||||
return this.billingService.issueMemo(id, dto);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
|
||||
})
|
||||
async document(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("format") format: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (format !== undefined && format !== "a4" && format !== "thermal") {
|
||||
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
|
||||
}
|
||||
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,17 +13,21 @@ import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
// Cycles back via ShippingLineCompaniesModule, which imports this module.
|
||||
forwardRef(() => CompaniesModule),
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -79,6 +79,9 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,6 +120,160 @@ describe("BillingService.generateInvoice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.issueMemo", () => {
|
||||
const ORIGINAL_ID = "original-invoice-1";
|
||||
|
||||
function originalInvoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ORIGINAL_ID,
|
||||
invoiceNumber: "INV-20260807-00042",
|
||||
eimsIrn: "irn-value",
|
||||
eimsDocumentType: "INV",
|
||||
eimsStatus: "REGISTERED",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
shippingLineCompanyId: null,
|
||||
currency: "ETB",
|
||||
totalAmount: 1500,
|
||||
lines: [
|
||||
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
|
||||
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(original: ReturnType<typeof originalInvoice>) {
|
||||
const savedLines: unknown[] = [];
|
||||
const manager = makeManager(savedLines);
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
const invoices = { findById: jest.fn().mockResolvedValue(original) };
|
||||
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
invoices as never,
|
||||
invoiceLines as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
|
||||
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
|
||||
const { service, savedLines } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
|
||||
expect(memo.totalAmount).toBe(1500);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
|
||||
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
|
||||
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
|
||||
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
|
||||
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(memo.balanceAmount).toBe(1500);
|
||||
expect(memo.paidAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
|
||||
|
||||
expect(memo.sourceId).toBe(ORIGINAL_ID);
|
||||
expect(memo.sourceId).not.toBe("booking-1");
|
||||
});
|
||||
|
||||
it("allows a partial memo with explicit lines instead of copying the original", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "Partial credit",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a memo against an invoice never registered with EIMS", async () => {
|
||||
const { service } = build(originalInvoice({ eimsIrn: null }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a memo against a memo", async () => {
|
||||
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
|
||||
"cannot issue a memo against a memo",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a memo against an EIMS-cancelled invoice", async () => {
|
||||
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
|
||||
"cancelled with EIMS",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a credit memo whose total exceeds the original", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
await expect(
|
||||
service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "too much",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
|
||||
}),
|
||||
).rejects.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "DEB",
|
||||
reason: "additional charge",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(5000);
|
||||
});
|
||||
|
||||
it("refuses a blank reason", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
|
||||
"requires a reason",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
@@ -140,6 +297,9 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -193,6 +353,9 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -236,6 +399,9 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -347,6 +513,9 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -462,6 +631,9 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -533,6 +705,9 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -622,6 +797,9 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -669,11 +847,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
|
||||
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
|
||||
// totalAmount — money missing from the bank with the books saying paid.
|
||||
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
|
||||
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
||||
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
||||
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
||||
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
||||
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
@@ -682,9 +860,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "ETB",
|
||||
// .40 — the case Math.round gets wrong (rounds down, underpays).
|
||||
balanceAmount: 12345.4,
|
||||
totalAmount: 12345.4,
|
||||
// .43 — cents that must survive all the way to the bill.
|
||||
balanceAmount: 12345.43,
|
||||
totalAmount: 12345.43,
|
||||
company: { name: "Acme PLC" },
|
||||
paymentId: null,
|
||||
dueAt: null,
|
||||
@@ -703,11 +881,14 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("opens the intent for the ceiled balance, never below it", async () => {
|
||||
it("opens the intent for the exact balance, cents included", async () => {
|
||||
const initiate = jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
@@ -718,16 +899,137 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||
|
||||
expect(initiate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ amountMinor: 12346 }),
|
||||
expect.objectContaining({ amountMinor: 12345.43 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
|
||||
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
|
||||
const { service } = build();
|
||||
|
||||
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||
stillPayable: true,
|
||||
currentAmountMinor: 12346,
|
||||
currentAmountMinor: 12345.43,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.document", () => {
|
||||
const invoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260812-00001",
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
type: "freight",
|
||||
currency: "ETB",
|
||||
subtotalAmount: 100,
|
||||
taxAmount: 0,
|
||||
totalAmount: 100,
|
||||
paidAmount: 0,
|
||||
balanceAmount: 100,
|
||||
issuedAt: new Date(2026, 7, 12),
|
||||
dueAt: new Date(2026, 7, 19),
|
||||
eimsIrn: null,
|
||||
eimsSignedQr: null,
|
||||
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
|
||||
...over,
|
||||
});
|
||||
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ render, renderThermal } as never,
|
||||
{} as never,
|
||||
{
|
||||
get: (key: string) =>
|
||||
key === "eims"
|
||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
|
||||
expect(model.qrImageUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
|
||||
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
|
||||
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
|
||||
});
|
||||
|
||||
it("omits the VAT row when the buyer company has none", async () => {
|
||||
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
|
||||
expect(model.summary).toContainEqual({
|
||||
label: "Seller VAT No.",
|
||||
value: "43256663343256663322",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
|
||||
const { service, render } = build(
|
||||
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
|
||||
);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
expect(renderThermal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1", "thermal");
|
||||
|
||||
expect(renderThermal).toHaveBeenCalledTimes(1);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
@@ -8,16 +9,26 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -35,6 +46,22 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** Booking context attached to a finance offline-USD invoice row. */
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
reference: string;
|
||||
tradeDirection: string | null;
|
||||
paymentDeadline: Date | null;
|
||||
paymentStatus: string;
|
||||
}
|
||||
|
||||
/** Row shape of the manual-payments worklist. */
|
||||
export type OfflineUsdInvoiceRow = Invoice & {
|
||||
booking: OfflineUsdBookingInfo | null;
|
||||
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||
};
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
@@ -102,8 +129,16 @@ export interface GenerateInvoiceInput {
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** The customer billed. Omit only when billing a shipping line instead. */
|
||||
companyId?: string | null;
|
||||
companyProfileId?: string | null;
|
||||
/**
|
||||
* The shipping line billed, for an invoice covering batched shipping-line
|
||||
* credits. Mutually exclusive with `companyId` — the DB enforces this via
|
||||
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
|
||||
* setting both or neither before it ever reaches the constraint.
|
||||
*/
|
||||
shippingLineCompanyId?: string | null;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
@@ -122,6 +157,18 @@ export interface GenerateInvoiceInput {
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
||||
export type MemoType = "CRE" | "DEB";
|
||||
|
||||
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
||||
export interface IssueMemoInput {
|
||||
type: MemoType;
|
||||
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
||||
reason: string;
|
||||
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
||||
lines?: InvoiceLineInput[];
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
@@ -129,8 +176,11 @@ export interface InvoiceEventPayload {
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
/** Null when the payer is a shipping line rather than a customer company. */
|
||||
companyId: string | null;
|
||||
companyProfileId: string | null;
|
||||
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
@@ -150,6 +200,9 @@ export class BillingService {
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -164,6 +217,40 @@ export class BillingService {
|
||||
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||
* invoices page).
|
||||
*/
|
||||
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
|
||||
private applyInvoiceFilters(
|
||||
qb: SelectQueryBuilder<Invoice>,
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
},
|
||||
) {
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
if (filter.tradeDirections) {
|
||||
applyBookingRefDirectionScope(
|
||||
qb,
|
||||
"invoice.source_id",
|
||||
filter.tradeDirections,
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
@@ -187,13 +274,126 @@ export class BillingService {
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items: await this.attachShippingLineCompanies(items), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
|
||||
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
|
||||
* entity's doc comment — so this is a second query keyed off the ids
|
||||
* already loaded, same shape as `company`.
|
||||
*/
|
||||
private async attachShippingLineCompanies<T extends Invoice>(
|
||||
invoices: T[],
|
||||
): Promise<T[]> {
|
||||
const ids = [
|
||||
...new Set(
|
||||
invoices
|
||||
.map((i) => i.shippingLineCompanyId)
|
||||
.filter((id): id is string => id != null),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return invoices;
|
||||
const lines = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.find({ where: { id: In(ids) } });
|
||||
const byId = new Map(lines.map((l) => [l.id, l]));
|
||||
return invoices.map((invoice) => {
|
||||
const line = invoice.shippingLineCompanyId
|
||||
? byId.get(invoice.shippingLineCompanyId)
|
||||
: undefined;
|
||||
return line
|
||||
? ({
|
||||
...invoice,
|
||||
shippingLineCompany: {
|
||||
id: line.id,
|
||||
name: line.name,
|
||||
email: line.email,
|
||||
phoneNumber: line.phoneNumber,
|
||||
},
|
||||
} as T)
|
||||
: invoice;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Total collected (`paidAmount`) across every invoice matching the same
|
||||
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
|
||||
* invoices summary card reflects the whole filtered set, not just the
|
||||
* visible page.
|
||||
*/
|
||||
async collectedSummary(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
): Promise<Record<string, number>> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||
.groupBy("invoice.currency");
|
||||
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const rows: { currency: string; collected: string }[] =
|
||||
await qb.getRawMany();
|
||||
|
||||
return Object.fromEntries(
|
||||
rows.map((row) => [row.currency, Number(row.collected) || 0]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||
* transfer / counter) instead of the customer paying online. Open ones by
|
||||
* default or a single status when filtered; both currencies unless
|
||||
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||
* trade direction and pay-window deadline so the UI can show the countdown
|
||||
* and link to the booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
// Only currencies whose manual-payment channel is switched on are listed:
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
@@ -202,16 +402,141 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.tradeDirections) {
|
||||
applyBookingRefDirectionScope(
|
||||
qb,
|
||||
"invoice.source_id",
|
||||
filter.tradeDirections,
|
||||
);
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
.filter((i) => i.source === "booking")
|
||||
.map((i) => i.sourceId);
|
||||
const bookings = bookingIds.length
|
||||
? await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(bookingIds) },
|
||||
select: [
|
||||
"id",
|
||||
"reference",
|
||||
"tradeDirection",
|
||||
"paymentDeadline",
|
||||
"paymentStatus",
|
||||
],
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
// Shipping-line credit invoices bill many bookings at once; each credit
|
||||
// keeps its own booking link, so collect them per invoice.
|
||||
const creditInvoiceIds = items
|
||||
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
|
||||
.map((i) => i.id);
|
||||
const credits = creditInvoiceIds.length
|
||||
? await this.dataSource.getRepository(ShippingLineCredit).find({
|
||||
where: { invoiceId: In(creditInvoiceIds) },
|
||||
relations: { booking: true },
|
||||
})
|
||||
: [];
|
||||
const bookingsByInvoice = new Map<
|
||||
string,
|
||||
OfflineUsdInvoiceRow["bookings"]
|
||||
>();
|
||||
for (const c of credits) {
|
||||
if (!c.invoiceId || !c.booking) continue;
|
||||
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
|
||||
list.push({
|
||||
id: c.booking.id,
|
||||
reference: c.booking.reference,
|
||||
tradeDirection: c.booking.tradeDirection ?? null,
|
||||
});
|
||||
bookingsByInvoice.set(c.invoiceId, list);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items, total };
|
||||
return {
|
||||
items: items.map((inv) => {
|
||||
const b = byId.get(inv.sourceId);
|
||||
return {
|
||||
...inv,
|
||||
booking: b
|
||||
? {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
tradeDirection: b.tradeDirection ?? null,
|
||||
paymentDeadline: b.paymentDeadline ?? null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
}
|
||||
: null,
|
||||
bookings: bookingsByInvoice.get(inv.id) ?? [],
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||
* or counter payment. Refused when that currency's manual-payment channel is
|
||||
* switched off in settings. Stores the slip against the invoice and settles the
|
||||
* FULL outstanding balance through
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
* the booking advances exactly as if it had been paid through the gateway.
|
||||
*
|
||||
* Guarded by the booking's pay window: past the deadline the booking expires
|
||||
* like any unpaid one, so confirmation is refused.
|
||||
*/
|
||||
async confirmOfflinePayment(
|
||||
invoiceId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
input: {
|
||||
reference?: string | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
},
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.invoices.findById(invoiceId);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
// The channel is a setting, not a role: even a permitted user cannot
|
||||
// settle by hand in a currency whose channel is switched off.
|
||||
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
||||
throw new BadRequestException(
|
||||
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
||||
);
|
||||
}
|
||||
if (!file) {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
|
||||
if (invoice.source === "booking") {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "paymentDeadline"],
|
||||
});
|
||||
const deadline = booking?.paymentDeadline;
|
||||
if (deadline && new Date(deadline).getTime() < Date.now()) {
|
||||
throw new BadRequestException(
|
||||
"The payment window has closed — this booking can no longer be confirmed as paid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const slip = await this.files.upload({
|
||||
resource: "invoice",
|
||||
resourceId: invoice.id,
|
||||
code: "OFFLINE_PAYMENT_SLIP",
|
||||
file,
|
||||
title: "Bank payment slip",
|
||||
uploadedByUserId: input.userId ?? null,
|
||||
uploadedByName: input.userName ?? null,
|
||||
});
|
||||
|
||||
return this.recordPayment(invoiceId, {
|
||||
amount: Number(invoice.balanceAmount),
|
||||
method: "BANK_TRANSFER",
|
||||
reference: input.reference || slip.name,
|
||||
metadata: {
|
||||
offline: true,
|
||||
slipFileId: slip.id,
|
||||
confirmedByUserId: input.userId ?? null,
|
||||
confirmedByName: input.userName ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
@@ -220,21 +545,30 @@ export class BillingService {
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||
* boundary check, not a business rule.
|
||||
*/
|
||||
async document(
|
||||
id: string,
|
||||
format: "a4" | "thermal" = "a4",
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||
return format === "thermal"
|
||||
? this.invoiceDocuments.renderThermal(model)
|
||||
: this.invoiceDocuments.render(model);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
@@ -246,15 +580,41 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
await this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
): Promise<InvoiceDocumentModel["summary"]> {
|
||||
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
});
|
||||
if (!booking) return [];
|
||||
return [
|
||||
{
|
||||
label: "Route",
|
||||
value:
|
||||
booking.originYard && booking.destinationYard
|
||||
? `${booking.originYard.label} → ${booking.destinationYard.label}`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private toDocumentModel(
|
||||
private async toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
): Promise<InvoiceDocumentModel> {
|
||||
const title = invoice.source
|
||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||
: "EDR";
|
||||
@@ -272,6 +632,55 @@ export class BillingService {
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
const summary: InvoiceDocumentModel["summary"] = [
|
||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||
...(invoice.company?.vatNumber
|
||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||
: []),
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
...(await this.bookingSummaryRows(invoice)),
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
|
||||
// codebase). Shown only when actually configured, same as the buyer VAT row.
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
||||
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
||||
// look it up by source id; only shown once a payment actually generated one.
|
||||
if (invoice.source === Freight.InvoiceSource.Booking) {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
@@ -279,24 +688,7 @@ export class BillingService {
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
],
|
||||
summary,
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
@@ -307,6 +699,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,23 +745,61 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
/**
|
||||
* Resolve a shipping-line company from the signed-in user (null for ordinary
|
||||
* customers). Queried straight off the entity rather than through
|
||||
* ShippingLineCompaniesService — that module already imports billing, so a
|
||||
* service edge back would deepen the forwardRef cycle for one lookup.
|
||||
*/
|
||||
private async resolveShippingLineCompanyId(
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
const line = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.findOne({ where: { userId } });
|
||||
return line?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices for the signed-in portal user; empty when they have no company.
|
||||
* A payer is either a customer company or a shipping line (enforced by the
|
||||
* DB's single-payer check), so the two lookups cannot both match.
|
||||
*/
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
if (companyId) return this.findByCompany(companyId, filter);
|
||||
|
||||
const shippingLineCompanyId =
|
||||
await this.resolveShippingLineCompanyId(userId);
|
||||
if (!shippingLineCompanyId) return [];
|
||||
return this.invoices.findAll({
|
||||
where: {
|
||||
shippingLineCompanyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
const ownedByCompany =
|
||||
invoice.companyId != null &&
|
||||
invoice.companyId === (await this.resolveCompanyId(userId));
|
||||
const ownedByShippingLine =
|
||||
!ownedByCompany &&
|
||||
invoice.shippingLineCompanyId != null &&
|
||||
invoice.shippingLineCompanyId ===
|
||||
(await this.resolveShippingLineCompanyId(userId));
|
||||
if (!ownedByCompany && !ownedByShippingLine) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
@@ -435,11 +866,16 @@ export class BillingService {
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
/**
|
||||
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
||||
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
||||
* daily sequence (different prefix hashes to a different advisory lock, see
|
||||
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
||||
*/
|
||||
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,19 +894,147 @@ export class BillingService {
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
console.log("oooooooooo", input);
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
||||
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
||||
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
||||
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
||||
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
||||
* is just an ordinary invoice with three extra columns set.
|
||||
*
|
||||
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
||||
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
||||
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
||||
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
||||
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
||||
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
||||
*
|
||||
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
||||
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
||||
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
||||
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
||||
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
||||
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
||||
*/
|
||||
async issueMemo(
|
||||
originalId: string,
|
||||
input: IssueMemoInput,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) {
|
||||
throw new BadRequestException("A memo requires a reason.");
|
||||
}
|
||||
|
||||
const original = await this.findById(originalId);
|
||||
if (!original.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
||||
);
|
||||
}
|
||||
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
||||
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity: Number(l.quantity),
|
||||
unitRate: Number(l.unitRate),
|
||||
amount: Number(l.amount),
|
||||
currency: l.currency,
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
||||
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
||||
// assume the credit-note ceiling is correct for DEB).
|
||||
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = input.type === "CRE" ? "CRE" : "DEB";
|
||||
const settled = input.type === "CRE";
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const memo = await this.createInvoice(
|
||||
{
|
||||
source: original.source as Freight.InvoiceSource,
|
||||
sourceId: original.id,
|
||||
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
||||
companyId: original.companyId,
|
||||
companyProfileId: original.companyProfileId,
|
||||
shippingLineCompanyId: original.shippingLineCompanyId,
|
||||
lines,
|
||||
currency: original.currency,
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
);
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
eimsDocumentType: input.type,
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
|
||||
this.logger.log(
|
||||
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
||||
);
|
||||
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
||||
});
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
code = "INV",
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
// Exactly one payer, checked here so a bad payload fails with a clear
|
||||
// message instead of a raw `chk_invoices_single_payer` violation.
|
||||
const billsCompany = Boolean(input.companyId);
|
||||
const billsShippingLine = Boolean(input.shippingLineCompanyId);
|
||||
if (billsCompany === billsShippingLine) {
|
||||
throw new BadRequestException(
|
||||
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
|
||||
);
|
||||
}
|
||||
if (billsCompany && !input.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
"companyProfileId is required when billing a company.",
|
||||
);
|
||||
}
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
@@ -498,7 +1062,7 @@ export class BillingService {
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
@@ -506,8 +1070,9 @@ export class BillingService {
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
companyId: input.companyId ?? null,
|
||||
companyProfileId: input.companyProfileId ?? null,
|
||||
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
@@ -801,6 +1366,24 @@ export class BillingService {
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
// Every invoice status move in the app funnels through here — money
|
||||
// changing state is the single most-asked question in support.
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
from: invoice.status,
|
||||
to: status,
|
||||
event,
|
||||
amount: Number(invoice.totalAmount),
|
||||
currency: invoice.currency,
|
||||
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
|
||||
},
|
||||
{ path: "invoiceTransitions", mode: "push" },
|
||||
);
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
@@ -819,6 +1402,7 @@ export class BillingService {
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
@@ -1170,6 +1754,21 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
companyId: invoice.companyId,
|
||||
amountDue,
|
||||
currency: invoice.currency,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
},
|
||||
{ path: "payment.payInvoice" },
|
||||
);
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||
if (
|
||||
@@ -1191,11 +1790,11 @@ export class BillingService {
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
|
||||
// land below the outstanding balance — Math.round would let a .40 balance
|
||||
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
|
||||
// ceil in billQuery keeps the quoted and debited amounts identical.
|
||||
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
|
||||
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
||||
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
||||
// rounding here would overcharge the payer and leave the invoice balance
|
||||
// non-zero. billQuery quotes the same unrounded value.
|
||||
amountMinor: round2(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
@@ -1293,7 +1892,24 @@ export class BillingService {
|
||||
// first on DESC, which would hand back an unissued invoice.
|
||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
if (!invoice) {
|
||||
logCtx(
|
||||
{ paymentId, outcome: "no-invoice-for-payment" },
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
paymentId,
|
||||
providerTxnId,
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceStatus: invoice.status,
|
||||
},
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
|
||||
const settleable: Freight.InvoiceStatus[] = [
|
||||
...OPEN_STATUSES,
|
||||
@@ -1303,6 +1919,12 @@ export class BillingService {
|
||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||
// inline by payInvoice). Anything else means money was captured with
|
||||
// nowhere to land — that needs a person, so say so loudly.
|
||||
logCtx(
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
? "already-paid"
|
||||
: "captured-with-nowhere-to-land",
|
||||
{ path: "payment.settleInvoice.outcome", mode: "set" },
|
||||
);
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
this.logger.error(
|
||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
||||
@@ -1340,9 +1962,10 @@ export class BillingService {
|
||||
});
|
||||
|
||||
if (open) {
|
||||
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
|
||||
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
|
||||
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
|
||||
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
||||
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
||||
// sees a mismatch.
|
||||
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
@@ -1377,7 +2000,7 @@ export class BillingService {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
|
||||
currentAmountMinor: round2(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
|
||||
@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
|
||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||
* warehouses, …) can import it to print invoices without coupling to the
|
||||
* billing payment graph.
|
||||
* billing payment graph. StampSettingsService is @Global (see
|
||||
* StampSettingsModule) so InvoiceDocumentService can inject it without this
|
||||
* module declaring an explicit import.
|
||||
*/
|
||||
@Module({
|
||||
providers: [PdfRenderService, InvoiceDocumentService],
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||
|
||||
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||
kind: "INVOICE",
|
||||
title: "Freight",
|
||||
documentNumber: "INV-20260812-00001",
|
||||
issuedAt: new Date(2026, 7, 12),
|
||||
status: "PENDING",
|
||||
currency: "ETB",
|
||||
summary: [{ label: "Status", value: "PENDING" }],
|
||||
lines: [],
|
||||
totals: [{ label: "Total", amount: 100, grand: true }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
it("renders no QR block when qrImageUrl is unset", () => {
|
||||
const html = service.buildHtml(model());
|
||||
expect(html).not.toContain('class="qr"');
|
||||
});
|
||||
|
||||
it("renders the QR image when qrImageUrl is set", () => {
|
||||
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||
expect(html).toContain('class="qr"');
|
||||
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||
});
|
||||
|
||||
it("still shows the IRN text row via the ordinary summary grid", () => {
|
||||
const html = service.buildHtml(
|
||||
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
|
||||
);
|
||||
expect(html).toContain("EIMS IRN");
|
||||
expect(html).toContain("IRN-123");
|
||||
});
|
||||
|
||||
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
|
||||
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
|
||||
// the actual div's class attribute, not a bare substring match.
|
||||
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
|
||||
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
|
||||
'class="summary summary-with-qr"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain('class="seal"');
|
||||
expect(html).not.toContain("seal-image");
|
||||
});
|
||||
|
||||
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
|
||||
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||
expect(html).toContain('class="qr"');
|
||||
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||
expect(html).not.toContain("position: absolute");
|
||||
});
|
||||
|
||||
it("wraps a long IRN summary value rather than truncating it", () => {
|
||||
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
|
||||
expect(html).toContain(irn);
|
||||
expect(html).toContain("overflow-wrap: anywhere");
|
||||
});
|
||||
|
||||
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
|
||||
const html = service.buildThermalHtml(
|
||||
model({
|
||||
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).not.toContain("<td");
|
||||
expect(html).toContain("40ft container rail freight");
|
||||
expect(html).toContain("12 x");
|
||||
expect(html).toContain("2,948,207.4 Birr (ETB)");
|
||||
});
|
||||
|
||||
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain("width: 330px");
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
|
||||
import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
import { logoImageCss, logoMarkup } from "./logo-markup.util";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
@@ -14,6 +18,36 @@ import {
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
/**
|
||||
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
|
||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
|
||||
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
|
||||
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
|
||||
* same gateway.
|
||||
*/
|
||||
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||
|
||||
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
|
||||
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
|
||||
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
|
||||
|
||||
function esc(value: unknown): string {
|
||||
return String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function money(amount: unknown, currency: string): string {
|
||||
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
@@ -53,6 +87,20 @@ export interface InvoiceDocumentModel {
|
||||
totals: InvoiceDocumentTotal[];
|
||||
/** Override the round seal text; defaults from kind/status. */
|
||||
sealText?: string;
|
||||
/**
|
||||
* Company stamp image (data URL) to render instead of the plain text seal.
|
||||
* Callers normally leave this unset — `InvoiceDocumentService.render()`
|
||||
* fills it in from the single global stamp in StampSettingsService; set it
|
||||
* explicitly only to override that default for one document.
|
||||
*/
|
||||
stampImageUrl?: string | null;
|
||||
logoImageUrl?: string | null;
|
||||
/**
|
||||
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
|
||||
* see that column's comment). Set only once an invoice is actually registered; the IRN text
|
||||
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||
*/
|
||||
qrImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,12 +111,26 @@ export interface InvoiceDocumentModel {
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceDocumentService {
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
constructor(
|
||||
private readonly pdf: PdfRenderService,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async render(
|
||||
model: InvoiceDocumentModel,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const html = this.buildHtml(model);
|
||||
const stampImageUrl =
|
||||
model.stampImageUrl !== undefined
|
||||
? model.stampImageUrl
|
||||
: await this.stampSettings.getStampImageUrl();
|
||||
const logoImageUrl =
|
||||
model.logoImageUrl !== undefined
|
||||
? model.logoImageUrl
|
||||
: await this.logoSettings.getLogoImageUrl();
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl };
|
||||
|
||||
const html = this.buildHtml(resolvedModel);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
@@ -77,11 +139,128 @@ export class InvoiceDocumentService {
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||
// image, and omits the EIMS QR entirely — embedding a raster image
|
||||
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
|
||||
// when the Chromium-less path needs to carry the real stamp/QR too;
|
||||
// today it's a rare degraded fallback. The IRN text itself still
|
||||
// comes through (buildFallbackPdf renders model.summary same as HTML).
|
||||
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
|
||||
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
|
||||
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
|
||||
*
|
||||
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
|
||||
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
|
||||
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
|
||||
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
|
||||
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
|
||||
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
|
||||
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
|
||||
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
|
||||
*/
|
||||
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const logoImageUrl =
|
||||
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
|
||||
// Seal deliberately dropped — never fetched, so no stampSettings call either.
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
|
||||
|
||||
const html = this.buildThermalHtml(resolvedModel);
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} thermal invoice`,
|
||||
thermal: true,
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
|
||||
noFallback: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
buildThermalHtml(model: InvoiceDocumentModel): string {
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map(
|
||||
(row) =>
|
||||
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const itemBlocks = model.lines
|
||||
.map((item) => {
|
||||
const currency = item.currency ?? model.currency;
|
||||
return `<div class="item">
|
||||
<div class="item-desc">${esc(item.description)}</div>
|
||||
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(heading)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
|
||||
.doc { width: 100%; box-sizing: border-box; }
|
||||
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
|
||||
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
|
||||
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
|
||||
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
|
||||
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
|
||||
.row .label { color: #64748b; white-space: nowrap; }
|
||||
.row .value { text-align: right; overflow-wrap: anywhere; }
|
||||
.item { margin: 4px 0; }
|
||||
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
|
||||
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr img { width: 150px; height: 150px; }
|
||||
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
${logoInner}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<div class="title">${esc(heading)}</div>
|
||||
<div class="meta">${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}</div>
|
||||
<div class="rule"></div>
|
||||
${summaryRows}
|
||||
<div class="rule"></div>
|
||||
${itemBlocks}
|
||||
<div class="rule"></div>
|
||||
${totalRows}
|
||||
${qrMarkup}
|
||||
<div class="footer">Thank you</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
@@ -203,21 +382,17 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||
const sealCssClass = sealClass(model.stampImageUrl);
|
||||
const logoInner = logoMarkup(model.logoImageUrl);
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
||||
: "";
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
@@ -238,7 +413,7 @@ export class InvoiceDocumentService {
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
@@ -256,8 +431,21 @@ export class InvoiceDocumentService {
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
${sealImageCss()}
|
||||
${logoImageCss()}
|
||||
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
||||
.qr img { width: 90px; height: 90px; }
|
||||
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
|
||||
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
|
||||
.summary.summary-with-qr { margin-right: 270px; }
|
||||
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
|
||||
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
|
||||
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
|
||||
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
|
||||
the text itself was still escaping the box's own right edge until this was added). */
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
@@ -274,6 +462,7 @@ export class InvoiceDocumentService {
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoInner}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||
</div>
|
||||
@@ -283,8 +472,9 @@ export class InvoiceDocumentService {
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<div class="${sealCssClass}">${sealInner}</div>
|
||||
${qrMarkup}
|
||||
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The single decision every EDR document makes about its header logo: draw
|
||||
* the one uploaded company logo when configured (LogoSettingsService), or
|
||||
* render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next
|
||||
* to it already covers the no-logo case, so there is no text fallback here
|
||||
* (contrast seal-markup.util.ts, whose seal has no text of its own).
|
||||
*/
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* `<img>` markup for the header logo, or "" when unset. `logoImageUrl` is
|
||||
* expected to be a data URL from LogoSettingsService.getLogoImageUrl().
|
||||
* `className` defaults to "doc-logo" — each document supplies that class's
|
||||
* sizing in its own <style> block (see logoImageCss()).
|
||||
*/
|
||||
export function logoMarkup(
|
||||
logoImageUrl: string | null | undefined,
|
||||
className = "doc-logo",
|
||||
): string {
|
||||
if (!logoImageUrl) return "";
|
||||
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
|
||||
}
|
||||
|
||||
/** Default CSS for the header logo — append inside a document's <style> block. */
|
||||
export function logoImageCss(className = "doc-logo"): string {
|
||||
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
|
||||
}
|
||||
@@ -15,13 +15,43 @@ const PDF_PRINT_STYLES = `
|
||||
}
|
||||
</style>`;
|
||||
|
||||
/**
|
||||
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
||||
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
||||
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
||||
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
||||
*/
|
||||
const THERMAL_PAGE_WIDTH_MM = 80;
|
||||
const THERMAL_MARGIN_MM = 4;
|
||||
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
||||
const THERMAL_FEED_MM = 6;
|
||||
/** Guard against a runaway line-item list producing an absurd page. */
|
||||
const THERMAL_MAX_HEIGHT_MM = 1500;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||
landscape?: boolean;
|
||||
/**
|
||||
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
||||
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
||||
* `format: "A4"`.
|
||||
*/
|
||||
thermal?: boolean;
|
||||
/**
|
||||
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
||||
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
||||
* output" (it silently hands back a different document shape than what was asked for); the
|
||||
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
||||
* also supplied — an explicit fallback always wins.
|
||||
*/
|
||||
noFallback?: boolean;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
||||
* produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
@@ -52,16 +82,37 @@ export class PdfRenderService {
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
const thermal = opts.thermal ?? false;
|
||||
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
|
||||
// is defined as the LARGER of the content's height and the viewport's own height, so a
|
||||
// receipt shorter than the viewport would otherwise report the viewport height back, not
|
||||
// its true content height — a real page-length trailing blank space bug, not theoretical
|
||||
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
|
||||
// so scrollHeight always reflects the content, never the viewport.
|
||||
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
const pdf = thermal
|
||||
? await page.pdf({
|
||||
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: `${THERMAL_MARGIN_MM}mm`,
|
||||
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
||||
left: `${THERMAL_MARGIN_MM}mm`,
|
||||
right: `${THERMAL_MARGIN_MM}mm`,
|
||||
},
|
||||
})
|
||||
: await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
@@ -76,6 +127,15 @@ export class PdfRenderService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
if (!opts.fallback && opts.noFallback) {
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — it silently hands back a different document than what was asked for.
|
||||
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
||||
throw new InternalServerErrorException(
|
||||
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
||||
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
||||
);
|
||||
}
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
@@ -89,6 +149,20 @@ export class PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
||||
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
||||
* receipt, not a fixed A4-length page with blank space at the bottom.
|
||||
*/
|
||||
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
||||
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
||||
// isn't a known global to type-check against — the string is evaluated in the page's own
|
||||
// browser context regardless, same as the closure form would be.
|
||||
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
|
||||
/**
|
||||
* These three helpers are the single image-vs-text branch shared by every
|
||||
* EDR document's round seal, so a regression here silently unstamps invoices,
|
||||
* warehouse release papers and handover papers at once.
|
||||
*/
|
||||
describe("seal markup helpers", () => {
|
||||
const STAMP = "data:image/png;base64,QUJD";
|
||||
|
||||
describe("sealMarkup", () => {
|
||||
it("renders the stamp image when one is configured", () => {
|
||||
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
|
||||
`<img src="${STAMP}" alt="Company stamp" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to text rings when no stamp is configured", () => {
|
||||
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
|
||||
"<span>EDR<br />Warehouse<br />Cleared</span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats undefined as unset", () => {
|
||||
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
|
||||
});
|
||||
|
||||
it("accepts a bare string as a single line", () => {
|
||||
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
|
||||
});
|
||||
|
||||
it("escapes text lines so document data cannot inject markup", () => {
|
||||
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
|
||||
"<span><script>alert("x")</script></span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes the image src so it cannot break out of the attribute", () => {
|
||||
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
|
||||
'<img src="data:image/png;base64,A" onerror="x" alt="Company stamp" />',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealClass", () => {
|
||||
it("adds the image modifier only when stamped", () => {
|
||||
expect(sealClass(STAMP)).toBe("seal seal-image");
|
||||
expect(sealClass(null)).toBe("seal");
|
||||
});
|
||||
|
||||
it("honours a document's own seal selector", () => {
|
||||
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
|
||||
"sig-stamp-box sig-stamp-box-image",
|
||||
);
|
||||
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealImageCss", () => {
|
||||
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
|
||||
const css = sealImageCss();
|
||||
|
||||
expect(css).toContain(".seal.seal-image { border: none;");
|
||||
expect(css).toContain("transform: none;");
|
||||
// The ::before pseudo-element draws the inner ring of the text seal.
|
||||
expect(css).toContain(".seal.seal-image::before { content: none; }");
|
||||
expect(css).toContain(".seal img { max-width: 100%;");
|
||||
});
|
||||
|
||||
it("scopes every rule to the given selector", () => {
|
||||
const css = sealImageCss("sig-stamp-box");
|
||||
|
||||
expect(css).not.toContain(".seal");
|
||||
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
|
||||
expect(css).toContain(".sig-stamp-box img");
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user