diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1c797dc6..b6a7fdb69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: "passenger-portal" "passenger-backoffice" "payment-api" + "synapse" + "element-web" ) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -84,6 +86,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)) @@ -119,7 +125,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" ;; diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/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` 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` from + `@edr/api-common`. Services inject the repository class, never `Repository` 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..)`. +- 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//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files. +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. 2. The entity extends `BaseEntity` from `@edr/api-common`. 3. The repository extends `BaseRepository` from `@edr/api-common`. 4. The service injects the repository class (not `Repository` 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//.tsx` and `src/components//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=` 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. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -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` from - `@edr/api-common`. Services inject the repository class, never `Repository` 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..)`. -- 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//` with `entities/`, `dto/`, and the four - `.{module,controller,service,repository}.ts` files. -2. The entity extends `BaseEntity` from `@edr/api-common`. -3. The repository extends `BaseRepository` from `@edr/api-common`. -4. The service injects the repository class (not `Repository` 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//.tsx` and `src/components//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=` 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. diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d2359dd97..930c8f1ca 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -219,3 +219,22 @@ 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= diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..4ec11e082 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ 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"; @@ -50,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { 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"; @@ -116,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"; +// 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"; @@ -135,6 +140,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -213,6 +219,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + PaymentSettingsModule, StampSettingsModule, LogoSettingsModule, ContractTemplatesModule, @@ -252,6 +259,7 @@ if (!process.env.APPLICATION_NAME) { FleetHistoryModule, AiModule, AuditModule, + ChatModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 36ed7ae74..f9eab4d39 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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 diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -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 | 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> { + const resolved = new Map(); + 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; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts new file mode 100644 index 000000000..ce20b610b --- /dev/null +++ b/apps/edr-freight-api/src/config/chat.config.ts @@ -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!, + }; +}); diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..127b3ea62 --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -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, fn: () => void) => { + const prior: Record = {}; + 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"); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index c5565cbc1..e5530eaf2 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,5 +1,7 @@ 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. * @@ -30,6 +32,23 @@ export interface EimsConfig { 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; @@ -135,14 +154,14 @@ export interface EimsInvoiceConfig { buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +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; @@ -163,6 +182,14 @@ const parseCodeMap = (raw: string | undefined): Record => { 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; @@ -189,6 +216,10 @@ export default registerAs("eims", (): EimsConfig => { 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", @@ -229,9 +260,11 @@ export default registerAs("eims", (): EimsConfig => { unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), - buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), - buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_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), @@ -245,7 +278,10 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + 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(", ")}`, diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts new file mode 100644 index 000000000..ca48a7c5f --- /dev/null +++ b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts @@ -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 => { + const map: Record = {}; + 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 = 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 = buildMap((r) => [r[1], r[4]]); +export const ETHIOPIA_WOREDA_CODES: Record = 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]; +} diff --git a/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts new file mode 100644 index 000000000..0c4f5698f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.manual_payment_settings;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts new file mode 100644 index 000000000..102a5b85d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts new file mode 100644 index 000000000..879c9b472 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.consolidation_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts new file mode 100644 index 000000000..0a53bd530 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts @@ -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 { + 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 { + 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, + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index d46f63a7d..17b2d1580 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly> = { "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"], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 243b1ee4d..c5cc9a81e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); }); @@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} as never, { get: () => undefined } as never, + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager, savedLines }; } @@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} 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); @@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} 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); @@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => { {} 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 }; } @@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => { {} 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 }; } @@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, defaultManager, txManager, transaction }; }; @@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager }; }; @@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -943,6 +953,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, render, renderThermal }; }; diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 44e194a8d..d64361bf8 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity"; // 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"; @@ -201,6 +202,7 @@ export class BillingService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, private readonly config: ConfigService, + private readonly manualPaymentSettings: ManualPaymentSettingsService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -370,20 +372,24 @@ export class BillingService { 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 ('USD', 'ETB')") + .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); - - if (filter.currency) { - qb.andWhere("UPPER(invoice.currency) = :currency", { - currency: filter.currency, - }); - } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -465,7 +471,8 @@ export class BillingService { /** * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer - * or counter payment: stores the slip against the invoice and settles the + * 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 @@ -485,6 +492,13 @@ export class BillingService { ): Promise { 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."); } diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 6d58a7bec..75d39a500 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -151,7 +151,9 @@ describe("toEimsInvoice", () => { // EimsLineTax.discount comment in eims-invoice.mapper.ts. Discount: 25, TotalLineAmount: 1050, - Unit: "CTR", + // Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR + // unit of measure, and is never read for this field (see the mapper's own comment). + Unit: "PCS", }); expect(doc.ValueDetails).toEqual({ Discount: null, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index b8755c709..c72e61c7a 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -463,7 +463,13 @@ export function toEimsInvoice( const PreTaxValue = round2(num(line.amount)); const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); const ExciseTaxValue = round2(tax.exciseTaxValue); - const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + // `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge + // is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here + // (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex + // all at once) is what a prior version of this mapper did by mistake. MoR's own enum + // (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line + // uses the single configured default rather than guessing a per-line value that doesn't exist. + const unit = context.unitDefault; return { Discount: round2(tax.discount), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 9a5960295..c9bdc4845 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -36,9 +36,13 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = ); }); - it('sends a GL-created booking back to the GL who created it, not the customer', async () => { + it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => { service.operationChangesRequested( - booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }), + booking({ + customsClearingEnabled: true, + createdByRole: 'GL_ET', + createdByUserId: 'gl-user-1', + }), 'Cargo weight does not match the declaration', ); await flush(); @@ -54,7 +58,7 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(notifications.directSend).not.toHaveBeenCalled(); }); - it('still tells the customer when the booking is their own', async () => { + it('still tells the customer when the booking is a non-customs self-service booking', async () => { service.operationChangesRequested(booking(), 'Please attach the packing list'); await flush(); @@ -65,14 +69,38 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(notifications.directSend).toHaveBeenCalled(); }); - it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => { + it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => { + // Path B lets the customer open the ONE_TIME shipment instance themselves + // (contract-booking.service assertGate's customerMayInitiate) — createdByRole + // stays 'CUSTOMER', but GL still owns completing/resubmitting it. service.operationChangesRequested( - booking({ createdByRole: 'GL_ET', createdByUserId: null }), + booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }), 'Fix the declaration', ); await flush(); - expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + expect(sent.audience).toBe('BACKOFFICE'); + expect(notifications.directSend).not.toHaveBeenCalled(); + }); + + it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => { + service.operationChangesRequested( + booking({ + customsClearingEnabled: true, + createdByRole: 'GL_ET', + createdByUserId: null, + }), + 'Fix the declaration', + ); + await flush(); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 01f0fd053..78c07bd22 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -247,20 +247,27 @@ export class BookingLifecycleNotifierService { /** * Operations returned the operation request for changes. * - * A customs (Path B) booking was created BY GL Ethiopia on the customer's - * behalf — the customer cannot edit or resubmit it, so telling them to "update - * from the portal" is a dead end. Those go to the GL who created it, linking - * the contract clearance page they work from. Everything else (customer-made - * bookings) keeps the portal message. + * A customs (Path B) booking is completed by GL Ethiopia on the customer's + * behalf regardless of who opened the shipment instance — the customer-opened + * ONE_TIME case (see contract-booking.service assertGate) still stamps + * createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who + * created it. The customer cannot edit or resubmit a customs booking, so + * telling them to "update from the portal" is a dead end. Those go to the GL + * who created it when known, else the clearance desk, linking the contract + * clearance page they work from. Everything else (customer-made bookings) + * keeps the portal message. */ operationChangesRequested(b: Booking, note: string): void { - if (b.createdByRole === 'GL_ET' && b.createdByUserId) { + if (b.customsClearingEnabled) { const msg = `Operations returned booking ${b.reference} for changes: ${note}. ` + `Address it on the contract clearance page and resubmit to Operations.`; this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); void this.inbox.notify({ - recipients: { userIds: [b.createdByUserId] }, + recipients: + b.createdByRole === 'GL_ET' && b.createdByUserId + ? { userIds: [b.createdByUserId] } + : CLEARANCE_DESK, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: `Booking ${b.reference} needs changes`, @@ -463,6 +470,36 @@ export class BookingLifecycleNotifierService { ); } + /** + * A shared-wagon pairing is waiting for a human decision. Two customers' cargo + * on one wagon is a commercial call, so this never auto-advances. + */ + consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void { + this.inAppStaff( + b, + 'Shared wagon needs approval', + `Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`, + ); + } + + /** The pairing was approved; both halves move on to Operations together. */ + consolidationApprovedToStaff(b: Booking, partnerReference: string): void { + this.inAppStaff( + b, + 'Shared wagon approved', + `The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`, + ); + } + + /** The pairing was rejected; both halves go back to GL for changes. */ + consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void { + this.inAppStaff( + b, + 'Shared wagon rejected', + `The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`, + ); + } + /** Customer uploaded clearance documents — review is next. */ clearanceDocsUploadedToStaff(b: Booking): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts new file mode 100644 index 000000000..91b1bfd91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -0,0 +1,132 @@ +import { BookingTransitionService } from './booking-transition.service'; +import { Booking } from './entities/booking.entity'; + +/** + * Staff decisions on a consolidated pair. Two bookings sharing a wagon must move + * together: accepting one alone would put half a wagon into the approval chain, + * and cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, neither booking moved. + */ +describe('BookingTransitionService — paired staff decisions', () => { + function makeService(booking: Partial) { + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking as Booking), + }; + // Runs the callback so a throw propagates, which is what the all-or-nothing + // guarantee reduces to from this service's point of view. + const dataSource = { + transaction: jest.fn(async (cb: () => Promise) => cb()), + }; + + const service = new BookingTransitionService( + {} as never, // bookingsRepository + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + {} as never, // bookingBatchService + bookingsService as never, + {} as never, // bookingClearanceService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // containerValidationService + {} as never, // notifier + {} as never, // events + undefined, // milestoneService + dataSource as never, + ); + return { service, dataSource }; + } + + const paired = { + id: 'b-1', + reference: 'BK-1', + consolidationPartnerId: 'b-2', + } as Booking; + + it('accepts both halves with the same validity window', async () => { + const { service } = makeService(paired); + const accept = jest + .spyOn(service, 'acceptIntake') + .mockImplementation(async (id) => ({ id }) as Booking); + + const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }); + + expect(accept).toHaveBeenCalledTimes(2); + expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30); + expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('cancels both halves with the same reason', async () => { + const { service } = makeService(paired); + const cancel = jest + .spyOn(service, 'cancel') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'cancel', 'staff-1', { + reason: 'customer withdrew', + }); + + expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); + expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + }); + + it('propagates a failure on the second half so neither is committed', async () => { + const { service, dataSource } = makeService(paired); + jest + .spyOn(service, 'cancel') + .mockImplementationOnce(async (id) => ({ id }) as Booking) + .mockImplementationOnce(async () => { + throw new Error('partner is already in transit'); + }); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow('partner is already in transit'); + + // Both halves ran inside one transaction, so the throw rolls the first back. + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); + + it('refuses a booking that has no partner', async () => { + const { service } = makeService({ + id: 'b-1', + consolidationPartnerId: null, + } as Booking); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow(/no consolidation partner/i); + }); + + it('requires a validity window to accept', async () => { + const { service } = makeService(paired); + const accept = jest.spyOn(service, 'acceptIntake'); + + await expect( + service.applyPairedDecision('b-1', 'accept', 'staff-1', {}), + ).rejects.toThrow(/validity/i); + expect(accept).not.toHaveBeenCalled(); + }); + + it('routes operationAccept through the operation review on both halves', async () => { + const { service } = makeService(paired); + const review = jest + .spyOn(service, 'reviewOperationRequest') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {}); + + expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', { + note: undefined, + }); + expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', { + note: undefined, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2f50d0f38..556ce0e98 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -455,6 +455,75 @@ export class BookingTransitionService { return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); } + /** + * Run a staff decision across BOTH halves of a consolidated pair. + * + * Two bookings that share a wagon must move together: accepting one while the + * other stays behind would put half a wagon into the approval chain, and + * cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, the transaction rolls back and + * neither booking moved. + * + * Each half still runs the ordinary single-booking transition, so pricing, + * invoicing and notifications stay per booking: the customers are billed and + * notified separately, exactly as they are today. + */ + async applyPairedDecision( + bookingId: string, + decision: "accept" | "cancel" | "operationAccept" | "requestChanges", + actorId: string, + options: { reason?: string; note?: string; validityDays?: number } = {}, + ): Promise<{ booking: Booking; partner: Booking }> { + const booking = await this.bookingsService.findById(bookingId); + const partnerId = booking.consolidationPartnerId; + if (!partnerId) { + throw new BadRequestException( + "This booking has no consolidation partner — use the single-booking action.", + ); + } + + const runOne = async (id: string): Promise => { + switch (decision) { + case "accept": + // Same requirement as the single-booking accept: the approval chain + // needs a contract validity window. + if (!(Number(options.validityDays) > 0)) { + throw new BadRequestException( + "Contract validity (days) is required to accept.", + ); + } + return this.acceptIntake(id, actorId, Number(options.validityDays)); + case "cancel": + return this.cancel( + id, + options.reason ?? "Cancelled with its consolidation partner", + ); + case "operationAccept": + return this.reviewOperationRequest(id, "ACCEPT", actorId, { + note: options.note, + }); + case "requestChanges": + return this.requestChanges(id, options.note ?? "", actorId); + } + }; + + // Without a DataSource (unit tests hand-construct this service) fall back to + // running the two halves directly — the ordering guarantee still holds, only + // the rollback does not. + if (!this.dataSource) { + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + } + + return this.dataSource.transaction(async () => { + // Sequential: one connection per transaction context. + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + }); + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 9c4b69ad6..67a0f930d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -50,6 +50,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service'; import { scopedDirections } from '../user-trade-access/trade-scope.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { BookingsService } from './bookings.service'; +import { ConsolidationApprovalService } from './consolidation-approval.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; @@ -58,7 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, + ApproveConsolidationDto, CancelBookingDto, + PairedDecisionDto, + RejectConsolidationDto, RejectBookingDto, RequestChangesDto, ReviewDocumentDto, @@ -165,6 +169,7 @@ export class BookingsController { private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, private readonly wagonCancellationService: BookingWagonCancellationService, + private readonly consolidationApprovalService: ConsolidationApprovalService, ) {} @Post() @@ -1541,6 +1546,92 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // ── Shared-wagon (consolidation) approval gate ──────────────────────────── + // A consolidated pair is held here, not in the operations queue: two + // customers' cargo on one wagon is a commercial call, so a person signs off + // on the pairing before Operations sees either half. + + @Get("consolidation-approvals/queue") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", + }) + consolidationApprovalQueue() { + return this.consolidationApprovalService.queue(); + } + + @Get(":id/consolidation-approvals") + @BookingStaff(FREIGHT_PERMS.bookings.view) + @ApiOperation({ + summary: + "Approval history for this booking's shared wagon — who decided what, when, and why.", + }) + consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.consolidationApprovalService.historyForBooking(id); + } + + @Post("consolidation-approvals/:approvalId/approve") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Approve a shared wagon: both bookings leave the gate and continue to Operations together.", + }) + approveConsolidation( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: ApproveConsolidationDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.consolidationApprovalService.approve( + approvalId, + resolveAuthUserId(user) ?? "", + dto.note, + ); + } + + @Post("consolidation-approvals/:approvalId/reject") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Reject a shared wagon: both bookings go back to GL for changes with the reason.", + }) + rejectConsolidation( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: RejectConsolidationDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.consolidationApprovalService.reject( + approvalId, + resolveAuthUserId(user) ?? "", + dto.reason, + ); + } + + @Post(":id/paired-decision") + @BookingStaff(FREIGHT_PERMS.bookings.cancel) + @ApiOperation({ + summary: + "Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", + }) + async pairedDecision( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: PairedDecisionDto, + @CurrentUser() user: AuthUserPayload, + ) { + const { booking, partner } = await this.transitionService.applyPairedDecision( + id, + dto.decision, + resolveAuthUserId(user), + { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, + ); + // Sequential enrichment: both go back so the UI can refresh either tab. + const enrichedBooking = + await this.transitionService.enrichBookingResponse(booking); + const enrichedPartner = + await this.transitionService.enrichBookingResponse(partner); + return { booking: enrichedBooking, partner: enrichedPartner }; + } + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: "Cancel booking" }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 447292117..b3ea4a546 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ConsolidationApprovalService } from './consolidation-approval.service'; +import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository'; +import { ConsolidationApproval } from './entities/consolidation-approval.entity'; import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingWagonCancellation, CustomerTruckAssignment, CustomerTruckContainer, + ConsolidationApproval, ]), BillingModule, DocumentsModule, @@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, ConsolidationService, + ConsolidationApprovalService, + ConsolidationApprovalsRepository, ContainerValidationService, BookingReferenceDataService, BookingPricingService, @@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingLifecycleNotifierService, BookingTransitionService, ConsolidationService, + ConsolidationApprovalService, + ConsolidationApprovalsRepository, CustomerTruckService, ContainerReceiptService, BookingWagonCancellationService, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 310364927..7b042e0c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository { .find({ where: { contractId } }); } + /** + * Bookings a GL operator may manually link to `booking` as its odd-20ft + * consolidation partner (Path B customs flow). Unlike + * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact + * quantity complement — this lists CANDIDATES for a human to choose from, so + * the filter is deliberately looser: any other customs booking on the same + * route/direction that is itself carrying an odd 20ft count. Two odd counts + * always sum to even, so any pick fills the shared wagon. + * + * Bare instances awaiting completion have no persisted containers yet, so the + * odd-count test runs on the requested container lines when they exist and the + * booking is offered as a candidate when they do not (GL enters its cargo on + * the split form). + */ + async findManualConsolidationCandidates( + booking: Booking, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + // Never offer a booking that already shares a wagon with someone else. + .andWhere('b.consolidationPartnerId IS NULL') + // Customs-only: this manual flow exists because a customs (Path B) + // instance is completed by GL, not by the customer. + .andWhere('b.customsClearingEnabled = true') + // Same physical wagon ⇒ same route and same direction. + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + // Bookable = clearance finished and the booking is waiting to be completed, + // the same set completeUnderContract accepts, plus one already parked for a + // partner. + .andWhere('b.status IN (:...statuses)', { + statuses: [ + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'PENDING_CONSOLIDATION', + ], + }) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory: a bare instance has no containers yet (GL fills + // them on the split form) and stays a candidate; one that already carries + // cargo qualifies only when its 20ft total is odd. + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever @@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Link two bookings as consolidation partners WITHOUT touching their statuses. + * Used by the manual GL pairing, where both bookings have just been completed + * into their live status — unlike {@link pairConsolidation}, which exists to + * resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part + * of that resume. + */ + async linkConsolidationPartners( + bookingId: string, + partnerId: string, + ): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts new file mode 100644 index 000000000..5f121f630 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -0,0 +1,207 @@ +import { + ConsolidationApprovalService, + CONSOLIDATION_APPROVAL_PENDING, +} from './consolidation-approval.service'; +import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity'; +import { Booking } from './entities/booking.entity'; + +/** + * The shared-wagon approval gate. Two customers' cargo on one wagon is a + * commercial call, so the pair is held for a human decision instead of going + * straight to Operations. + * + * The invariants that matter: both halves are held and released TOGETHER (a + * decision on one side of a shared wagon is meaningless without the other), and + * a decided pairing cannot be decided twice. + */ +describe('ConsolidationApprovalService', () => { + const PENDING = { + id: 'ap-1', + bookingId: 'b-1', + partnerBookingId: 'b-2', + status: ConsolidationApprovalStatus.Pending, + requestedBy: 'gl-user', + }; + + function makeService(overrides: { + approvals?: Partial>; + bookingsRepository?: Partial>; + } = {}) { + const approvals = { + findPendingForBooking: jest.fn().mockResolvedValue(null), + findById: jest.fn().mockResolvedValue(PENDING), + create: jest.fn().mockResolvedValue({ id: 'ap-1' }), + decide: jest.fn().mockResolvedValue(true), + findQueue: jest.fn().mockResolvedValue([]), + findAllForBooking: jest.fn().mockResolvedValue([]), + ...overrides.approvals, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue(undefined), + createReviewNote: jest.fn().mockResolvedValue(undefined), + ...overrides.bookingsRepository, + }; + const bookingsService = { + findById: jest.fn(async (id: string) => + ({ id, reference: `BK-${id}` }) as Booking, + ), + }; + const notifier = { + consolidationApprovalRequestedToStaff: jest.fn(), + consolidationApprovedToStaff: jest.fn(), + consolidationRejectedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + }; + const dataSource = { + transaction: jest.fn(async (cb: () => Promise) => cb()), + }; + + const service = new ConsolidationApprovalService( + approvals as never, + bookingsRepository as never, + bookingsService as never, + notifier as never, + dataSource as never, + ); + return { service, approvals, bookingsRepository, notifier }; + } + + it('holds BOTH halves at the gate when a pairing is created', async () => { + const { service, approvals, bookingsRepository, notifier } = makeService(); + + await service.requestApproval('b-1', 'b-2', 'gl-user'); + + expect(approvals.create).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'b-1', + partnerBookingId: 'b-2', + requestedBy: 'gl-user', + }), + ); + // Neither half may sit in the operations queue while the wagon is unreviewed. + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: CONSOLIDATION_APPROVAL_PENDING, + }); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + status: CONSOLIDATION_APPROVAL_PENDING, + }); + expect( + notifier.consolidationApprovalRequestedToStaff, + ).toHaveBeenCalledTimes(1); + }); + + it('does not open a second review for a pairing already pending', async () => { + const { service, approvals } = makeService({ + approvals: { + findPendingForBooking: jest.fn().mockResolvedValue(PENDING), + }, + }); + + const result = await service.requestApproval('b-1', 'b-2', 'gl-user'); + + expect(result).toBe(PENDING); + expect(approvals.create).not.toHaveBeenCalled(); + }); + + it('releases BOTH halves to Operations on approval, logging who decided', async () => { + const { service, approvals, bookingsRepository, notifier } = makeService(); + + await service.approve('ap-1', 'approver-1', 'looks fine'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Approved, + 'approver-1', + 'looks fine', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: 'OPERATION_REQUEST_PENDING', + }); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + status: 'OPERATION_REQUEST_PENDING', + }); + // Operations only learns about the pair now — the gate is what kept it out. + expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2); + }); + + it('sends BOTH halves back to GL on rejection, with the reason on each', async () => { + const { service, approvals, bookingsRepository } = makeService(); + + await service.reject('ap-1', 'approver-1', 'partner cargo is wrong'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Rejected, + 'approver-1', + 'partner cargo is wrong', + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-1', + 'partner cargo is wrong', + 'CHANGES_REQUESTED', + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-2', + 'partner cargo is wrong', + 'CHANGES_REQUESTED', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: 'OPERATION_CHANGES_REQUESTED', + }); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + status: 'OPERATION_CHANGES_REQUESTED', + }); + }); + + it('lets the requester approve their own pairing', async () => { + // No maker-checker separation: the permission alone decides who may approve, + // and the audit trail still records requester and approver separately. + const { service, approvals } = makeService(); + + await service.approve('ap-1', 'gl-user'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Approved, + 'gl-user', + undefined, + ); + }); + + it('requires a reason to reject', async () => { + const { service, approvals } = makeService(); + + await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow( + /reason is required/i, + ); + expect(approvals.decide).not.toHaveBeenCalled(); + }); + + it('refuses a pairing that was already decided', async () => { + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Approved, + }), + }, + }); + + await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + /already approved/i, + ); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('loses cleanly when another approver decides the same pairing first', async () => { + // decide() writes only against a still-PENDING row, so the loser of the race + // affects nothing and must not move the bookings. + const { service } = makeService({ + approvals: { decide: jest.fn().mockResolvedValue(false) }, + }); + + await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + /already decided by someone else/i, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts new file mode 100644 index 000000000..ab4f4891d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -0,0 +1,242 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, + forwardRef, +} from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { Booking } from "./entities/booking.entity"; +import { + ConsolidationApproval, + ConsolidationApprovalStatus, +} from "./entities/consolidation-approval.entity"; +import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository"; +import { BookingsRepository } from "./bookings.repository"; +import { BookingsService } from "./bookings.service"; +import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; + +/** Where a rejected pair goes back to, so GL can fix and resubmit. */ +const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; + +/** The gate's own holding status — neither half reaches Operations from here. */ +export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; + +/** + * The shared-wagon approval gate. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A consolidated one does not: two customers' cargo rides one + * physical wagon under two separate invoices, so a person reviews the pairing + * before Operations sees either half. + * + * Both halves are held and released TOGETHER — the wagon is shared, so a + * decision on one is meaningless without the other. Every request is kept, + * decided or not: the table is the audit trail of who approved which pairing, + * when, and why. + * + * No maker-checker separation: whoever holds the approve permission may decide a + * pairing, including the GL user who created it. The record of who requested and + * who decided is still kept either way. + */ +@Injectable() +export class ConsolidationApprovalService { + private readonly logger = new Logger(ConsolidationApprovalService.name); + + constructor( + private readonly approvals: ConsolidationApprovalsRepository, + private readonly bookingsRepository: BookingsRepository, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + private readonly notifier: BookingLifecycleNotifierService, + private readonly dataSource: DataSource, + ) {} + + /** + * Park a newly consolidated pair for review instead of letting it continue to + * Operations. Called from the completion path once the two halves are linked. + * + * Idempotent: a pair that already has an undecided request is left alone, so a + * retried completion cannot open a second review of the same wagon. + */ + async requestApproval( + bookingId: string, + partnerBookingId: string, + requestedBy: string | null, + ): Promise { + const existing = await this.approvals.findPendingForBooking(bookingId); + if (existing) return existing; + + // Sequential reads: one connection per transaction context. + const booking = await this.bookingsService.findById(bookingId); + const partner = await this.bookingsService.findById(partnerBookingId); + if (!booking || !partner) { + throw new NotFoundException("Both bookings of the pair must exist."); + } + + const approval = await this.approvals.create({ + bookingId, + partnerBookingId, + requestedBy, + scheduledDate: booking.scheduledDate ?? null, + bookingReference: booking.reference ?? null, + partnerBookingReference: partner.reference ?? null, + }); + + // Hold BOTH halves: the wagon is shared, so neither may advance alone. + await this.bookingsRepository.update(bookingId, { + status: CONSOLIDATION_APPROVAL_PENDING, + } as never); + await this.bookingsRepository.update(partnerBookingId, { + status: CONSOLIDATION_APPROVAL_PENDING, + } as never); + + this.notifier.consolidationApprovalRequestedToStaff( + booking, + partner.reference ?? partnerBookingId, + ); + this.logger.log( + `Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`, + ); + return approval; + } + + /** + * Approve the pairing: both halves leave the gate and continue to Operations, + * which is exactly where a non-consolidated booking would already be. + * + * All-or-nothing — the two status writes and the decision record share one + * transaction, so the audit trail can never claim an approval that did not + * take effect. + */ + async approve( + approvalId: string, + decidedBy: string, + note?: string, + ): Promise<{ booking: Booking; partner: Booking }> { + const approval = await this.loadPending(approvalId); + + await this.dataSource.transaction(async () => { + const claimed = await this.approvals.decide( + approval.id, + ConsolidationApprovalStatus.Approved, + decidedBy, + note, + ); + // Lost the race to another approver deciding the same pairing. + if (!claimed) { + throw new ConflictException( + "This consolidation was already decided by someone else.", + ); + } + await this.bookingsRepository.update(approval.bookingId, { + status: "OPERATION_REQUEST_PENDING", + } as never); + await this.bookingsRepository.update(approval.partnerBookingId, { + status: "OPERATION_REQUEST_PENDING", + } as never); + }); + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + this.notifier.consolidationApprovedToStaff( + booking, + partner.reference ?? approval.partnerBookingId, + ); + // Operations only now learns about the pair — the gate is what kept it out. + this.notifier.operationRequestedToStaff(booking); + this.notifier.operationRequestedToStaff(partner); + return { booking, partner }; + } + + /** + * Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED + * with the reason, so the cargo or the partner can be changed and resubmitted. + */ + async reject( + approvalId: string, + decidedBy: string, + reason: string, + ): Promise<{ booking: Booking; partner: Booking }> { + if (!reason?.trim()) { + throw new BadRequestException( + "A reason is required to reject a consolidation.", + ); + } + const approval = await this.loadPending(approvalId); + + await this.dataSource.transaction(async () => { + const claimed = await this.approvals.decide( + approval.id, + ConsolidationApprovalStatus.Rejected, + decidedBy, + reason.trim(), + ); + if (!claimed) { + throw new ConflictException( + "This consolidation was already decided by someone else.", + ); + } + await this.bookingsRepository.createReviewNote( + approval.bookingId, + reason.trim(), + "CHANGES_REQUESTED", + ); + await this.bookingsRepository.createReviewNote( + approval.partnerBookingId, + reason.trim(), + "CHANGES_REQUESTED", + ); + await this.bookingsRepository.update(approval.bookingId, { + status: REJECTED_STATUS, + } as never); + await this.bookingsRepository.update(approval.partnerBookingId, { + status: REJECTED_STATUS, + } as never); + }); + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + this.notifier.consolidationRejectedToStaff( + booking, + partner.reference ?? approval.partnerBookingId, + reason.trim(), + ); + return { booking, partner }; + } + + /** Pending pairings awaiting a decision, oldest first. */ + queue(): Promise { + return this.approvals.findQueue(); + } + + /** Full decision history for one booking — who decided what, and when. */ + historyForBooking(bookingId: string): Promise { + return this.approvals.findAllForBooking(bookingId); + } + + /** The undecided request covering this booking, if any. */ + pendingForBooking(bookingId: string): Promise { + return this.approvals.findPendingForBooking(bookingId); + } + + private async loadPending(approvalId: string): Promise { + const approval = await this.approvals.findById(approvalId); + if (!approval) { + throw new NotFoundException(`Approval ${approvalId} not found`); + } + if (approval.status !== ConsolidationApprovalStatus.Pending) { + throw new ConflictException( + `This consolidation was already ${approval.status.toLowerCase()}.`, + ); + } + return approval; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts new file mode 100644 index 000000000..b398e7e8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts @@ -0,0 +1,120 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, In, Repository } from "typeorm"; + +import { + ConsolidationApproval, + ConsolidationApprovalStatus, +} from "./entities/consolidation-approval.entity"; + +/** + * Persistence for the shared-wagon approval gate. Rows are never deleted — + * decided rows are the audit trail of who approved which pairing and when. + */ +@Injectable() +export class ConsolidationApprovalsRepository { + private readonly repository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repository = this.dataSource.getRepository(ConsolidationApproval); + } + + /** + * The undecided request covering `bookingId`, from EITHER side of the pair — + * one row governs both halves, and the caller may hold either one. + */ + findPendingForBooking( + bookingId: string, + ): Promise { + return this.repository.findOne({ + where: [ + { bookingId, status: ConsolidationApprovalStatus.Pending }, + { + partnerBookingId: bookingId, + status: ConsolidationApprovalStatus.Pending, + }, + ], + }); + } + + /** Every request touching this booking, newest first (the audit trail). */ + findAllForBooking(bookingId: string): Promise { + return this.repository.find({ + where: [{ bookingId }, { partnerBookingId: bookingId }], + order: { createdAt: "DESC" }, + }); + } + + findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + /** Pending requests for the review queue, oldest first (FIFO). */ + findQueue(): Promise { + return this.repository.find({ + where: { status: ConsolidationApprovalStatus.Pending }, + relations: { + booking: { company: true }, + partnerBooking: { company: true }, + }, + order: { requestedAt: "ASC" }, + }); + } + + create(input: { + bookingId: string; + partnerBookingId: string; + requestedBy?: string | null; + scheduledDate?: Date | null; + bookingReference?: string | null; + partnerBookingReference?: string | null; + }): Promise { + return this.repository.save( + this.repository.create({ + ...input, + status: ConsolidationApprovalStatus.Pending, + requestedAt: new Date(), + }), + ); + } + + /** + * Record the decision. Written only against a row still PENDING, so two + * approvers racing on the same pairing cannot both succeed — the second + * update matches nothing and the caller sees `false`. + */ + async decide( + id: string, + status: + | ConsolidationApprovalStatus.Approved + | ConsolidationApprovalStatus.Rejected, + decidedBy: string | null, + decisionNote?: string | null, + ): Promise { + const result = await this.repository.update( + { id, status: ConsolidationApprovalStatus.Pending }, + { + status, + decidedBy, + decidedAt: new Date(), + decisionNote: decisionNote ?? null, + }, + ); + return (result.affected ?? 0) > 0; + } + + /** Undecided requests covering any of these bookings (list badging). */ + findPendingForBookings( + bookingIds: string[], + ): Promise { + if (bookingIds.length === 0) return Promise.resolve([]); + return this.repository.find({ + where: [ + { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, + { + partnerBookingId: In(bookingIds), + status: ConsolidationApprovalStatus.Pending, + }, + ], + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 63ad5b9f4..9631def73 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -125,3 +125,59 @@ export class OperationReviewDto { @IsString() note?: string; } + +/** + * A staff decision applied to BOTH halves of a consolidated pair. The two + * bookings share a wagon, so they advance or cancel together — never one alone. + */ +export class PairedDecisionDto { + @ApiProperty({ + enum: ["accept", "cancel", "operationAccept", "requestChanges"], + description: 'Which staff decision to apply to both bookings.', + }) + @IsIn(["accept", "cancel", "operationAccept", "requestChanges"]) + decision!: "accept" | "cancel" | "operationAccept" | "requestChanges"; + + @ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." }) + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ + description: "Message to the customer (decision=requestChanges).", + }) + @IsOptional() + @IsString() + note?: string; + + @ApiPropertyOptional({ + description: "Contract validity window in days (decision=accept).", + }) + @IsOptional() + @IsInt() + @Min(1) + validityDays?: number; +} + +/** Approve a shared-wagon pairing. The note is optional context for the audit. */ +export class ApproveConsolidationDto { + @ApiPropertyOptional({ + description: "Optional note recorded with the approval.", + maxLength: 500, + }) + @IsOptional() + @IsString() + note?: string; +} + +/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */ +export class RejectConsolidationDto { + @ApiProperty({ + description: + "Why the pairing is rejected. Sent back to GL on both bookings.", + maxLength: 500, + }) + @IsString() + @MinLength(1) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index cdcfbc3eb..c85e15cfb 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [ // the booking enters the batch holding pool. 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', + // Shared-wagon review gate: a consolidated pair waits for a human decision + // before either half reaches Operations. Two customers' cargo on one wagon is + // a commercial call, so it is never auto-advanced. + 'CONSOLIDATION_APPROVAL_PENDING', 'OPERATION_PRICE_PENDING_CONFIRM', ] as const; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts new file mode 100644 index 000000000..91c3dcdee --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts @@ -0,0 +1,98 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "./booking.entity"; + +export enum ConsolidationApprovalStatus { + Pending = "PENDING", + Approved = "APPROVED", + Rejected = "REJECTED", +} + +/** + * Approval gate for a consolidated (shared-wagon) booking pair. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A consolidated one does not: two customers' cargo rides one + * physical wagon, under two separate invoices and two separate liabilities. That + * pairing is a commercial decision, so a person reviews it before Operations + * sees either half. + * + * The pair is approved as a UNIT — one row covers both halves — so nobody can + * 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, when, + * and why. + */ +@Entity({ schema: "freight", name: "consolidation_approvals" }) +@Index(["bookingId", "status"]) +@Index(["status"]) +export class ConsolidationApproval extends BaseEntity { + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + /** The other half of the shared wagon. */ + @Column({ name: "partner_booking_id", type: "uuid" }) + partnerBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "partner_booking_id" }) + partnerBooking?: Booking; + + @Column({ + name: "status", + type: "enum", + enum: ConsolidationApprovalStatus, + default: ConsolidationApprovalStatus.Pending, + }) + status!: ConsolidationApprovalStatus; + + /** IAM user id of the GL staff whose completion created the pairing. */ + @Column({ name: "requested_by", type: "uuid", nullable: true }) + requestedBy?: string | null; + + @Column({ name: "requested_at", type: "timestamptz", default: () => "now()" }) + requestedAt!: Date; + + /** IAM user id of the approver; null while pending. */ + @Column({ name: "decided_by", type: "uuid", nullable: true }) + decidedBy?: string | null; + + @Column({ name: "decided_at", type: "timestamptz", nullable: true }) + decidedAt?: Date | null; + + /** Why it was approved or rejected. Required on reject, optional on approve. */ + @Column({ + name: "decision_note", + type: "varchar", + length: 500, + nullable: true, + }) + decisionNote?: string | null; + + // ── Snapshot ────────────────────────────────────────────────────────────── + // Copied at request time so the audit trail still reads correctly after the + // bookings themselves move on (rebooked to another day, cancelled, renamed). + + @Column({ name: "scheduled_date", type: "timestamptz", nullable: true }) + scheduledDate?: Date | null; + + @Column({ + name: "booking_reference", + type: "varchar", + length: 50, + nullable: true, + }) + bookingReference?: string | null; + + @Column({ + name: "partner_booking_reference", + type: "varchar", + length: 50, + nullable: true, + }) + partnerBookingReference?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts new file mode 100644 index 000000000..75bbe9a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NotificationType, type NotifyInput } from '@edr/types'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + +/** + * Best-effort per-type routing to an existing dept room. Anything not listed + * (including GENERIC) falls through to #freight-alerts — safer than a wrong + * guess at which department a type belongs to. Extend as real usage shows + * which types actually want a dept room instead of the shared feed. + * + * `name` matters only if this bridge is the very first thing to touch that + * alias (normally the nightly/on-demand reconcile creates dept rooms first, + * with the position's real name) — ensureRoom never renames an existing + * room, so this must match what ChatProvisioningService would have used. + */ +const ROOM_FOR_TYPE: Partial> = { + [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, + [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, +}; + +/** + * Mirrors BACKOFFICE-audience notifications into chat so staff see them + * without having the inbox open. Hooked once into + * NotificationInboxService.notify() — every one of that service's ~20 + * callers gets this for free. + * + * Gated on BACKOFFICE only: notify() also serves PORTAL (customer) + * notifications, which must never land in an internal staff room. + */ +@Injectable() +export class ChatBridgeService { + private readonly logger = new Logger(ChatBridgeService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async bridge(input: NotifyInput): Promise { + if (!this.config.enabled) return; + + try { + const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM; + const roomId = await this.matrix.ensureRoom(room.alias, room.name); + const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`; + const html = `${escapeHtml(input.title)}
${escapeHtml(input.body)}${ + input.link ? `
${escapeHtml(input.link)}` : '' + }`; + await this.matrix.sendMessage(roomId, body, html); + } catch (err) { + // Same contract as NotificationInboxService.notify(): a chat-bridge + // failure must never break or roll back the notification that + // triggered it. + this.logger.error( + `Chat bridge failed for ${input.type}: ${(err as Error).message}`, + ); + } + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts new file mode 100644 index 000000000..b122d8073 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -0,0 +1,237 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { MatrixClient } from './matrix.client'; + +/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat + * (one org, one unit), so this is the entire scope of what gets provisioned. */ +const ORG_KEY = 'edr_freight'; +const UNIT_KEY = 'edr_freight_app'; + +const SPACE_ALIAS = 'edr-freight'; +const GENERAL_ALIAS = 'general'; + +interface PositionHolder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +export interface ReconcileResult { + rooms: number; + joined: number; + kicked: number; + deactivated: number; +} + +/** + * Keeps Matrix rooms and their membership in sync with IAM's unit/position + * tree. There is no local hook on "employee position changed" — IAM writes + * happen inside the vendored @tria-plc/iamapi-common package — so this is a + * reconcile loop, not an event handler: nightly, plus on-demand via + * POST /chat/sync. + * + * Room identity is a deterministic alias (#dept-), not a stored + * mapping table — resolved via the directory API, created on first miss. + * Room membership is diffed against Matrix's own joined_members, not a local + * snapshot — so a user removed from IAM disappears from chat on the very + * next reconcile, with no extra state for this service to own. + */ +@Injectable() +export class ChatProvisioningService { + private readonly logger = new Logger(ChatProvisioningService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly matrix: MatrixClient, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' }) + async scheduledReconcile(): Promise { + try { + const result = await this.reconcile(); + this.logger.log( + `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + + `${result.kicked} kicked, ${result.deactivated} deactivated`, + ); + } catch (err) { + // Never throws into the scheduler — chat provisioning must not be able + // to take down anything else on the cron registry. + this.logger.error( + `Chat reconcile failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + /** Every current holder in the unit, or just one person's rows when `userId` is given. */ + private async currentHolders(userId?: string): Promise { + return this.dataSource.query( + `SELECT p.key AS "positionKey", + COALESCE(p.name->>'en', p.key) AS "positionName", + e.user_id AS "userId", + COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName" + FROM iam.employee_positions ep + JOIN iam.employees e ON e.id = ep.employee_id + JOIN iam.positions p ON p.id = ep.position_id + JOIN iam.units u ON u.id = p.unit_id + JOIN iam.organizations o ON o.id = u.organization_id + JOIN iam.users iu ON iu.id = e.user_id + WHERE ep.is_current = true + AND e.is_current = true + AND o.key = $1 + AND u.key = $2 + ${userId ? 'AND e.user_id = $3' : ''}`, + userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY], + ); + } + + /** + * Put one person in their rooms right now. + * + * {@link reconcile} is nightly, so without this a new employee's first + * sign-in shows an empty client until 3AM — the SSO handoff creates their + * account but joins them to nothing. Called on every /chat/sso, so it is + * scoped to the one user (a full reconcile per click would be a room-count + * multiple of Matrix calls) and every step is get-or-create. + * + * Someone holding no current position in the unit joins nothing, by the same + * rule the reconcile uses — chat membership follows the org tree. + */ + async joinUserRooms(userId: string, displayName: string): Promise { + const positions = await this.currentHolders(userId); + if (positions.length === 0) return 0; + + const mxid = this.matrix.mxidFor(userId, displayName); + // The JWT login auto-registers too, but that happens after this runs and + // the admin join API 404s on an account that does not exist yet. + await this.matrix.ensureUser(mxid, displayName); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + await this.matrix.ensureJoined(generalRoomId, mxid); + + for (const position of positions) { + const roomId = await this.matrix.ensureRoom( + `dept-${position.positionKey}`, + position.positionName, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(roomId, mxid); + } + + return positions.length + 1; + } + + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + private async syncMembership( + roomId: string, + desiredUserIds: Set, + botMxid: string, + ): Promise<{ joined: number; kicked: string[] }> { + const current = await this.matrix.joinedMembers(roomId); + const currentSet = new Set(current.filter((id) => id !== botMxid)); + + let joined = 0; + for (const userId of desiredUserIds) { + if (!currentSet.has(userId)) { + await this.matrix.ensureJoined(roomId, userId); + joined += 1; + } + } + + const kicked: string[] = []; + for (const userId of currentSet) { + if (!desiredUserIds.has(userId)) { + await this.matrix.kick(roomId, userId, 'No longer assigned to this room'); + kicked.push(userId); + } + } + + return { joined, kicked }; + } + + async reconcile(): Promise { + const holders = await this.currentHolders(); + const botMxid = await this.matrix.whoami(); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + + const allUserIds = new Set( + holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), + ); + + // Accounts are otherwise only created lazily on first JWT login (see + // ChatSsoService) — force-joining someone who has never clicked "Chat" + // yet 404s ("User not found") without this. + const seenUserIds = new Set(); + for (const h of holders) { + const mxid = this.matrix.mxidFor(h.userId, h.userName); + if (seenUserIds.has(mxid)) continue; + seenUserIds.add(mxid); + await this.matrix.ensureUser(mxid, h.userName); + } + + let rooms = 2; // space + general + let joined = 0; + let kicked = 0; + // A user kicked from anything while holding zero current positions + // anywhere in the unit (allUserIds spans every position) is a full + // leaver, not just moved between positions — deactivate their account. + const kickedUserIds = new Set(); + + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); + joined += generalDiff.joined; + kicked += generalDiff.kicked.length; + generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + + const byPosition = new Map }>(); + for (const h of holders) { + const entry = byPosition.get(h.positionKey) ?? { + name: h.positionName, + userIds: new Set(), + }; + entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName)); + byPosition.set(h.positionKey, entry); + } + + for (const [positionKey, { name, userIds }] of byPosition) { + const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, { + parentSpaceId: spaceId, + }); + rooms += 1; + + const diff = await this.syncMembership(roomId, userIds, botMxid); + joined += diff.joined; + kicked += diff.kicked.length; + diff.kicked.forEach((uid) => kickedUserIds.add(uid)); + } + + let deactivated = 0; + for (const userId of kickedUserIds) { + if (allUserIds.has(userId)) continue; // moved position, still current elsewhere + try { + await this.matrix.deactivateUser(userId); + deactivated += 1; + } catch (err) { + this.logger.warn( + `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + ); + } + } + + return { rooms, joined, kicked, deactivated }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts new file mode 100644 index 000000000..22edb8e1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -0,0 +1,91 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { SignJWT } from 'jose'; + +import chatConfig from '../../config/chat.config'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { MatrixClient, chatLocalpart } from './matrix.client'; + +/** Long enough for one login call, short enough to be worthless if it leaks. */ +const JWT_TTL_SECONDS = 60; + +function displayName(user: TCurrentUser): string { + return ( + user.name?.en || + Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) || + user.username || + user.email + ); +} + +/** + * The SSO handoff: turn an already-authenticated freight session into a + * one-click Element sign-in link, with no second password anywhere. + * + * 1. Sign a short-lived JWT asserting this user's id (Synapse's + * org.matrix.login.jwt auto-registers the account on first use). + * 2. Trade that JWT for a real Matrix session. + * 3. Hand the caller a link to Element's sso.html shim, which writes that + * session into localStorage and drops the user straight into Element. + * + * Step 3 used to mint a one-shot login_token and let Element redeem it. That + * path is capped at one request per user per minute by a limiter hardcoded in + * Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a + * spent token surfaces in Element as "Incorrect username and/or password". + * Element accepts a plaintext token out of localStorage (Lifecycle.ts + * getStoredToken/tryDecryptToken), so handing over the session we already hold + * removes both failure modes and one round-trip. + */ +@Injectable() +export class ChatSsoService { + private readonly logger = new Logger(ChatSsoService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + private readonly provisioning: ChatProvisioningService, + ) {} + + async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { + const secret = new TextEncoder().encode(this.config.jwtSecret); + const name = displayName(user); + + // Before the link, not after: the reconcile that fills rooms is nightly, so + // a first sign-in would otherwise open an empty client. Best-effort — + // failing to join a room is no reason to refuse someone a sign-in link. + try { + await this.provisioning.joinUserRooms(user.id, name); + } catch (err) { + this.logger.error( + `Room join on sign-in failed for ${user.id}: ${(err as Error).message}`, + ); + } + // Synapse takes the localpart straight from `sub` on auto-registration, so + // this must be byte-identical to what ChatProvisioningService derives for + // the same person — otherwise SSO signs them into one account while the + // reconcile force-joins a different one into the rooms. + const jwt = await new SignJWT({ name }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(chatLocalpart(user.id, name)) + .setIssuer('edr-freight-api') + .setAudience('matrix') + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(secret); + + const session = await this.matrix.loginWithJwt(jwt); + + // Session goes in the URL fragment, never the query: a fragment is not sent + // to any server, so the token stays out of Element's access log, and + // sso.html replaces the entry so it does not linger in history either. + const params = new URLSearchParams({ + hs: this.config.publicBaseUrl, + t: session.access_token, + u: session.user_id, + d: session.device_id, + }); + return { url: `${this.config.webUrl}/sso.html#${params.toString()}` }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts new file mode 100644 index 000000000..0ecca04c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { ChatSync } from '../../common/booking-guards'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; + +@ApiTags('chat') +@Controller('chat') +@ApiBearerAuth() +export class ChatController { + constructor( + private readonly sso: ChatSsoService, + private readonly provisioning: ChatProvisioningService, + ) {} + + @Get('sso') + @UseGuards(JwtGuard) + @ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' }) + getSso(@CurrentUser() user: TCurrentUser) { + return this.sso.getSsoUrl(user); + } + + @Post('sync') + @ChatSync() + @ApiOperation({ + summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)', + }) + sync() { + return this.provisioning.reconcile(); + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts new file mode 100644 index 000000000..8df827339 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ChatBridgeService } from './chat-bridge.service'; +import { ChatController } from './chat.controller'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; +import { MatrixClient } from './matrix.client'; + +@Module({ + controllers: [ChatController], + providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], + // ChatBridgeService: consumed by NotificationInboxModule to mirror + // BACKOFFICE notifications into chat — see notification-inbox.module.ts. + exports: [ChatBridgeService], +}) +export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts new file mode 100644 index 000000000..ea5faa978 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -0,0 +1,35 @@ +import { chatLocalpart } from './matrix.client'; + +describe('chatLocalpart', () => { + it('reads from the name, not the id', () => { + expect( + chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'), + ).toBe('nati-wondi.03f5eb'); + }); + + it('separates two people who share a name', () => { + // Both of these are real dev rows — same name, different employees. + const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA'); + const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA'); + expect(a).not.toBe(b); + }); + + it('is stable for the same person', () => { + const id = '7d798218-09de-47a1-98eb-f61ec44e9280'; + expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod')); + }); + + it('still yields a usable localpart for a name that slugs to nothing', () => { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe( + 'user.7d7982', + ); + }); + + it('only emits characters Matrix accepts in a localpart', () => { + for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch( + /^[a-z0-9._=\-/]+$/, + ); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts new file mode 100644 index 000000000..1cd09ac33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -0,0 +1,320 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; + +import chatConfig from '../../config/chat.config'; + +/** + * Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API + * calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a + * browser/Element concern; the server side only ever provisions rooms/users + * and posts bot messages, so a fetch wrapper is the whole job. + * + * All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That + * same account also posts the notification-bridge messages (see + * ChatBridgeService) — one bot/admin account covers both jobs, no separate + * bot user needed. + */ +/** + * Localpart of a staff member's MXID: their name, plus the first 6 hex of + * their freight user id. + * + * The tail is not decoration. Names collide — 19 of the 114 users in the dev + * IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are + * two different people) — and an MXID is permanent, so a bare slug would hand + * two employees the same Matrix account and each other's rooms. The id is + * already random, so 6 hex of it separates them without a lookup or a mapping + * table, and keeps the derivation pure: ChatSsoService (which mints the JWT + * `sub`) and ChatProvisioningService (which force-joins rooms) must agree on + * this string exactly or they provision two accounts per person. + */ +export function chatLocalpart(userId: string, displayName: string): string { + const slug = displayName + // NFKD splits an accent off its letter; the non-alnum sweep below then + // folds the leftover mark into the same `-` run as the neighbouring space. + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + // Amharic-only names slug to nothing — the tail still makes it unique. + return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; +} + +@Injectable() +export class MatrixClient { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + ) {} + + /** + * Alias localparts go in a URL path segment, so a `/` in one is fatal: + * Synapse decodes the path before routing, and `%2F` splits the request into + * a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404 + * as "no such room" and ensureRoom then tries to create the same broken alias + * on every run. Position keys are `edr_freight_app/opn` shaped, so this hits + * every dept room but the handful whose key happens to be a bare word. + */ + private static aliasSafe(alias: string): string { + return alias.replace(/[^A-Za-z0-9._=-]/g, '-'); + } + + /** `@:` — the one place this format is assembled. */ + mxid(localpart: string): string { + return `@${localpart}:${this.config.serverName}`; + } + + /** The MXID of a freight user — see {@link chatLocalpart}. */ + mxidFor(userId: string, displayName: string): string { + return this.mxid(chatLocalpart(userId, displayName)); + } + + get serverName(): string { + return this.config.serverName; + } + + private async request( + method: string, + path: string, + body?: unknown, + token: string = this.config.adminToken, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + /** No auth — only /login accepts a bare JWT with nothing else on the request. */ + private async publicRequest( + method: string, + path: string, + body: unknown, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** 404 → null. Every other non-2xx still throws via {@link request}. */ + private async requestOrNull( + method: string, + path: string, + token?: string, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** Sign an already-authenticated freight session into a Matrix session. */ + loginWithJwt( + jwt: string, + ): Promise<{ access_token: string; user_id: string; device_id: string }> { + return this.publicRequest('POST', '/_matrix/client/v3/login', { + type: 'org.matrix.login.jwt', + token: jwt, + initial_device_display_name: 'EDR Backoffice', + }); + } + + /** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */ + async whoami(): Promise { + const res = await this.request<{ user_id: string }>( + 'GET', + '/_matrix/client/v3/account/whoami', + ); + return res.user_id; + } + + /** Currently-joined user ids for a room (not full member-event state). */ + async joinedMembers(roomId: string): Promise { + const res = await this.request<{ joined: Record }>( + 'GET', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, + ); + return Object.keys(res.joined); + } + + // No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token + // is rate limited to 1 request per user per MINUTE, hardcoded in Synapse + // (rest/client/login_token_request.py: "Ratelimit aggressively … could be + // abused by a malicious client to create many sessions") and not settable + // from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED. + // ChatSsoService hands Element the session from loginWithJwt directly + // instead, which needs no second call. + + /** null when the alias doesn't resolve to a room yet. */ + resolveAlias(alias: string): Promise<{ room_id: string } | null> { + return this.requestOrNull( + 'GET', + `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + ); + } + + createRoom(input: { + alias: string; + name: string; + topic?: string; + isSpace?: boolean; + parentSpaceId?: string; + }): Promise<{ room_id: string }> { + return this.request('POST', '/_matrix/client/v3/createRoom', { + room_alias_name: input.alias, + name: input.name, + topic: input.topic, + preset: 'private_chat', + creation_content: input.isSpace ? { type: 'm.space' } : undefined, + initial_state: input.parentSpaceId + ? [ + { + type: 'm.space.parent', + state_key: input.parentSpaceId, + content: { via: [this.config.serverName], canonical: true }, + }, + ] + : undefined, + }); + } + + addToSpace(spaceId: string, childRoomId: string): Promise { + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`, + { via: [this.config.serverName] }, + ); + } + + /** + * Get-or-create by alias — the room identity scheme this whole module + * relies on instead of a local id-mapping table. Idempotent: safe to call + * on every reconcile run and every bridged notification alike. + */ + async ensureRoom( + rawAlias: string, + name: string, + opts: { isSpace?: boolean; parentSpaceId?: string } = {}, + ): Promise { + const alias = MatrixClient.aliasSafe(rawAlias); + const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); + if (existing) return existing.room_id; + + const { room_id } = await this.createRoom({ + alias, + name, + isSpace: opts.isSpace, + parentSpaceId: opts.parentSpaceId, + }); + if (opts.parentSpaceId) { + await this.addToSpace(opts.parentSpaceId, room_id); + } + return room_id; + } + + /** + * Create the account if absent (no password — this deployment is JWT-SSO + * only), or no-op if it already exists. Needed before force-joining a + * position holder who has never clicked "Chat": accounts are otherwise + * only created lazily on first JWT login, and the admin join API 404s + * ("User not found") on an account that doesn't exist yet. + */ + async ensureUser(userId: string, displayName?: string): Promise { + const existing = await this.requestOrNull<{ name: string }>( + 'GET', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + ); + if (existing) return; + await this.request( + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + displayName ? { displayname: displayName } : {}, + ); + } + + /** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */ + forceJoin(roomIdOrAlias: string, userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`, + { user_id: userId }, + ); + } + + /** + * Force-join, treating "already a member" as success. Synapse answers a + * repeat join with 403 `M_FORBIDDEN: " is already in the room."`, which + * is a failure only if you assumed you knew the membership first. Callers + * that just want someone in a room (sign-in, reconcile racing itself) want + * this; the raw 403 tells them nothing they can act on. + */ + async ensureJoined(roomIdOrAlias: string, userId: string): Promise { + try { + await this.forceJoin(roomIdOrAlias, userId); + } catch (err) { + if (!/already in the room/i.test((err as Error).message)) throw err; + } + } + + kick(roomId: string, userId: string, reason: string): Promise { + return this.request( + 'POST', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, + { user_id: userId, reason }, + ); + } + + /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ + deactivateUser(userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, + { erase: false }, + ); + } + + sendMessage(roomId: string, body: string, formattedBody?: string): Promise { + const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + formattedBody + ? { + msgtype: 'm.text', + body, + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + } + : { msgtype: 'm.text', body }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 175533c6d..5d29b32f6 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -288,10 +288,25 @@ export class CompaniesController { dto.roles, dto.nationality, dto.cooperative, + dto.investorLicence, ); return new CompanyInfoResponseDto(profile, company); } + @Post("onboarding/revert-to-etrade") + @PortalCustomer() + @ApiOperation({ + summary: + "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", + }) + async revertToRegularCompany( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.revertToRegularCompany(user.id); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts new file mode 100644 index 000000000..9de99186f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -0,0 +1,292 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; +import { + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * A foreign company on an Investment Commission licence has no eTrade record, + * so it types its registration — and the flag saying so is what makes the + * backoffice treat those fields as unverified. Two things must hold: only a + * foreign company can carry it, and dropping it must not leave the typed + * registration behind looking like eTrade's. + * + * The dropping half is shared with the co-operative route, which has the same + * "eTrade holds nothing" shape, so it is exercised here for both. + */ +function makeService(company: Record | null) { + const companiesRepo = { + findById: jest.fn(async () => company), + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "company-1", + ...row, + })), + existsByTin: jest.fn(async () => false), + }; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async (): Promise[]> => []), + updateStatus: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "cp-1", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const profilesRepo = { + findByUserId: jest.fn(async () => + company + ? { id: "external-1", companyId: "company-1", company: { id: "company-1" } } + : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "external-1", + ...row, + })), + update: jest.fn(async () => null), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} as never, + {} as never, + profilesRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, + ); + + return { service, companiesRepo, companyProfilesRepo, profilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = ( + service: CompaniesService, + nationality: CompanyNationality | undefined, + cooperative: boolean, + investorLicence: boolean, +) => + service.startOnboarding( + identity as never, + CompanyType.Customer, + [ProfileType.importer], + nationality, + cooperative, + investorLicence, + ); + +describe("the foreign investment-licence route", () => { + it("refuses the flag for an Ethiopian company", async () => { + const { service } = makeService(null); + await expect( + start(service, CompanyNationality.Ethiopian, false, true), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses the flag alongside the co-operative one", async () => { + const { service } = makeService(null); + await expect( + start(service, CompanyNationality.Foreign, true, true), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stores the flag on a new foreign draft", async () => { + const { service, companiesRepo } = makeService(null); + await start(service, CompanyNationality.Foreign, false, true); + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true }, + }), + ); + }); + + it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + attributes: { investorLicence: true, etradeManagerName: "Typed Name" }, + }); + + await service.revertToRegularCompany("user-1"); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.attributes).toEqual({}); + expect(updates.status).toBe(CompanyStatus.Pending); + // The wizard treats a populated registration as a passed lookup, so leaving + // any of it behind would walk the customer straight past the eTrade step. + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingCompleted: false, + onboardingStep: "company", + }); + }); + + it("clears the typed registration when the box is un-ticked on the way back", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true, etradeManagerName: "Typed Name" }, + region: "Addis Ababa", + licenceNumber: "TYPED-1", + }); + + await start(service, CompanyNationality.Foreign, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + // The wizard sends both flags; the typed manager does not survive. + expect(updates.attributes).toEqual({ + cooperative: false, + investorLicence: false, + }); + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + // Resume must land back on the company step, or the customer never reaches + // the eTrade lookup they just opted back into. + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingStep: "company", + }); + }); + + it("does the same for a co-operative that stops being one", async () => { + const { service, companiesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Ethiopian, + attributes: { cooperative: true }, + region: "Oromia", + }); + + await start(service, CompanyNationality.Ethiopian, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.region).toBeNull(); + }); + + it("leaves the registration alone while the flag stays on", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true }, + region: "Addis Ababa", + }); + + await start(service, CompanyNationality.Foreign, false, true); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates).not.toHaveProperty("region"); + expect(profilesRepo.update).not.toHaveBeenCalled(); + }); + + it("refuses to switch a company that never took a manual-registration route", async () => { + const { service } = makeService({ id: "company-1", attributes: {} }); + await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + /** + * The switch belongs to both manual-registration routes, not just this one. A + * co-operative that has since taken out a trade licence had no way back at + * all: the wizard is where the flag is chosen, and an onboarded company can no + * longer reach it. + */ + it("switches a co-operative back to eTrade on the same terms", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Ethiopian, + attributes: { cooperative: true, etradeManagerName: "Typed Name" }, + region: "Oromia", + licenceNumber: "TYPED-1", + }); + + await service.revertToRegularCompany("user-1"); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.attributes).toEqual({}); + expect(updates.status).toBe(CompanyStatus.Pending); + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + // A co-op owes no per-role business licence; once it stops being one it + // does, so the application has to be re-opened and re-reviewed. + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingCompleted: false, + onboardingStep: "company", + }); + }); + + /** + * Approval of a co-op's role was granted without a business licence, because + * a co-op owes none. Leaving makes one due, so the approval no longer stands + * for what it said. + */ + it("sends a co-operative's approved roles back for approval", async () => { + const { service, companyProfilesRepo } = makeService({ + id: "company-1", + attributes: { cooperative: true }, + }); + companyProfilesRepo.findByCompanyId.mockResolvedValue([ + { id: "role-active", status: ProfileStatus.Active }, + { id: "role-blocked", status: ProfileStatus.Blacklisted }, + { id: "role-pending", status: ProfileStatus.Pending }, + ]); + + await service.revertToRegularCompany("user-1"); + + expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith( + "role-active", + ProfileStatus.Pending, + ); + // A staff decision is not the customer's to undo by switching registration: + // promoting a blocked role to "awaiting approval" would launder the block. + expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1); + }); + + it("leaves an investor's roles alone — their licences were always due", async () => { + const { service, companyProfilesRepo } = makeService({ + id: "company-1", + attributes: { investorLicence: true }, + }); + companyProfilesRepo.findByCompanyId.mockResolvedValue([ + { id: "role-active", status: ProfileStatus.Active }, + ]); + + await service.revertToRegularCompany("user-1"); + + expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts new file mode 100644 index 000000000..5a73b6040 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts @@ -0,0 +1,187 @@ +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * Uploading a business licence only ever adds a row — nothing overwrites. So a + * customer answering a rejection or a document correction used to end up with + * the refused licence still listed beside the new one, in the portal and in the + * backoffice, with nothing saying which is current. An upload that answers a + * reviewer now retires what it answers; an upload with nothing outstanding is a + * genuine addition and still just adds. + */ +interface StoredFile { + id: string; + name: string; + code: string; + createdAt: Date; + reviewStatus?: string | null; + removed?: boolean; +} + +const T0 = new Date("2026-01-01T00:00:00Z"); +const REJECTED_AT = new Date("2026-02-01T00:00:00Z"); +const T2 = new Date("2026-03-01T00:00:00Z"); + +function makeService( + status: ProfileStatus, + files: StoredFile[], + reviewedAt: Date | null = null, +) { + const stored = [...files]; + const profile = { + id: "profile-1", + companyId: "company-1", + type: ProfileType.importer, + status, + reviewedAt, + }; + const company = { + id: "company-1", + status: + status === ProfileStatus.Active + ? CompanyStatus.Active + : CompanyStatus.Pending, + companyProfiles: [profile], + }; + + const live = () => stored.filter((f) => !f.removed); + const filesService = { + upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => { + const record = { + id: `file-${stored.length + 1}`, + name: input.file.originalname, + code: input.code, + createdAt: T2, + size: 1, + mimeType: "application/pdf", + }; + stored.push(record); + return record; + }), + findByResource: jest.fn(async () => live()), + findWithOpenChangeRequest: jest.fn(async () => + live().filter((f) => f.reviewStatus === "change_requested"), + ), + findById: jest.fn(async (id: string) => ({ + ...stored.find((f) => f.id === id), + resource: "company_profiles", + resourceId: "profile-1", + })), + remove: jest.fn(async (id: string) => { + const found = stored.find((f) => f.id === id); + if (found) found.removed = true; + }), + clearReview: jest.fn(async (id: string) => { + const found = stored.find((f) => f.id === id); + if (found) found.reviewStatus = null; + }), + }; + + const changeRequestRepo = { + findPendingByCompanyId: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ id: "cr-1", ...row })), + update: jest.fn(async () => ({ id: "cr-1" })), + findByCompanyId: jest.fn(async () => []), + }; + + const service = new CompaniesService( + { findById: jest.fn(async () => company) } as never, + { findByCompanyId: jest.fn(async () => [profile]) } as never, + changeRequestRepo as never, + {} as never, + { findByCompanyId: jest.fn(async () => []) } as never, + {} as never, + filesService as never, + {} as never, + {} as never, + { changeRequestSubmitted: jest.fn() } as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => ({ profile: { id: "external-1" }, company }) as never, + ); + + return { service, stored, live, filesService, changeRequestRepo }; +} + +const upload = (service: CompaniesService) => + service.addProfileLicenseFiles("user-1", "profile-1", [ + { originalname: "new-licence.pdf" } as never, + ]); + +describe("a business licence uploaded to answer a reviewer", () => { + it("retires the file the reviewer flagged for correction", async () => { + const { service, live } = makeService(ProfileStatus.Pending, [ + { id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" }, + ]); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]); + }); + + it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => { + // Two uploads answering one rejection (a second page, or a re-pick) must not + // cannibalise each other — only what the reviewer actually refused goes. + const { service, live } = makeService( + ProfileStatus.Rejected, + [ + { id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 }, + { id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 }, + ], + REJECTED_AT, + ); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual([ + "fix-page-1.pdf", + "new-licence.pdf", + ]); + }); + + it("leaves an ordinary addition alone when nothing was asked for", async () => { + const { service, live } = makeService(ProfileStatus.Pending, [ + { id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 }, + ]); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual([ + "existing.pdf", + "new-licence.pdf", + ]); + }); + + it("stages the swap for review on an approved role instead of deleting", async () => { + // A live role's licence is not the customer's to remove unilaterally: the + // old file stays until a reviewer approves the swap. + const { service, live, changeRequestRepo } = makeService( + ProfileStatus.Active, + [ + { id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" }, + ], + ); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]); + const intents = changeRequestRepo.create.mock.calls.flatMap( + ([row]) => (row as any).documents.licenseChanges, + ); + expect(intents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: "add", fileId: "file-2" }), + expect.objectContaining({ op: "remove", fileId: "file-old" }), + ]), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts new file mode 100644 index 000000000..79fbdd479 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts @@ -0,0 +1,115 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { Company, CompanyStatus } from "./entities/company.entity"; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * A rejection hands the role back to the customer: they fix what was flagged + * and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be + * able to skip that entirely and approve straight out of Rejected — granting + * the role over the documents that were just refused, while the customer's + * "please fix this" note was still on their screen. + */ +function makeService(status: ProfileStatus) { + const profile: Partial = { + id: "profile-1", + companyId: "company-1", + type: ProfileType.importer, + status, + reference: null, + reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null, + }; + const company = { + id: "company-1", + status: CompanyStatus.Pending, + attributes: {}, + }; + + const written: Partial[] = []; + const profileRepo = { + update: jest.fn(async (_id: string, patch: Partial) => { + written.push(patch); + Object.assign(profile, patch); + return null; + }), + findOne: jest.fn(async () => profile), + }; + const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() }; + + const companyProfilesRepo = { + findById: jest.fn(async () => profile), + generateReference: jest.fn(async () => "IM-A00001"), + }; + const profilesRepo = { + // Onboarding submitted — the other gate in this method is not what these + // tests are about. + findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]), + }; + const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + findOne: jest.fn(async () => company), + getRepository: (entity: unknown) => + entity === Company ? companyRepo : profileRepo, + }), + ), + }; + const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() }; + + const service = new CompaniesService( + {} as never, + companyProfilesRepo as never, + {} as never, + {} as never, + profilesRepo as never, + {} as never, + filesService as never, + {} as never, + {} as never, + companyNotifier as never, + dataSource as never, + {} as never, + ); + + return { service, profile, written, companyProfilesRepo }; +} + +describe("approving an operational role", () => { + it("refuses to approve a role the customer has not resubmitted", async () => { + const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected); + + await expect( + service.setCompanyProfileStatus("profile-1", ProfileStatus.Active), + ).rejects.toBeInstanceOf(BadRequestException); + // Refused before any reference could be minted against the rejected role. + expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled(); + }); + + it("lets a reviewer undo their own rejection, and drops the note with it", async () => { + const { service, written } = makeService(ProfileStatus.Rejected); + + await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending); + + expect(written[0]).toMatchObject({ + status: ProfileStatus.Pending, + reviewNote: null, + }); + }); + + it("still approves a role that is awaiting its first decision", async () => { + const { service, written } = makeService(ProfileStatus.Pending); + + await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active); + + expect(written[0]).toMatchObject({ + status: ProfileStatus.Active, + reference: "IM-A00001", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..d7151f200 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -62,7 +63,10 @@ import { CompanyStatus, CompanyType, COOPERATIVE_KEY, + INVESTOR_LICENCE_KEY, + hasInvestorLicence, isCooperative, + usesManualRegistration, } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { @@ -377,6 +381,7 @@ export class CompaniesService { roles: ProfileType[], nationality?: CompanyNationality, cooperative?: boolean, + investorLicence?: boolean, ): Promise<{ profile: ExternalProfile; company: Company }> { // Already started — reuse the existing draft, just ensure roles exist and // keep the nationality up to date if it was (re)selected. @@ -387,13 +392,20 @@ export class CompaniesService { // flag into `attributes`, or to read a stored one the caller didn't send. const needsCompany = cooperative !== undefined || + investorLicence !== undefined || roles.includes(ProfileType.freightForwarder); const current = needsCompany ? await this.companiesRepo.findById(companyId) : null; const isCoop = cooperative ?? isCooperative(current); + const isInvestor = investorLicence ?? hasInvestorLicence(current); this.assertRolesAllowedForCooperative(isCoop, roles); this.assertNationalityAllowedForCooperative(isCoop, nationality); + this.assertInvestorLicenceAllowed( + isInvestor, + isCoop, + nationality ?? current?.nationality ?? undefined, + ); await this.syncCompanyProfiles(companyId, companyType, roles); const updates: Partial = {}; if (nationality) updates.nationality = nationality; @@ -401,20 +413,49 @@ export class CompaniesService { // stored nationality too, or the company keeps resolving to the foreign // document set. if (isCoop) updates.nationality = CompanyNationality.Ethiopian; - if (cooperative !== undefined) { + if (cooperative !== undefined || investorLicence !== undefined) { updates.attributes = { ...(current?.attributes ?? {}), - [COOPERATIVE_KEY]: cooperative, + ...(cooperative !== undefined + ? { [COOPERATIVE_KEY]: cooperative } + : {}), + ...(investorLicence !== undefined + ? { [INVESTOR_LICENCE_KEY]: investorLicence } + : {}), }; } + // Going back and un-ticking the box is the same act as the settings + // switch, so it has to cost the same: the registration the customer typed + // goes, and onboarding drops back to the company step. Without this the + // draft keeps the typed values, `hasRegistrationDetails` reads as a passed + // lookup, resume lands past the company step entirely — and the company + // finishes onboarding on unverified data with no flag left to say so. + const backToEtrade = + usesManualRegistration(current) && !isCoop && !isInvestor; + if (backToEtrade) { + Object.assign(updates, CompaniesService.CLEARED_REGISTRATION); + updates.attributes = this.withoutTypedEtradeManager( + updates.attributes ?? current?.attributes, + ); + } if (Object.keys(updates).length > 0) { await this.companiesRepo.update(companyId, updates); } + if (backToEtrade) { + await this.profilesRepo.update(existing.id, { + onboardingStep: "company", + }); + } return this.getCompanyInfoByUserId(identity.userId); } this.assertRolesAllowedForCooperative(cooperative === true, roles); this.assertNationalityAllowedForCooperative(cooperative === true, nationality); + this.assertInvestorLicenceAllowed( + investorLicence === true, + cooperative === true, + nationality, + ); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); @@ -427,7 +468,14 @@ export class CompaniesService { country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, - ...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}), + ...(cooperative || investorLicence + ? { + attributes: { + ...(cooperative ? { [COOPERATIVE_KEY]: true } : {}), + ...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}), + }, + } + : {}), }); await this.profilesRepo.create({ @@ -484,6 +532,73 @@ export class CompaniesService { } } + /** + * The registration block as it must look when nobody has verified it. + * + * Used wherever a company stops being one eTrade cannot answer for: whatever + * sits in these columns was the customer's own statement, and the wizard + * treats a populated registration as a lookup that already passed + * (`hasRegistrationDetails`). Leaving it behind would hand the company an + * eTrade-verified record eTrade never supplied — and, once the flag is gone, + * a backoffice screen that says so. + */ + private static readonly CLEARED_REGISTRATION: Partial = { + licenceNumber: null, + statusDescription: null, + dateRegistered: null, + renewedFrom: null, + renewalDate: null, + renewedTo: null, + region: null, + zone: null, + woreda: null, + kebele: null, + houseNo: null, + etradePhone: null, + }; + + /** + * The company's own `attributes`, minus the manager captured alongside a + * typed registration. It never came from a licence, so it must not outlive + * the registration it belonged to. + */ + private withoutTypedEtradeManager( + attributes: Record | null | undefined, + ): Record { + const next = { ...(attributes ?? {}) }; + delete next.etradeManagerName; + delete next.etradeManagerPhone; + return next; + } + + /** + * An investment licence belongs to a foreign company and to nothing else. + * + * It is the Ethiopian Investment Commission's licence, issued to a foreign + * investor — an Ethiopian company registers with the trade registry, which is + * exactly the eTrade record this flag says does not exist. A co-operative + * cannot hold one either: it is Ethiopian by construction, and the two flags + * resolve to different document sets, so a company carrying both would owe an + * incoherent list of papers. + */ + private assertInvestorLicenceAllowed( + investorLicence: boolean, + cooperative: boolean, + nationality: CompanyNationality | undefined, + ): void { + if (!investorLicence) return; + if (cooperative) { + throw new BadRequestException( + "A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.", + ); + } + if (nationality !== CompanyNationality.Foreign) { + throw new BadRequestException( + "Only a foreign company can onboard on an investment licence.", + ); + } + } + /** * Reconcile the company's operational profiles with the roles the user has * selected: create the missing ones, drop the ones they deselected. @@ -1141,16 +1256,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1673,7 @@ export class CompaniesService { } await this.discardLicenseChanges(request); await this.discardDocumentChanges(request); + await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, @@ -1556,6 +1711,12 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.notifyChangeRequestReturned( + request, + "changes_requested", + note, + reviewerId, + ); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.ChangesRequested, @@ -1566,6 +1727,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); @@ -1640,6 +1830,24 @@ export class CompaniesService { ); } + // A rejected role is waiting on the customer, not on the reviewer: nothing + // has been resubmitted, and the note telling them what to fix is still on + // their screen. Approving straight out of Rejected grants the very role that + // was refused, over the documents that were refused with it. The way back is + // the customer's own resubmission (`reapplyCompanyProfile` → Pending); a + // rejection made in error is undone by moving the role back to pending + // review first — the same shape as "withdraw the change request first" on + // the document gate below. + if ( + status === ProfileStatus.Active && + existing.status === ProfileStatus.Rejected + ) { + throw new BadRequestException( + "This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " + + "If the rejection was a mistake, move the role back to pending review first.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1768,7 +1976,14 @@ export class CompaniesService { status === ProfileStatus.Suspended ) { patch.reviewNote = note ?? null; - } else if (status === ProfileStatus.Active) { + } else if ( + status === ProfileStatus.Active || + status === ProfileStatus.Pending + ) { + // Pending only reaches here when a reviewer withdraws their own rejection + // (the customer's resubmission clears the note in `reapplyCompanyProfile`), + // so the reason they gave goes with it — leaving it would keep telling the + // customer to fix something nobody is waiting on any more. patch.reviewNote = null; } if (status !== ProfileStatus.Pending) { @@ -2067,6 +2282,7 @@ export class CompaniesService { // or farm holds no business licence, so it owes its own list rather than the // nationality list plus extras. const cooperative = isCooperative(company); + const investorLicence = hasInvestorLicence(company); const documentSettingCode = this.documentSettingCodeFor(company); const [setting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService @@ -2216,6 +2432,7 @@ export class CompaniesService { documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, cooperative, + investorLicence, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -2293,6 +2510,97 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Drop whichever manual-registration route the company is on and send it back + * through the normal eTrade one. + * + * Both routes exist for the same reason — eTrade holds no record to fetch — + * so leaving one is the same act whichever it is, and it is the only way back + * to eTrade for either. A co-operative union or farm that has since taken out + * a trade licence had no exit at all before this; its only route was the + * wizard, which an onboarded company can no longer reach. + * + * Everything the flag let the customer type is cleared, not kept: the + * registration block on file was their own statement, and leaving it there + * would let the wizard treat the company as already looked-up + * (`hasRegistrationDetails` is what stands in for a verified TIN on a + * resume) and walk straight past the eTrade step this switch exists to + * reach. Onboarding reopens at the company step and the company goes back to + * pending — an approval granted against typed data cannot silently carry over + * to a record that now claims to be eTrade's. + * + * Switching the other way — INTO a co-operative or an investment licence — is + * deliberately not here. It is the wizard's nationality/role step, which this + * reopens, and which is the one place the mutually-exclusive rules live + * (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`, + * `assertNationalityAllowedForCooperative`). A second entry point would have + * to restate all three. + */ + async revertToRegularCompany( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.companiesRepo.findById(companyId); + if (!company) + throw new NotFoundException(`Company ${companyId} not found`); + if (!usesManualRegistration(company)) { + throw new BadRequestException( + "This company is already registered through eTrade — there is nothing to switch.", + ); + } + + const wasCooperative = isCooperative(company); + + // Both flags go, not just the one that was set: they are mutually exclusive + // and a company can only ever hold one, but the destination is "neither", + // so stripping only the one we happened to check for would leave the other + // behind if the pair ever did coexist. + const attributes = this.withoutTypedEtradeManager(company.attributes); + delete attributes[INVESTOR_LICENCE_KEY]; + delete attributes[COOPERATIVE_KEY]; + + await this.companiesRepo.update(companyId, { + ...CompaniesService.CLEARED_REGISTRATION, + attributes, + status: CompanyStatus.Pending, + }); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: false, + onboardingStep: "company", + }); + + // A co-operative owes no per-role business licence — that is the whole + // reason its own document set stands in for one. The moment it stops being + // one, every role owes a licence that was never uploaded, so an approval + // granted without one no longer means what it said: back to Pending, and + // the reviewer sees the licence with the rest of the re-application. + // + // Only Active roles move. Rejected, Suspended and Blacklisted are the + // backoffice's own decisions, and quietly promoting a blocked role to + // "awaiting approval" would launder the block away. The reference survives + // either way — it is minted once (`setCompanyProfileStatus`) and re-approval + // reuses it, so bookings that cite it keep citing the same number. + // + // An investor is untouched: it always held a licence per role, so nothing + // becomes due that was not already reviewed. + if (wasCooperative) { + const roles = await this.companyProfilesRepo.findByCompanyId(companyId); + for (const role of roles) { + if (role.status !== ProfileStatus.Active) continue; + await this.companyProfilesRepo.updateStatus( + role.id, + ProfileStatus.Pending, + ); + } + } + + return this.getCompanyInfoByUserId(userId); + } + /** * Block a self-service action when the company account isn't active, naming * the actual status — a suspended customer told "awaiting approval" has no @@ -2392,6 +2700,9 @@ export class CompaniesService { * with the role itself. Only for an already-approved role are they staged under * the pending code and recorded as `add` intents on a pending change request — * a licence swap on a live role is a change; a licence on a new role is not. + * + * An upload that answers a reviewer also retires the licence it answers (see + * below), so a correction never leaves both copies on file. */ async addProfileLicenseFiles( userId: string, @@ -2403,6 +2714,28 @@ export class CompaniesService { const gated = profile.status === ProfileStatus.Active; const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; + // An upload that answers the reviewer replaces what they refused; it does + // not sit next to it. Uploading only ever adds a row, so without this the + // refused licence stays listed in the portal and the backoffice beside the + // new one and nothing says which is current. Two things count as refused: + // the file the reviewer flagged for correction, and — when the whole role + // came back rejected — every licence that was already on file when they + // rejected it. Anything the customer uploaded *since* that decision is part + // of the same fix (a second page, a re-pick), so it survives, and an upload + // with nothing outstanding is a genuine addition and is left alone. + const rejectedAt = + profile.status === ProfileStatus.Rejected + ? (profile.reviewedAt ?? null) + : null; + const superseded = rejectedAt + ? ( + await this.filesService.findByResource(profileId, LICENSE_RESOURCE) + ).filter((f) => f.createdAt < rejectedAt) + : await this.filesService.findWithOpenChangeRequest( + [profileId], + LICENSE_RESOURCE, + ); + const uploaded = await Promise.all( files.map((file) => this.filesService.upload({ @@ -2427,6 +2760,13 @@ export class CompaniesService { ); } + // Retire what the upload supersedes, through the normal removal path so an + // approved role stages a `remove` intent (reviewed as a swap) while an + // unapproved one just drops the file. + for (const stale of superseded) { + await this.removeProfileLicenseFile(userId, profileId, stale.id); + } + // A fresh licence upload answers any correction the reviewer asked for on the // previous one, so the old row must stop blocking approval. await this.resolveDocumentChangeRequests( @@ -3459,7 +3799,7 @@ export class CompaniesService { // the registered address themselves, and what they send IS the data. The // check is skipped rather than failed: running the lookup would 400 every // save with "no registration found for this TIN". - if (isCooperative(company)) return; + if (usesManualRegistration(company)) return; const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 6bcd21f08..d3a556f50 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 20bd0ab06..49dad4b8d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto { */ cooperative: boolean; + /** + * The company is a foreign investor on an investment licence: no eTrade + * record, so the registration was typed. The nationality document set still + * applies (it already asks for the investment licence itself), and so does + * the per-role business licence. + */ + investorLicence: boolean; + /** Required company-information fields and whether each is filled. */ companyInfo: { complete: boolean; @@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto { this.documentSettingCode = init.documentSettingCode; this.nationality = init.nationality; this.cooperative = init.cooperative; + this.investorLicence = init.investorLicence; this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 0d7293ab0..14753f8ef 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -2,7 +2,11 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, } from "./complete-identity-verification.dto"; -import { Company, isCooperative } from "../entities/company.entity"; +import { + Company, + hasInvestorLicence, + isCooperative, +} from "../entities/company.entity"; import { ExternalProfile } from "../entities/external-profile.entity"; import { ChangeRequestStatus, @@ -21,6 +25,12 @@ export class ProfileResponseDto { * it from eTrade. */ cooperative: boolean; + /** + * The company is a foreign investor on an investment licence: eTrade holds + * no record, so the company step collects the registration by hand. Drives + * the settings card that switches back to the eTrade route. + */ + investorLicence: boolean; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -93,6 +103,7 @@ export class ProfileResponseDto { this.companyType = company.type; this.nationality = company.nationality ?? null; this.cooperative = isCooperative(company); + this.investorLicence = hasInvestorLicence(company); this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index e75182889..7c4348e4f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -3,6 +3,7 @@ import { CompanyType, CompanyStatus, CompanyNationality, + hasInvestorLicence, isCooperative, } from '../entities/company.entity'; import { @@ -62,6 +63,13 @@ export class ResponseCompanyDto { * eTrade manager to check the owner against. */ cooperative: boolean; + /** + * The company onboarded as a foreign investor on an investment licence: + * eTrade holds no record for its TIN, so its registration below was typed by + * the customer rather than fetched — nothing here has been checked against a + * licence, and the reviewer is the check. + */ + investorLicence: boolean; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -118,6 +126,7 @@ export class ResponseCompanyDto { this.status = company.status; this.nationality = company.nationality ?? null; this.cooperative = isCooperative(company); + this.investorLicence = hasInvestorLicence(company); this.tin = company.tin; this.vatNumber = company.vatNumber; this.fanNumber = company.fanNumber; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 91fcb44a1..0eac35a5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -31,4 +31,15 @@ export class StartOnboardingDto { @IsOptional() @IsBoolean() cooperative?: boolean; + + /** + * The company is a foreign investor: it operates on an investment licence + * issued by the Ethiopian Investment Commission, so eTrade holds no record + * for its TIN and the registration is typed here instead. Chosen on the same + * step for the same reason as the co-operative flag — it decides what the + * company step asks for. Only a foreign company can hold one. + */ + @IsOptional() + @IsBoolean() + investorLicence?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 54c36bf41..68bf73cdf 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -51,6 +51,37 @@ export function isCooperative( return company?.attributes?.[COOPERATIVE_KEY] === true; } +/** + * `attributes` key marking a foreign company onboarding on an investment + * licence. + * + * The Ethiopian Investment Commission registers it, not the trade registry, so + * eTrade holds no record for its TIN: the registration is typed and the eTrade + * authenticity check is skipped rather than failed — exactly as for a + * co-operative. What does NOT change is the licence: the company still holds + * one per operational role, so that requirement stands. + */ +export const INVESTOR_LICENCE_KEY = "investorLicence"; + +/** Is this a foreign company registered on an investment licence? */ +export function hasInvestorLicence( + company: Pick | null | undefined, +): boolean { + return company?.attributes?.[INVESTOR_LICENCE_KEY] === true; +} + +/** + * eTrade holds nothing for this company, so its registration was typed by hand + * rather than fetched — and the backoffice is told so. Two different companies + * reach it (a co-operative has no licence at all; a foreign investor's is not + * the trade registry's), and every consequence they share hangs off this. + */ +export function usesManualRegistration( + company: Pick | null | undefined, +): boolean { + return isCooperative(company) || hasInvestorLicence(company); +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index c222ffc16..bdb8d74cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } @@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, {} as never, {} as never, + {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 84465145d..fb68b3f5c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts index 6b5f54c91..6155eb583 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts new file mode 100644 index 000000000..49e77627f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -0,0 +1,216 @@ +import { ContractBookingService } from './contract-booking.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes + * the booking, so GL also picks who shares its wagon: two bookings each carrying + * an odd 20ft count are completed together onto one wagon. + * + * The two invariants that matter are that the pair is all-or-nothing (a failure + * on either half must leave NEITHER booking completed and no link written) and + * that the two bookings stay financially separate — one completion each, so one + * price and one invoice each. + */ +describe('ContractBookingService — manual odd-20ft consolidation', () => { + function makeService(overrides: { + bookingsRepository?: Partial>; + dataSource?: unknown; + }) { + const bookingsRepository = { + findByIdWithFiles: jest.fn(), + findManualConsolidationCandidates: jest.fn().mockResolvedValue([]), + linkConsolidationPartners: jest.fn().mockResolvedValue(undefined), + ...overrides.bookingsRepository, + }; + + // A transaction that simply runs the callback — enough to assert the + // all-or-nothing contract: whatever throws inside propagates out, and the + // caller observes no link written. + const dataSource = overrides.dataSource ?? { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb({})), + }; + + const service = new ContractBookingService( + { findByIdWithRelations: jest.fn() } as never, + bookingsRepository as never, + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // invoiceService + {} as never, // bookingNotifier + dataSource as never, + {} as never, // trainSchedulingService + {} as never, // bookingBatchService + {} as never, // bookingTransitionService + // The pairing is parked for approval rather than going straight to + // Operations; the gate itself is covered by its own spec. + { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, + ); + return { service, bookingsRepository, dataSource }; + } + + const partnerBooking = { + id: 'b-2', + reference: 'BK-2', + contractId: 'c-2', + consolidationPartnerId: null, + } as unknown as Booking; + + const pairDto = { + partnerBookingId: 'b-2', + booking: { scheduledDate: '2026-09-01' }, + partner: { scheduledDate: '2026-09-01' }, + }; + + it('completes both halves and links them', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + // partner lookup before the transaction + .mockResolvedValueOnce(partnerBooking) + // the two reloads after it + .mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking) + .mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking), + }, + }); + + // Each half runs the ordinary completion machine — one call per booking, so + // each is priced and invoiced on its own. + const complete = jest + .spyOn(service, 'completeUnderContract') + .mockImplementation( + async (_contractId, bookingId) => + ({ + booking: { id: bookingId } as Booking, + warnings: [], + }) as never, + ); + + const result = await service.completeConsolidatedPair( + 'c-1', + 'b-1', + pairDto as never, + ); + + expect(complete).toHaveBeenCalledTimes(2); + // The partner is completed against ITS OWN contract, not this one. + expect(complete.mock.calls[0][0]).toBe('c-1'); + expect(complete.mock.calls[1][0]).toBe('c-2'); + // Neither half may re-enter the automatic matcher — GL links them here. + expect(complete.mock.calls[0][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(complete.mock.calls[1][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith( + 'b-1', + 'b-2', + ); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('links nothing when the partner half fails (all-or-nothing)', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking), + }, + }); + + jest + .spyOn(service, 'completeUnderContract') + .mockImplementationOnce( + async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never, + ) + .mockImplementationOnce(async () => { + throw new Error('no train space for the partner'); + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow('no train space for the partner'); + + // The link is the last write in the transaction — it must never happen when + // a half failed, so the rollback leaves no dangling pairing. + expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled(); + }); + + it('refuses a partner that already shares a wagon', async () => { + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue({ + ...partnerBooking, + consolidationPartnerId: 'b-9', + }), + }, + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow(/already shares a wagon/i); + }); + + it('refuses to consolidate a booking with itself', async () => { + const { service } = makeService({}); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', { + ...pairDto, + partnerBookingId: 'b-1', + } as never), + ).rejects.toThrow(/cannot be consolidated with itself/i); + }); + + it('offers only bookings whose own 20ft count is odd', async () => { + // Two odd counts always sum to even, so an odd partner is exactly what fills + // the wagon; an even one would leave the pair partial again. + const rows = [ + { + id: 'odd', + reference: 'BK-ODD', + bookingContainers: [ + { quantity: 3, containerType: { sizeFt: 20 } }, + ], + }, + { + id: 'even', + reference: 'BK-EVEN', + bookingContainers: [ + { quantity: 4, containerType: { sizeFt: 20 } }, + ], + }, + // A bare instance has no cargo yet — GL enters it on the split form, so it + // stays a candidate. + { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, + ]; + + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + .mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking), + findManualConsolidationCandidates: jest.fn(async (booking: Booking) => + // Mirror the repository's in-memory odd filter. + rows.filter((row) => { + void booking; + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((l) => Number(l.containerType?.sizeFt) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + return ft20 % 2 === 1; + }), + ), + }, + }); + + const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates[0].ft20Quantity).toBe(3); + expect(candidates[1].hasCargo).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts index d5d15d73d..0a892b108 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo' trainSchedulingService as never, {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, bookingsRepository, invoiceService }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3d362d53f..9e31aff34 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ConsolidationService } from '../bookings/consolidation.service'; +import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; @@ -44,6 +45,7 @@ import { import { ClearanceMilestoneService } from './clearance-milestone.service'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { + CompleteConsolidatedPairDto, CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; @@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * A booking GL may pick as the shared-wagon partner of an odd-20ft customs + * booking. `hasCargo` is false for a bare instance whose containers GL still has + * to enter on the split completion form. + */ +export interface ConsolidationCandidate { + id: string; + reference: string; + contractId: string | null; + companyName: string | null; + status: string; + tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; + scheduledDate: string | null; + ft20Quantity: number; + hasCargo: boolean; +} + /** * Outstanding split remainder of a contract: what was booked in the first split * booking's pre-split snapshot MINUS everything currently booked. Container @@ -110,6 +131,8 @@ export class ContractBookingService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingTransitionService)) private readonly bookingTransitionService: BookingTransitionService, + @Inject(forwardRef(() => ConsolidationApprovalService)) + private readonly consolidationApprovalService: ConsolidationApprovalService, ) {} async createUnderContract( @@ -598,6 +621,146 @@ export class ContractBookingService { return created; } + /** + * Candidate partners a GL operator may link to an odd-20ft customs booking. + * Manual counterpart to the automatic pairing in {@link consolidateDrawdown} — + * a customs instance is completed by GL, so GL also chooses who shares its + * wagon rather than waiting for the auto-matcher to find an exact complement. + */ + async listConsolidationCandidates( + contractId: string, + bookingId: string, + ): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.contractId !== contractId) { + throw new NotFoundException(`Booking ${bookingId} not found on this contract`); + } + + const rows = await this.bookingsRepository.findManualConsolidationCandidates( + booking, + ); + return rows.map((row) => { + const lines = row.bookingContainers ?? []; + return { + id: row.id, + reference: row.reference, + contractId: row.contractId ?? null, + companyName: row.company?.name ?? null, + status: row.status, + tradeDirection: row.tradeDirection ?? null, + originYardId: row.originYardId ?? null, + destinationYardId: row.destinationYardId ?? null, + scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null, + ft20Quantity: lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + hasCargo: lines.length > 0, + }; + }); + } + + /** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked for its shared wagon. Both halves run the ordinary + * {@link completeUnderContract} machine — same gates, same pricing, same + * per-booking invoice, so each customer still pays only its own shipment — and + * are linked as consolidation partners at the end. + * + * All-or-nothing: the two completions plus the pairing run inside one + * transaction, so a failure on either half leaves neither booking completed + * and no half-linked wagon behind. `runInTransaction` is used rather than a + * manual QueryRunner so the nested services join the same transactional + * context through the shared DataSource. + */ + async completeConsolidatedPair( + contractId: string, + bookingId: string, + dto: CompleteConsolidatedPairDto, + actorPermissions?: unknown, + /** IAM id of the GL user creating the pairing — recorded on the approval. */ + actorUserId?: string | null, + ): Promise<{ + booking: Booking; + partner: Booking; + warnings: string[]; + }> { + if (dto.partnerBookingId === bookingId) { + throw new BadRequestException( + 'A booking cannot be consolidated with itself.', + ); + } + + const partner = await this.bookingsRepository.findByIdWithFiles( + dto.partnerBookingId, + ); + if (!partner) { + throw new NotFoundException( + `Partner booking ${dto.partnerBookingId} not found`, + ); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} is not a contract booking and cannot be completed here.`, + ); + } + + const warnings: string[] = []; + + const { ownId, partnerId } = await this.dataSource.transaction(async () => { + const own = await this.completeUnderContract( + contractId, + bookingId, + { ...dto.booking, skipAutoConsolidation: true }, + // Both halves are completed by the same GL actor that reached this + // endpoint — the customs gate in completeUnderContract re-checks it. + actorPermissions, + ); + warnings.push(...own.warnings); + + const other = await this.completeUnderContract( + partner.contractId as string, + partner.id, + { ...dto.partner, skipAutoConsolidation: true }, + actorPermissions, + ); + warnings.push(...other.warnings); + + // Link the two halves. Written directly (not via pairConsolidation) because + // both bookings have just been completed into their live status here — + // pairConsolidation exists to RESUME bookings parked in + // PENDING_CONSOLIDATION and would overwrite that status. + await this.bookingsRepository.linkConsolidationPartners( + own.booking.id, + other.booking.id, + ); + return { ownId: own.booking.id, partnerId: other.booking.id }; + }); + + // Both halves have just been completed into the operations queue by the + // ordinary completion machine. A shared wagon does not go there unreviewed: + // pull the pair back into the approval gate, which releases them to + // Operations only once a person signs off on the pairing. + await this.consolidationApprovalService.requestApproval( + ownId, + partnerId, + actorUserId ?? null, + ); + + // Sequential reads: one connection per transaction context. + const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId); + const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId); + return { + booking: finalBooking!, + partner: finalPartner ?? partner, + warnings, + }; + } + /** * Complete a bare initiated booking after its per-booking clearance is * finalized (CLEARANCE_READY) or operations returned it for changes @@ -826,10 +989,18 @@ export class ContractBookingService { // exactly like a drawdown created with cargo does. The shipment day is // stored first so the pairing event can resume straight into the // operations queue. + // Customs (Path B) instances are exempt from the AUTO-matcher: GL links + // their shared wagon by hand through completeConsolidatedPair, so nothing + // may claim a partner for them behind GL's back. A customs half completed + // as part of a manual pair carries `skipAutoConsolidation`; one completed + // alone still falls through to the automatic gate below, so an odd 20ft + // booking can never proceed on a partial wagon. Non-customs drawdowns are + // unaffected. const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); if ( withContainers && freightType === 'CONTAINER' && + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking(withContainers)) ) { await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 76106a9ff..826c7fdff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -358,32 +358,39 @@ export class ContractClearanceService { // Once GL creates the shipment booking, surface its reference + status so the // customer sees the concrete booking instead of a stale "will be created // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingId: string | null = null; let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; let linkedBookingReviewNote: string | null = null; let linkedBookingScheduledDate: string | null = null; - if (cycle?.bookingId) { - const booking = await this.bookingsService.findById(cycle.bookingId); - if (booking) { - linkedBookingReference = booking.reference ?? null; - linkedBookingStatus = booking.status ?? null; - linkedBookingScheduledDate = booking.scheduledDate - ? new Date(booking.scheduledDate).toISOString() - : null; - // Newest changes-requested note (reviewNotes ride along on findById). - linkedBookingReviewNote = - [...(booking.reviewNotes ?? [])] - .filter((n) => n.type === 'CHANGES_REQUESTED') - .sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - )[0]?.note ?? null; - if (contract.tradeDirection === 'EXPORT') { - nextAction = this.workflowService.computeNextActionForBooking( - booking, - bookingMilestones, - ); - } + // The cycle is the historical link, but it is not written on every path (an + // FCFS export booking and a GL drawdown both reach the operations queue + // without a cycle row), so fall back to the contract's own live booking — + // otherwise the clearance page sees no linked booking at all and cannot show + // its status or the actions that depend on it. + const booking = cycle?.bookingId + ? await this.bookingsService.findById(cycle.bookingId) + : await this.contractsRepository.findLatestBookingForContract(contractId); + if (booking) { + linkedBookingId = booking.id ?? null; + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + linkedBookingScheduledDate = booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString() + : null; + // Newest changes-requested note (reviewNotes ride along on findById). + linkedBookingReviewNote = + [...(booking.reviewNotes ?? [])] + .filter((n) => n.type === 'CHANGES_REQUESTED') + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); } } @@ -420,7 +427,7 @@ export class ContractClearanceService { bookingReady: boundary, preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), - linkedBookingId: cycle?.bookingId ?? null, + linkedBookingId, linkedBookingReference, linkedBookingStatus, linkedBookingReviewNote, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -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; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index c9db49b24..9f602ec21 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -76,7 +76,10 @@ import { import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CompleteConsolidatedPairDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; import { CreateBookingRequestDto, ReviewBookingRequestDto, @@ -1152,10 +1155,48 @@ export class ContractsController { // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( + id, + bookingId, + // skipAutoConsolidation is internal to the manual pair-completion path; a + // client must never suppress the wagon gate on a lone booking. + { ...dto, skipAutoConsolidation: false }, + user, + ); + } + + @Get(':id/bookings/:bookingId/consolidation-candidates') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).', + }) + listConsolidationCandidates( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + ) { + return this.contractBookingService.listConsolidationCandidates(id, bookingId); + } + + @Post(':id/bookings/:bookingId/complete-consolidated') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + '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.', + }) + completeConsolidatedPair( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: CompleteConsolidatedPairDto, + @CurrentUser() user: TCurrentUser & { sub?: string }, + ) { + return this.contractBookingService.completeConsolidatedPair( id, bookingId, dto, user, + // Recorded as the requester on the approval: the person who created the + // pairing may not be the one who approves it. + user?.id ?? user?.sub ?? null, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index d89d43250..309079010 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository { .getCount(); } + /** + * The live shipment booking on a contract, newest first. + * + * The clearance view historically reached the booking through + * `currentCycle().bookingId`, but a cycle row is not created on every path — + * an FCFS export booking and a GL drawdown both reach + * OPERATION_REQUEST_PENDING without one — so that lookup returns null and the + * clearance page loses the booking's status entirely. This resolves it from + * the bookings themselves, which is the authoritative link (bookings carry + * contract_id), and is used as the fallback when the cycle has no booking. + */ + async findLatestBookingForContract( + contractId: string, + ): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .orderBy('b.created_at', 'DESC') + .getOne(); + } + async createReviewNote( contractId: string, body: string, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 8c1bf763d..f50ca9d4d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, @@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto { @IsOptional() @IsString() notes?: string; + + /** + * Internal: set by the manual GL pair-completion path, never by a client. + * Suppresses the automatic wagon-consolidation gate for this completion + * because the caller links the shared wagon itself. Excluded from the public + * schema so a client cannot set it to bypass the gate on a lone booking. + */ + @ApiHideProperty() + @IsOptional() + @IsBoolean() + skipAutoConsolidation?: boolean; +} + +/** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked to share its wagon. Each half carries its own full completion payload — + * the two bookings stay separately priced and separately invoiced, they only + * share the wagon. + */ +export class CompleteConsolidatedPairDto { + @ApiProperty({ + format: 'uuid', + description: 'The booking chosen to share this booking’s wagon.', + }) + @IsUUID() + partnerBookingId!: string; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the booking in the URL.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + booking!: CreateBookingUnderContractDto; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the partner booking.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + partner!: CreateBookingUnderContractDto; } diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts new file mode 100644 index 000000000..c4782782c --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +export class BulkCancelEimsItemDto { + @ApiProperty({ description: "Invoice ID to cancel." }) + @IsUUID() + invoiceId!: string; + + @ApiProperty({ + description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).', + example: "1", + }) + @IsString() + @Length(1, 8) + reasonCode!: string; + + @ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" }) + @IsOptional() + @IsString() + @Length(0, 500) + remark?: string; +} + +/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */ +export class BulkCancelEimsRegistrationDto { + @ApiProperty({ type: [BulkCancelEimsItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BulkCancelEimsItemDto) + items!: BulkCancelEimsItemDto[]; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts index 6bd9b04b3..b9fdf62df 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts @@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors"; import { EimsInvoiceStatus } from "./eims-registration.types"; const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; +const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06"; const invoiceRow = (over: Partial = {}): Invoice => ({ @@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); }); + +describe("EimsCancellationService.cancelBulkWithEims", () => { + it("cancels every eligible invoice in one call, matching results back by IRN", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + { id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" }, + ]); + + expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [ + { Irn: IRN, ReasonCode: "1", Remark: "" }, + { Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + ]); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + ]); + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + // Bulk success carries no cancellationDate at all, unlike single cancel. + expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull(); + }); + + it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }), + invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }), + ]); + const postBearer = jest.fn(); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(postBearer).not.toHaveBeenCalled(); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") }, + ]); + }); + + it("a mix of MoR success and rejection only updates the succeeding invoice", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + { Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." }, + ]); + }); + + it("makes no HTTP call at all when every item fails the local eligibility check", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + const postBearer = jest.fn(); + + await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]); + + expect(postBearer).not.toHaveBeenCalled(); + }); + + it("notifies the buyer only for invoices that actually got cancelled", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + db.companyContact = { phone: "+251911000000", email: null }; + const directSend = jest.fn().mockResolvedValue(undefined); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { status: "C", Irn: IRN }, + { Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN }, + ], + }); + + await build(db, postBearer, directSend).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(directSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts index 9aa525ae3..7f8605c5f 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts @@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsClientService } from "./eims-client.service"; -import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types"; +import { + EimsBulkCancelItemResult, + EimsBulkCancelRequest, + EimsBulkCancelResponse, + EimsCancelRequest, + EimsCancelResponse, + EimsInvoiceStatus, + EimsInvoiceStatusView, +} from "./eims-registration.types"; import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; /** @@ -92,6 +100,102 @@ export class EimsCancellationService { return this.getEimsCancellationStatus(invoiceId); } + /** + * `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the + * collection's own shape (an array in, an array of mixed success/error results back). + * + * Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything + * goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP + * call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible + * invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back + * immediately. + * + * ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch — + * same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across + * every item for the whole call isn't needed for correctness, only for avoiding a wasted call on + * an item that's already ineligible). + */ + async cancelBulkWithEims( + items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>, + ): Promise { + const results = new Map(); + const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = []; + + for (const item of items) { + try { + const invoice = await this.dataSource.transaction(async (manager) => { + const inv = await this.lockInvoice(manager, item.invoiceId); + if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException( + `Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`, + ); + } + if (!inv.eimsIrn) { + throw new BadRequestException( + `Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`, + ); + } + return inv; + }); + eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark }); + } catch (err) { + results.set(item.invoiceId, { + invoiceId: item.invoiceId, + success: false, + message: (err as Error).message, + }); + } + } + + if (eligible.length > 0) { + const request: EimsBulkCancelRequest = eligible.map((e) => ({ + Irn: e.invoice.eimsIrn!, + ReasonCode: e.reasonCode, + Remark: e.remark ?? "", + })); + // Outside any transaction — no DB lock held across the wire, same as single cancel. + const response = await this.client.postBearer( + "/v1/bulkCancel", + request, + ); + const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry])); + + for (const { invoice, reasonCode, remark } of eligible) { + const entry = byIrn.get(invoice.eimsIrn!); + const failed = !entry || "Status" in entry; + if (failed) { + const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice."; + this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`); + results.set(invoice.id, { invoiceId: invoice.id, success: false, message }); + continue; + } + + await this.dataSource.transaction(async (manager) => { + const fresh = await this.lockInvoice(manager, invoice.id); + // Re-checked under lock: a concurrent call may have already recorded this cancellation. + if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return; + await manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Cancelled, + eimsCancelledAt: new Date(), + // The bulk success shape carries no cancellationDate at all, unlike single cancel. + eimsCancellationDate: null, + eimsCancellationReasonCode: reasonCode, + eimsCancellationRemark: remark ?? null, + }); + }); + this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`); + await this.notifyBuyer(invoice); + results.set(invoice.id, { + invoiceId: invoice.id, + success: true, + message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`, + }); + } + } + + return items.map((item) => results.get(item.invoiceId)!); + } + async getEimsCancellationStatus(invoiceId: string): Promise { const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts index b68a4a32f..a5f837c1e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { EimsConfigException } from "./eims.errors"; +const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/; + +/** + * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the + * first line. Never the actual key material — PEM headers aren't secret, the base64 body is. + */ +const describeBytes = (bytes: Buffer): string => { + const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?"); + return `${bytes.length} bytes, starts with "${preview}"`; +}; + /** * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * @@ -24,25 +35,61 @@ export class EimsCredentialsProvider { return this.config.get("eims")!; } - /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + /** + * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM + * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a + * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if + * none is usable. + */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - const path = this.cfg.privateKeyPath; - if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyPem + ? "EIMS_PRIVATE_KEY" + : privateKeyBase64 + ? "EIMS_PRIVATE_KEY_BASE64" + : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyPem && !privateKeyBase64 && !path) { + throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + } + + let bytes: Buffer; + try { + bytes = privateKeyPem + ? Buffer.from(privateKeyPem, "utf8") + : privateKeyBase64 + ? Buffer.from(privateKeyBase64, "base64") + : readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, + ); + } + + // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own + // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a + // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes. + if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) { + throw new EimsConfigException( + `EIMS private key from ${source} does not look like a PEM key after decoding ` + + `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` + + `or truncation, and not base64 applied twice.`, + ); + } let key: KeyObject; try { - key = createPrivateKey(readFileSync(path)); + key = createPrivateKey(bytes); } catch (err) { - // The path is operational information, not a secret; the key material never appears. + // The source is operational information, not a secret; the key material never appears. throw new EimsConfigException( - `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, ); } if (key.asymmetricKeyType !== "rsa") { throw new EimsConfigException( - `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, ); } @@ -51,11 +98,26 @@ export class EimsCredentialsProvider { return key; } - /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + /** + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued. + * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the + * pasted text respectively); otherwise read from `certificatePath`. + */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const path = this.cfg.certificatePath; + const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg; + if (pem) { + this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64"); + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`); + return this.certificateBase64; + } + if (inline) { + this.certificateBase64 = inline; + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); + return this.certificateBase64; + } + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); let bytes: Buffer; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 79d3204dd..f560ff917 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -10,7 +10,7 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; @@ -479,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest + .fn() + .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsConfigException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + eimsLastError: expect.objectContaining({ kind: "CONFIG" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + + it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => { + // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls + // settleFailure — a throw here left the reservation permanently orphaned (a real live incident: + // 500 on register, then every subsequent attempt 409'd "already in flight" until manually + // resolved). This never reaches postSigned at all — the mapper throws before submit() is called. + const db = new FakeDb([ + invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }), + ]); + const postSigned = jest.fn(); + + // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the + // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own + // known exception types. + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /no MoR country code mapping/, + ); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ kind: "LOCAL" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + it("treats a success response with no IRN as a failed registration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 2ca5f5ffd..a823e5ddf 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -26,7 +26,7 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; @@ -123,27 +123,29 @@ export class EimsInvoiceRegistrationService { const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); - // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. - const request = toEimsInvoice( - invoice, - this.sellerCache.getSellerDetails(cfg), - buildEimsContext(cfg, { - // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber - // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. - documentNumber: reservation.documentNumber, - invoiceCounter: reservation.invoiceCounter, - previousIrn: reservation.previousIrn, - session, - documentType, - reason: invoice.eimsReason, - relatedDocument, - }), - ); - let irn: string; let ackDate: string | undefined; let signedQR: string | undefined; try { + // The request can only be built now: InvoiceCounter and PreviousIrn come from the + // reservation. Building it — and everything after — stays inside this try: a reservation is + // held from here on, and *any* failure past this point, mapper or wire, must release it + // through settleFailure rather than leave it orphaned as a permanent system-wide block. + const request = toEimsInvoice( + invoice, + this.sellerCache.getSellerDetails(cfg), + buildEimsContext(cfg, { + // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber + // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. + documentNumber: reservation.documentNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + session, + documentType, + reason: invoice.eimsReason, + relatedDocument, + }), + ); // Deliberately outside every transaction — no DB lock is held across the wire. const result = await this.submit(request); irn = result.irn; @@ -469,6 +471,15 @@ export class EimsInvoiceRegistrationService { * when two rejected self-test attempts deadlocked the sequence until a manual DB reset. * * An ambiguous result keeps both: MoR may have counted and stored the document. + * + * Any error that is *not* an `EimsApiException` is also deterministic, on a different basis: + * every error that actually touches the wire is normalized to `EimsApiException` before it gets + * here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call + * threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` — + * pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by + * touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer + * country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before + * any HTTP call went out, and releasing the reservation is always safe, never a guess. */ private async settleFailure( invoiceId: string, @@ -476,10 +487,12 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + // Never touched the wire (see the doc comment above) — always safe to release, whatever it is. + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; const lastError: EimsInvoiceError = { - kind: api?.kind ?? "UNKNOWN", + kind: api?.kind ?? localKind, message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -586,10 +599,14 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? "EIMS rejected an invoice" + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? "EIMS filing failed before reaching MoR" + : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.` + : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 7d417551f..11eb214dc 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -5,6 +5,7 @@ import type { Response } from "express"; import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { sendPdf } from "../billing/billing.controller"; +import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -89,6 +90,17 @@ export class EimsInvoiceController { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark); } + @Post("eims/bulk-cancel") + @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) + @ApiOperation({ + summary: + "Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " + + "reported independently — one failure never blocks the rest.", + }) + bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) { + return this.cancellation.cancelBulkWithEims(dto.items); + } + @Post(":id/eims/receipt/sales") @BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister) @ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" }) diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index 8094d0e74..60941825c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -89,6 +89,46 @@ export interface EimsCancelResponse { body?: EimsCancelResponseBody; } +/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */ +export type EimsBulkCancelRequest = EimsCancelRequest[]; + +/** + * One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same + * array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status` + * (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no + * `cancellationDate` at all. + */ +export interface EimsBulkCancelSuccessItem { + id?: number; + tin?: string; + status: string; + mode?: string; + Irn: string; + ReasonCode?: string; + Remark?: string; +} + +export interface EimsBulkCancelErrorItem { + Status: string; + msg: string; + Irn: string; +} + +export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem; + +export interface EimsBulkCancelResponse { + statusCode?: number; + message?: string; + body?: EimsBulkCancelResponseItem[]; +} + +/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */ +export interface EimsBulkCancelItemResult { + invoiceId: string; + success: boolean; + message: string; +} + /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ export interface EimsInvoiceError { kind: string; diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts index 5e408ddf7..f8210cab6 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -97,8 +97,14 @@ describe("EimsSignerService", () => { }); describe("EimsCredentialsProvider", () => { - const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => - new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + const providerFor = (cfg: { + privateKeyPath?: string; + certificatePath?: string; + privateKeyBase64?: string; + certificateBase64?: string; + privateKeyPem?: string; + certificatePem?: string; + }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); @@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => { writeFileSync(emptyPath, ""); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); }); + + it("loads the key from inline base64, no file involved", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers inline base64 over the path when both are set", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + // A path that would fail if it were ever actually read. + const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from inline base64 as-is, no re-encoding", () => { + const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); + expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); + }); + + it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => { + // Simulates the real failure this guards against: a truncated/mangled env var still decodes + // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why. + const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64"); + expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow( + /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s, + ); + }); + + it("loads the key from the raw PEM env var directly, no encoding step", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ privateKeyPem: pem }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers the raw PEM var over base64 and path when all three are set", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ + privateKeyPem: pem, + privateKeyBase64: Buffer.from("garbage").toString("base64"), + privateKeyPath: join(dir, "nope.key"), + }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from the raw PEM env var, re-encoded to base64", () => { + const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64(); + expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64")); + }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 650a834b8..a55951db4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -59,6 +59,10 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ systemType: EIMS_SYSTEM_TYPE, privateKeyPath: "/dev/null", certificatePath: "/dev/null", + privateKeyBase64: "", + certificateBase64: "", + privateKeyPem: "", + certificatePem: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 3be21fdd3..853b32c29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -10,7 +10,9 @@ export type EimsFailureKind = | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" - | "UNKNOWN"; + | "UNKNOWN" + | "CONFIG" + | "LOCAL"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index e9f3af958..aa363ad3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { BackofficeModule } from "../backoffice/backoffice.module"; +import { ChatModule } from "../chat/chat.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { Notification } from "./entities/notification.entity"; @@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service"; BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) NotificationsModule, + // ChatBridgeService (mirrors BACKOFFICE notifications into chat) + ChatModule, ], controllers: [NotificationInboxController], providers: [ diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts index 7cfbf81df..119019997 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -1,4 +1,5 @@ import { + NotificationAudience, NotificationChannels, NotificationChannelsSent, NotificationDto, @@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; +import { ChatBridgeService } from "../chat/chat-bridge.service"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; @@ -37,6 +39,7 @@ export class NotificationInboxService { private readonly gateway: NotificationsGateway, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, + private readonly chatBridge: ChatBridgeService, @InjectRepository(User) private readonly users: Repository, ) {} @@ -44,11 +47,18 @@ export class NotificationInboxService { /** * Fan a logical notification out to every resolved recipient: persist one row * each, push it live over WebSocket, and (for HIGH priority) also queue - * email/SMS via the existing clients. + * email/SMS via the existing clients. BACKOFFICE-audience notifications are + * also mirrored into internal chat (ChatBridgeService) — a shared-room + * broadcast, not per-recipient, so it runs once regardless of how many (if + * any) in-app rows get created below. Never PORTAL — that's customer-facing + * and must never reach a staff room. */ async notify(input: NotifyInput): Promise { try { const userIds = await this.recipients.resolve(input.recipients); + if (input.audience === NotificationAudience.BACKOFFICE) { + await this.chatBridge.bridge(input); + } if (userIds.length === 0) { this.logger.debug( `notify(${input.type}) resolved 0 recipients — skipped`, diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts new file mode 100644 index 000000000..971de03cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional } from "class-validator"; + +/** + * Partial update: the UI flips one currency at a time, so an omitted field + * leaves that currency's channel exactly as it was. + */ +export class UpdateManualPaymentSettingDto { + @ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" }) + @IsOptional() + @IsBoolean() + etbEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" }) + @IsOptional() + @IsBoolean() + usdEnabled?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts new file mode 100644 index 000000000..a18f97279 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand + * (bank transfer / counter payment) instead of the customer paying online. + * + * Per currency on purpose: the two channels are operationally different — USD + * bookings have always been bank-transfer-only, while ETB normally goes + * through the gateway and manual settlement is the exception. Switching one + * off must not switch off the other. + */ +@Entity({ schema: "freight", name: "manual_payment_settings" }) +export class ManualPaymentSetting extends BaseEntity { + /** Manual settlement allowed for ETB invoices. */ + @Column({ name: "etb_enabled", type: "boolean", default: false }) + etbEnabled!: boolean; + + /** Manual settlement allowed for USD invoices. */ + @Column({ name: "usd_enabled", type: "boolean", default: true }) + usdEnabled!: boolean; + + /** IAM user id of the last operator to change either toggle. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts new file mode 100644 index 000000000..786d1f7ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +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 { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +@ApiTags("payment-settings") +@ApiBearerAuth() +@Controller("payment-settings/manual") +export class ManualPaymentSettingsController { + constructor(private readonly service: ManualPaymentSettingsService) {} + + /** + * Read is gated on `manual_payment:view`, which Finance also holds — the + * Manual Payments worklist reads this to know which currency tabs to offer. + */ + @Get() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.view, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Whether manual (offline) invoice settlement is enabled, per currency", + }) + get() { + return this.service.get(); + } + + @Patch() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.manage, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Enable or disable manual invoice settlement for ETB and/or USD", + }) + update( + @Body() dto: UpdateManualPaymentSettingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.update(dto, user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts new file mode 100644 index 000000000..efb43fa91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; + +/** The two currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD"; + +/** + * Owns the single `manual_payment_settings` row: whether Finance may settle + * invoices by hand, per currency. + * + * Defaults mirror how the platform behaved before the toggles existed — USD + * has always been bank-transfer-only so it starts ON; ETB manual settlement is + * the new capability and starts OFF, so enabling it is a deliberate act. + */ +@Injectable() +export class ManualPaymentSettingsService { + private readonly logger = new Logger(ManualPaymentSettingsService.name); + + constructor( + @InjectRepository(ManualPaymentSetting) + private readonly repository: Repository, + ) {} + + /** The settings row, created at the defaults on first access. */ + async get(): Promise { + const existing = await this.repository.findOne({ where: {} }); + if (existing) return existing; + + return this.repository.save( + this.repository.create({ etbEnabled: false, usdEnabled: true }), + ); + } + + /** Currencies manual settlement is currently allowed for. */ + async enabledCurrencies(): Promise { + const setting = await this.get(); + const enabled: ManualPaymentCurrency[] = []; + if (setting.etbEnabled) enabled.push("ETB"); + if (setting.usdEnabled) enabled.push("USD"); + return enabled; + } + + /** Whether one currency may be settled by hand right now. */ + async isEnabled(currency: string | null | undefined): Promise { + const upper = currency?.toUpperCase(); + if (upper !== "ETB" && upper !== "USD") return false; + const setting = await this.get(); + return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + } + + /** Flip either toggle; an omitted field leaves that currency unchanged. */ + async update( + patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), + ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + updatedById: updatedById ?? null, + }); + const updated = await this.get(); + this.logger.warn( + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + ); + return updated; + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts new file mode 100644 index 000000000..bcadfe340 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; +import { ManualPaymentSettingsController } from "./manual-payment-settings.controller"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +/** + * Global so billing can inject {@link ManualPaymentSettingsService} to gate + * the manual-settlement worklist and confirmation endpoint without importing + * this module (and without a cycle, since this module needs nothing back). + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ManualPaymentSetting])], + controllers: [ManualPaymentSettingsController], + providers: [ManualPaymentSettingsService], + exports: [ManualPaymentSettingsService], +}) +export class PaymentSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts new file mode 100644 index 000000000..44ae27607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import { StaffReference } from '../../../common/booking-guards'; +import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; +import { + ListYardPositionsQueryDto, + SetPositionYardsDto, + SetYardPositionsDto, +} from '../dto/yard-positions.dto'; +import { YardPositionsService } from '../services/yard-positions.service'; +import { YardScopeService } from '../services/yard-scope.service'; + +/** + * Desk↔yard mapping — which positions ("departments" in the user-management + * tree) staff which yard. It is yard configuration, so it is gated by the same + * rule-engine yard keys as the rest of the yards screen. + * + * Writes REPLACE the whole set for the side being edited. The admin UI submits + * the full multi-select value; a caller sending a delta will drop everything it + * omits. Both write paths flush the scope resolver's cache so a mapping change + * takes effect on the next request instead of up to a minute later. + */ +@ApiTags('yard-positions') +@Controller('yard-positions') +@ApiBearerAuth() +export class YardPositionsController { + constructor( + private readonly service: YardPositionsService, + private readonly scope: YardScopeService, + ) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' }) + list(@Query() query: ListYardPositionsQueryDto) { + return this.service.list(query); + } + + @Get('positions') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Positions selectable as yard desks' }) + listPositions() { + return this.service.listSelectablePositions(); + } + + @Get('my-yards') + // Any signed-in staff member, NOT gated on the yards keys: this returns the + // caller's own access and nothing else, and the frontend needs it to + // preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every + // desk that does not administer yards — i.e. exactly the users it is for. + @StaffReference() + @ApiOperation({ + summary: "The caller's own yard scope (null yardIds = unrestricted)", + }) + async myYards(@CurrentUser() user: unknown) { + const yardIds = await this.scope.getScopedYardIds(user as never); + return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced }; + } + + @Put('yard/:yardId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a yard's whole position set" }) + async setPositionsForYard( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: SetYardPositionsDto, + ) { + const rows = await this.service.setPositionsForYard(yardId, dto.positionIds); + this.scope.invalidate(); + return rows; + } + + @Put('position/:positionId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a position's whole yard set" }) + async setYardsForPosition( + @Param('positionId', ParseUUIDPipe) positionId: string, + @Body() dto: SetPositionYardsDto, + ) { + const rows = await this.service.setYardsForPosition(positionId, dto.yardIds); + this.scope.invalidate(); + return rows; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts new file mode 100644 index 000000000..b18648d78 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ListYardPositionsQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + positionId?: string; +} + +/** Replaces the yard's whole position set — see the controller's PUT docs. */ +export class SetYardPositionsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + positionIds!: string[]; +} + +/** Replaces the position's whole yard set. */ +export class SetPositionYardsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + yardIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts new file mode 100644 index 000000000..de12b5674 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * One desk staffed at one yard. + * + * The pairing that yard access scoping resolves against: a caller's active + * position decides which yards they may touch. Position rows live in `iam` + * (`iam.positions` — what the user-management tree labels "departments"), so + * `positionId` is an unconstrained uuid by design; see the migration for why. + */ +@Entity({ schema: 'freight', name: 'yard_positions' }) +@Index(['yardId']) +@Index(['positionId']) +export class YardPosition extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */ + @Column({ name: 'position_id', type: 'uuid' }) + positionId!: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 549a35fa2..97e89cc3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; +import { YardPositionsController } from './controllers/yard-positions.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; import { CargoType } from './entities/cargo-type.entity'; @@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { YardLocation } from './entities/yard-location.entity'; +import { YardPosition } from './entities/yard-position.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; +import { YardPositionsService } from './services/yard-positions.service'; +import { YardScopeService } from './services/yard-scope.service'; import { RuleEngineService } from './rule-engine.service'; @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardDistance, YardFacility, YardLocation, + YardPosition, ShippingLine, Rate, ApprovalRule, @@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardPositionsController, YardDistancesController, ShippingLinesController, RatesController, @@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + // Exported so any module can narrow its yard queries through the one + // resolver — the module is @Global, so no import is needed to inject it. + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts new file mode 100644 index 000000000..93f9752f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts @@ -0,0 +1,194 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; + +import { YardPosition } from '../entities/yard-position.entity'; +import { Yard } from '../entities/yard.entity'; + +/** A mapped desk, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + /** Localised name from `iam.positions.name` — null if the position is gone. */ + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +/** + * The desk↔yard mapping behind yard access scoping. + * + * Reads always join `iam.positions` and drop soft-deleted rows: the mapping has + * no FK to IAM (see the migration), so a position deleted in the admin UI leaves + * an orphan row here. Dropping it on read means the orphan can never widen + * someone's scope — it just disappears. + */ +@Injectable() +export class YardPositionsService { + constructor(private readonly dataSource: DataSource) {} + + /** Mapping rows, optionally narrowed to one yard or one position. */ + async list(filter: { + yardId?: string; + positionId?: string; + }): Promise { + const params: unknown[] = []; + const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL']; + + if (filter.yardId) { + params.push(filter.yardId); + where.push(`yp.yard_id = $${params.length}`); + } + if (filter.positionId) { + params.push(filter.positionId); + where.push(`yp.position_id = $${params.length}`); + } + + return this.dataSource.query( + `SELECT yp.id AS "id", + yp.yard_id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + yp.position_id AS "positionId", + p.name AS "positionName", + pt.key AS "positionTypeKey" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id + -- INNER join: a mapping whose position was deleted grants nothing and + -- is not shown. The row stays for audit until someone re-saves the set. + JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE ${where.join(' AND ')} + ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`, + params, + ); + } + + /** + * Replace the yard's entire position set. + * + * Replace, not append — the admin UI submits the full multi-select value, so a + * partial payload would silently keep desks the user just unticked. Callers + * sending a delta will remove everything they omit. + */ + async setPositionsForYard( + yardId: string, + positionIds: string[], + ): Promise { + await this.assertYardExists(yardId); + await this.assertPositionsExist(positionIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ yardId }); + if (positionIds.length) { + await repo.insert( + [...new Set(positionIds)].map((positionId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ yardId }); + } + + /** Replace the position's entire yard set. Same replace semantics. */ + async setYardsForPosition( + positionId: string, + yardIds: string[], + ): Promise { + await this.assertPositionsExist([positionId]); + await this.assertYardsExist(yardIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ positionId }); + if (yardIds.length) { + await repo.insert( + [...new Set(yardIds)].map((yardId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ positionId }); + } + + /** + * Positions offered by the mapping picker. + * + * Reads `iam.positions` directly rather than going through IAM's + * `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit + * first, and the picker wants every desk that could staff a yard regardless of + * which unit it hangs under. + */ + async listSelectablePositions(): Promise< + Array<{ + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; + }> + > { + return this.dataSource.query( + `SELECT p.id AS "id", + p.name AS "name", + pt.key AS "positionTypeKey", + u.key AS "unitKey" + FROM iam.positions p + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + LEFT JOIN iam.units u ON u.id = p.unit_id + WHERE p.deleted_at IS NULL + ORDER BY p.name->>'en' ASC`, + ); + } + + /** Yard ids mapped to any of these positions — the scope resolver's read. */ + async yardIdsForPositions(positionIds: string[]): Promise { + if (!positionIds.length) return []; + const rows: { yardId: string }[] = await this.dataSource.query( + `SELECT DISTINCT yp.yard_id AS "yardId" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL + WHERE yp.deleted_at IS NULL + AND yp.position_id = ANY($1)`, + [positionIds], + ); + return rows.map((r) => r.yardId); + } + + private async assertYardExists(yardId: string): Promise { + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: yardId, deletedAt: IsNull() } }); + if (!yard) throw new NotFoundException(`Yard ${yardId} not found`); + } + + private async assertYardsExist(yardIds: string[]): Promise { + if (!yardIds.length) return; + const found = await this.dataSource + .getRepository(Yard) + .count({ where: { id: In(yardIds), deletedAt: IsNull() } }); + if (found !== new Set(yardIds).size) { + throw new BadRequestException('One or more yards do not exist'); + } + } + + /** + * Validated in the service because the database cannot: there is no FK to + * `iam.positions`, so an unchecked payload would happily store a typo'd uuid + * that silently grants nothing and reads as a configuration bug later. + */ + private async assertPositionsExist(positionIds: string[]): Promise { + if (!positionIds.length) return; + const unique = [...new Set(positionIds)]; + const rows: { count: string }[] = await this.dataSource.query( + `SELECT COUNT(*)::text AS count + FROM iam.positions + WHERE id = ANY($1) AND deleted_at IS NULL`, + [unique], + ); + if (Number(rows[0]?.count ?? 0) !== unique.length) { + throw new BadRequestException('One or more positions do not exist'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts new file mode 100644 index 000000000..c9af80ad3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts @@ -0,0 +1,139 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { YardScopeService } from './yard-scope.service'; + +/** + * The resolver answers "which yards", never "may they act at all" — that stays + * with the permission guard. So a mapped desk is narrowed to its yards, and an + * unmapped one keeps the reach its permissions already gave it. + */ +describe('YardScopeService', () => { + const yardIdsForPositions = jest.fn(); + const service = () => + new YardScopeService({ yardIdsForPositions } as never); + + const staff = (positionId: string, permissions: string[] = []) => ({ + roles: [{ key: 'staff' }], + permissions: permissions.map((key) => ({ key })), + employee: { position: { id: positionId, permissions: [] } }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.YARD_SCOPE_ENFORCE; + }); + + it('resolves a mapped position to its yards', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + const scope = await service().getScopedYardIds(staff('pos-officer')); + + expect(scope).toEqual(['yard-kality', 'yard-mojo']); + expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']); + }); + + it('leaves an unmapped position unrestricted — permissions still gate the action', async () => { + yardIdsForPositions.mockResolvedValue([]); + + expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull(); + }); + + it('leaves a caller with no resolvable position unrestricted', async () => { + const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } }; + + expect(await service().getScopedYardIds(noPosition)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('narrows nothing for an anonymous caller but grants nothing either', async () => { + expect(await service().getScopedYardIds(null)).toEqual([]); + }); + + it('returns unrestricted only for super admins and view_all holders', async () => { + const superAdmin = { roles: [{ key: 'super_admin' }] }; + const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']); + + expect(await service().getScopedYardIds(superAdmin)).toBeNull(); + expect(await service().getScopedYardIds(hqDesk)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('includes delegated positions — standing in must not lose the yard', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + await service().getScopedYardIds({ + roles: [{ key: 'staff' }], + employee: { + position: { id: 'pos-own' }, + delegatedPositions: [{ id: 'pos-gelan-director' }], + }, + }); + + expect(yardIdsForPositions).toHaveBeenCalledWith([ + 'pos-own', + 'pos-gelan-director', + ]); + }); + + describe('listFilterYardIds', () => { + it('narrows nothing while shadow-logging', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toBeNull(); + }); + + it('narrows to the mapped yards once enforcing', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toEqual(['yard-kality', 'yard-mojo']); + }); + + it('keeps an in-scope yard filter as the caller asked', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'), + ).toEqual(['yard-mojo']); + }); + + it('returns an empty set — not everything — for an out-of-scope yard filter', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'), + ).toEqual([]); + }); + + it('never narrows an unmapped desk', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue([]); + + expect( + await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'), + ).toBeNull(); + }); + }); + + it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + const shadow = service(); + + await expect( + shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).resolves.toBeUndefined(); + + process.env.YARD_SCOPE_ENFORCE = 'true'; + const enforcing = service(); + + await expect( + enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts new file mode 100644 index 000000000..0a7fbe2e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts @@ -0,0 +1,186 @@ +import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; + +import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; +import { YardPositionsService } from "./yard-positions.service"; + +/** + * Caller shape the resolver reads — the `/auth/me` user in either of its two + * shapes. Structurally compatible with what `freight-permission.util` accepts, + * so the same object serves both the permission checks and the position walk. + */ +type PositionLike = { + id?: string; + permissions?: { key?: string }[]; + positionType?: { key?: string } | null; +}; + +type ScopeUser = { + roles?: { key?: string }[]; + permissions?: { key?: string }[]; + employee?: + | { + position?: PositionLike; + delegatedPositions?: PositionLike[]; + } + | { positions?: PositionLike[] }[] + | null; +}; + +/** + * Which yards a caller may touch. + * + * Scope follows the caller's ACTIVE position, not a union of every position they + * have ever held: the frontends already send `x-current-position-id` and the + * token snapshots that one position, so switching desks switches yards — which + * is what staff covering two yards actually do. Delegated positions are added on + * top, otherwise standing in for the Gelan director silently loses Gelan. + * + * `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a + * desk that has been given yards; it does not hand out access. Whether the + * caller may perform the action at all is the permission guard's job — this + * resolver only answers "which yards", so a desk with the permission and no + * mapping keeps the reach it had before the mapping existed. + * + * The trade-off is deliberate and worth knowing: an accidentally-cleared + * mapping widens access rather than blocking work, so the mapping is not a + * containment barrier on its own — the permission keys still are. Super admins + * and holders of `yards:view_all` are unrestricted regardless of mapping. + * + * ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then + * {@link assertYardInScope} logs what it would have blocked and returns. Flip it + * only once the mapping table is populated and the log is quiet — on an empty + * table, enforcing locks out every staff member at once. + */ +@Injectable() +export class YardScopeService { + private readonly logger = new Logger(YardScopeService.name); + + // ponytail: 60s cache keyed by the position-id set, no invalidation hook. A + // mapping change takes up to a minute to reach the resolver. Call + // `invalidate()` from the mutation if that lag ever matters. + private static readonly CACHE_TTL_MS = 60_000; + private readonly cache = new Map(); + + constructor(private readonly yardPositions: YardPositionsService) {} + + /** True when the deny path is live; false while shadow-logging. */ + get enforced(): boolean { + return process.env.YARD_SCOPE_ENFORCE === "false"; + } + + /** Yard ids the caller is scoped to, or `null` for unrestricted. */ + async getScopedYardIds(user: ScopeUser | null | undefined): Promise { + // No user at all is an unauthenticated call the guards should already have + // rejected — narrow to nothing rather than trusting it. + if (!user) return []; + if (isSuperAdmin(user)) return null; + if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null; + + const positionIds = this.effectivePositionIds(user); + // No resolvable position — nothing to narrow by, so nothing is narrowed. + if (!positionIds.length) return null; + + const key = positionIds.join(","); + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) { + return hit.yardIds.length ? hit.yardIds : null; + } + + const yardIds = await this.yardPositions.yardIdsForPositions(positionIds); + this.cache.set(key, { yardIds, at: Date.now() }); + // Unmapped desk → unrestricted. Mapping narrows; absence of one does not. + return yardIds.length ? yardIds : null; + } + + async isYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + ): Promise { + if (!yardId) return true; + const scope = await this.getScopedYardIds(user); + return scope === null || scope.includes(yardId); + } + + /** + * Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only + * logs — wire it into write paths first and read filters second, so the + * shadow log shows what enforcement would break before it breaks it. + */ + async assertYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + context: string, + ): Promise { + if (await this.isYardInScope(user, yardId)) return; + + const positions = this.effectivePositionIds(user).join(",") || "none"; + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`, + ); + return; + } + throw new ForbiddenException("This yard is outside your assigned yards"); + } + + /** + * Yard ids a list query should be narrowed to, or `null` for no narrowing. + * + * Returns an EMPTY array only when the caller explicitly asked for a yard + * outside their scope and enforcement is on — the caller should answer with an + * empty result rather than silently widening back to everything. + * + * While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what + * it would have narrowed, so the mapping can be populated against real traffic + * before it starts hiding rows. + */ + async listFilterYardIds( + user: ScopeUser | null | undefined, + requestedYardId: string | null | undefined, + context: string, + ): Promise { + const scope = await this.getScopedYardIds(user); + if (scope === null) return null; + + const outOfScope = !!requestedYardId && !scope.includes(requestedYardId); + + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` + + (outOfScope ? ` and reject yard=${requestedYardId}` : ""), + ); + return null; + } + + if (outOfScope) return []; + return requestedYardId ? [requestedYardId] : scope; + } + + /** Drops the memoised scopes — call after editing the mapping. */ + invalidate(): void { + this.cache.clear(); + } + + /** Active position plus any delegated ones, across both `employee` shapes. */ + private effectivePositionIds(user: ScopeUser | null | undefined): string[] { + const ids = new Set(); + const employee = user?.employee; + if (!employee) return []; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const position of emp.positions ?? []) { + if (position?.id) ids.add(position.id); + } + } + return [...ids]; + } + + if (employee.position?.id) ids.add(employee.position.id); + for (const delegated of employee.delegatedPositions ?? []) { + if (delegated?.id) ids.add(delegated.id); + } + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 4418eed3d..cb1958582 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit { const ledger = new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); + // On a multi-yard consist the pool that matters is the one standing at + // the booking's own boarding yard — a type carried only in Mojo must not + // be advertised to a customer boarding at Dire. + const carriedAtBoardYard = (wagonTypeId: string): number => { + const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null; + if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0; + return stock.remainingByTypeId.get(wagonTypeId) ?? 0; + }; const byWagonType = allowed .filter( ({ wagonTypeId }) => - stock.mode !== 'TRAIN' || - !wagonTypeId || - (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0, ) .map(({ wagonTypeId, dims }) => { const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; @@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit { return new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 03535135d..9a20c0486 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -1589,6 +1589,7 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + await this.assertRouteCoversWagonYards(builtTrain, route); const conflict = await this.findTrainRouteDayConflict( builtTrain.id, route.id, @@ -4274,12 +4275,26 @@ export class TrainSchedulingService { .getRepository(Locomotive) .update({ id: In(locoIds) }, { currentYardId: station.yardId }); } + // Only wagons the train has actually COLLECTED move with it. On a + // consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo + // moves the Dire wagons — the ones already aboard — and picks up the + // Mojo ones standing here. Wagons waiting at yards further down the + // line stay where they are until the train physically gets to them. + const passedYardIds = stations + .filter((s) => s.sequenceNo <= dto.sequenceNo) + .map((s) => s.yardId); await manager .getRepository(Wagon) - .update( - { currentTrainScheduleId: scheduleId }, - { currentYardId: station.yardId }, - ); + .createQueryBuilder() + .update(Wagon) + .set({ currentYardId: station.yardId }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + // A yard-less wagon has no "waiting further down the line" position + // to protect, so it rides along as it always did. + .andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .execute(); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5286,12 +5301,26 @@ export class TrainSchedulingService { const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); + // A built consist spread across several yards can only offer, at each yard, + // the wagons standing there. A single-yard consist keeps the original + // behaviour: the whole train counts wherever it currently sits. + const consistYards = builtTrainId + ? new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ) + : new Set(); + const consistIsSplit = consistYards.size > 1; + for (const wagon of wagons) { // Train-bound schedule: the built train's own consist IS the fleet — only - // its wagons count (wherever they currently sit; they travel with the - // train), and loose yard wagons never do. + // its wagons count, and loose yard wagons never do. A single-yard consist + // counts wherever it sits (it travels with the train); a split consist is + // counted at the yard each wagon actually stands in. if (builtTrainId) { if (wagon.trainId !== builtTrainId) continue; + if (consistIsSplit && wagon.currentYardId !== originYardId) continue; } else { // Schedule-scoped availability: pins held by OTHER schedules never // consume a wagon here — the same physical wagon may serve the July 17 @@ -5651,12 +5680,23 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + const consistYards = new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ); + // Split consist: a slot boarding at a given yard must take a wagon that + // physically stands there — the train cannot load a Mojo wagon at Dire. + // A single-yard consist ignores this (the whole train is at one place). + const requiredYardId = + consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null; const candidates = wagons .filter( (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - spanFree(w.id), + spanFree(w.id) && + (!requiredYardId || w.currentYardId === requiredYardId), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -5825,14 +5865,28 @@ export class TrainSchedulingService { }); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); + const byYardId = new Map>(); for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + if (wagon.currentYardId) { + const perType = byYardId.get(wagon.currentYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(wagon.currentYardId, perType); + } } - return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; + // Single-yard consist (the overwhelming majority): the whole train is + // offered at every boarding yard exactly as before — the per-yard split is + // only meaningful once the consist is genuinely spread across yards. + return { + mode: 'TRAIN', + remainingByTypeId, + codesByTypeId, + ...(byYardId.size > 1 ? { byYardId } : {}), + }; } /** @@ -6160,6 +6214,45 @@ export class TrainSchedulingService { return saved; } + /** + * A built train's wagons may stand in several yards. The route must pass + * through every one of them as origin or an intermediate stop — never only + * as the final destination (the train has to pick the wagons up en route). + */ + private async assertRouteCoversWagonYards(train: Train, route: Route) { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, currentYardId: true }, + }); + const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))]; + if (!wagonYards.length) return; + + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } }); + const stops = milestones.length >= 2 + ? milestones.map((m) => m.yardId) + : [route.originYardId, route.destinationYardId]; + // Every stop except the last one is a pickup point. + const pickupYards = new Set(stops.slice(0, -1)); + + const uncovered = wagonYards.filter((y) => !pickupYards.has(y)); + if (!uncovered.length) return; + + const labels = await this.yardLabelMap(uncovered); + const destination = stops[stops.length - 1]; + const detail = uncovered + .map((y) => + y === destination + ? `${labels.get(y) ?? y} (only as the destination)` + : `${labels.get(y) ?? y} (not on route)`, + ) + .join(', '); + throw new BadRequestException( + `Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`, + ); + } + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index f7db22506..29a67da45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -43,6 +43,16 @@ export type WagonStock = { remainingByTypeId: Map; /** Wagon-type code per id, for human-readable shortfall messages. */ codesByTypeId: Map; + /** + * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons + * standing at that yard. A train whose wagons are split across yards can + * only offer, at each boarding yard, the wagons physically standing there — + * a wagon waiting in Mojo is not bookable from Dire, and one picked up at + * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits + * in one yard, which keeps single-yard trains on the original whole-train + * math. + */ + byYardId?: Map>; }; export type FlexPlanResult = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 47823cddd..5815ce435 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -68,3 +68,94 @@ describe('WagonStockLedger', () => { expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); }); }); + +describe('WagonStockLedger — multi-yard consist', () => { + // The reported case: a built train of 53 wagons, 20 standing in Dire and 33 + // in Mojo. Each yard may only sell the wagons physically standing there. + const DIRE = 'yard-dire'; + const MOJO = 'yard-mojo'; + const ADDIS = 'yard-addis'; + const STOPS = [DIRE, MOJO, ADDIS]; + const EDGES = STOPS.length - 1; + const splitStock = () => + new Map([ + [DIRE, new Map([['nw5', 20]])], + [MOJO, new Map([['nw5', 33]])], + ]); + // Legs along Dire → Mojo → Addis. + const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 }; + const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 }; + + const splitLedger = () => + new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS); + + it('offers each yard only the wagons standing there', () => { + const ledger = splitLedger(); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + }); + + it('keeps the yards independent — Dire bookings never eat Mojo stock', () => { + const ledger = splitLedger(); + // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge… + expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0); + // …but those are Dire's steel, so Mojo still has its own 33 to sell. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0); + }); + + it('never lends a free Dire wagon to a Mojo customer', () => { + const ledger = splitLedger(); + // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty. + expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5); + // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not + // offered here, exactly as the operator requires. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33); + }); + + it('offers nothing at the destination — there is nothing to pick up there', () => { + const ledger = splitLedger(); + // A leg boarding at the last stop has no pool of its own. + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0); + }); + + it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => { + const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [ + 'yard-add', + 'yard-dire', + 'yard-indode', + 'yard-mojo', + 'yard-djibouti', + ]; + const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI]; + const ledger = new WagonStockLedger( + new Map([['nw5', 53]]), + stops.length - 1, + new Map([ + [DIRE_2, new Map([['nw5', 20]])], + [MOJO_2, new Map([['nw5', 33]])], + ]), + stops, + ); + const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 }); + // Addis: the train starts empty — nothing to sell. + expect(ledger.availableFor(['nw5'], to(0))).toBe(0); + // Dire: the 20 wagons waiting there. + expect(ledger.availableFor(['nw5'], to(1))).toBe(20); + // Indode: the same 20 wagons, which have moved with the train. + expect(ledger.availableFor(['nw5'], to(2))).toBe(0); + // Mojo: its own 33 only. + expect(ledger.availableFor(['nw5'], to(3))).toBe(33); + }); + + it('single-yard consist keeps the original whole-train behaviour', () => { + // No byYardId (the train is not split) — every leg sees the whole train, + // exactly as before this feature. + const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index 0e4f6949d..bf5b935ad 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util'; * Gelan→Adama never competes for stock with an export on Adama→Doraleh. */ export class WagonStockLedger { + /** + * Usage rows keyed by pool. A single-yard train has one pool (''), so this is + * exactly the original per-type accounting. A multi-yard consist keys by + * boarding yard as well, because the Dire wagons and the Mojo wagons are + * disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the + * Mojo→Addis edge, but they must not shrink what Mojo itself can offer. + */ private readonly usedPerEdge = new Map(); constructor( private readonly remainingByTypeId: Map, private readonly edgeCount: number, + /** + * Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons + * standing at each yard. When present, a leg is served ONLY by the wagons + * standing at the yard it boards from — a Dire→Addis booking on a train + * whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis + * booking sees 33, never the Dire wagons that ride past empty. + */ + private readonly byYardId?: Map>, + /** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */ + private readonly stops: readonly string[] = [], ) {} + /** The yard a leg boards from, or '' when the train is not split across yards. */ + private poolYardOf(leg: CorridorLeg): string { + if (!this.byYardId) return ''; + return this.stops[leg.fromEdge] ?? ''; + } + + /** Usage-row key: one row per (pool, wagon type). */ + private rowKey(wagonTypeId: string, leg: CorridorLeg): string { + const pool = this.poolYardOf(leg); + return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId; + } + + /** Wagons of one type offered at the yard a leg boards from. */ + private totalForType(wagonTypeId: string, leg: CorridorLeg): number { + const pool = this.poolYardOf(leg); + if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0; + return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0; + } + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ private availableForType(wagonTypeId: string, leg: CorridorLeg): number { - const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; - const row = this.usedPerEdge.get(wagonTypeId); + const total = this.totalForType(wagonTypeId, leg); + const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg)); if (!row) return total; let busiest = 0; for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { @@ -69,10 +105,11 @@ export class WagonStockLedger { if (!deepest) break; const take = Math.min(outstanding, deepest.free); - let row = this.usedPerEdge.get(deepest.id); + const key = this.rowKey(deepest.id, leg); + let row = this.usedPerEdge.get(key); if (!row) { row = new Array(this.edgeCount).fill(0); - this.usedPerEdge.set(deepest.id, row); + this.usedPerEdge.set(key, row); } for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { row[edge] = (row[edge] ?? 0) + take; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 7b0bdd3bd..08a424cdc 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -44,7 +44,7 @@ export class BuildTrainDto { @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + description: 'Wagons to attach at build time, in consist order (any yard)', }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 0f377071c..4b94b2dcb 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -283,6 +283,10 @@ export class TrainBuilderService { wagonNumber: wagon.wagonNumber, sequenceNumber: wagon.sequenceNumber, status: wagon.status, + currentYardId: wagon.currentYardId ?? null, + currentYard: wagon.currentYard + ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label } + : null, wagonType: wagon.wagonType ? { id: wagon.wagonType.id, @@ -326,6 +330,27 @@ export class TrainBuilderService { : null, locomotives, wagons, + // Where the consist physically stands. A train built from several yards + // only picks a yard's wagons up when it reaches that yard, and a customer + // boarding there can only book the wagons standing there — the schedule + // route must therefore cover every one of these yards before its + // destination. + wagonYards: [ + ...wagons + .reduce((acc, wagon) => { + const id = wagon.currentYardId ?? 'UNASSIGNED'; + const entry = acc.get(id) ?? { + yardId: wagon.currentYardId ?? null, + code: wagon.currentYard?.code ?? null, + label: wagon.currentYard?.label ?? null, + wagonCount: 0, + }; + entry.wagonCount += 1; + acc.set(id, entry); + return acc; + }, new Map()) + .values(), + ].sort((a, b) => b.wagonCount - a.wagonCount), totals: { wagonCount: wagons.length, totalTareTons, @@ -428,10 +453,13 @@ export class TrainBuilderService { } /** - * Relocate the train to another yard. The consist moves as one unit: every - * coupled locomotive and wagon follows to the new yard (so their current - * yards always match the train's), and each wagon gets a movement-ledger row. - * Blocked while the train is out on a dispatched run. + * Relocate the train to another yard. The locomotives always follow. Of the + * wagons, only those standing WITH the train move: on a consist spread + * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo + * relocates the 20 it is actually pulling and leaves the Mojo wagons where + * they stand — the train collects those by arriving, not by this call. + * Each moved wagon gets a movement-ledger row. Blocked while the train is + * out on a dispatched run. */ async setYard(id: string, currentYardId: string) { await this.dataSource.transaction(async (manager) => { @@ -439,6 +467,7 @@ export class TrainBuilderService { if (train.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + const previousYardId = train.currentYardId ?? null; await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); @@ -454,7 +483,17 @@ export class TrainBuilderService { ); } - const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const allWagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + // Wagons travelling with the train = those at the yard it is leaving. + // A yard-less wagon has no standing position of its own, so it follows. + const wagons = allWagons.filter( + (wagon) => + wagon.currentYardId == null || + previousYardId == null || + wagon.currentYardId === previousYardId, + ); const now = new Date(); for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; @@ -475,7 +514,7 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Append AVAILABLE wagons from the train's own yard to the consist. */ + /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -1037,11 +1076,8 @@ export class TrainBuilderService { if (wagon.status !== WagonStatus.Available) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } + // Wagons may sit in any yard — the schedule's route must pass through + // every wagon yard before its destination (checked at scheduling time). toAttach.push(wagon); } if (!toAttach.length) return []; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index b5a14f6f3..f2c76e283 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -342,8 +342,8 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - // Mirror train-builder attachWagons: only a truly free, available wagon in - // the train's own yard can be coupled, and never onto a dispatched train. + // Mirror train-builder attachWagons: only a truly free, available wagon + // (any yard) can be coupled, and never onto a dispatched train. if (wagon.trainId != null) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); } @@ -358,11 +358,6 @@ export class WagonsService { `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, ); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } const maxSeq = await this.wagonRepo .createQueryBuilder('w') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 459b5009e..19d3b4b7a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -38,15 +38,21 @@ export class WarehouseInventoryController { @Get() @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) - findAll(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findAll(filter); + findAll( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findAll(filter, user); } @Get('ready-for-loading') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) - findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findReadyForLoading(filter); + findReadyForLoading( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findReadyForLoading(filter, user); } @Get('inquiry') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7412f0269..126998bae 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -39,6 +39,7 @@ import { import { SignaturesService } from '../signatures/signatures.service'; import { StampSettingsService } from '../stamp-settings/stamp-settings.service'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -423,6 +424,7 @@ export class WarehouseInventoryService { private readonly events: EventEmitter2, private readonly stampSettings: StampSettingsService, private readonly logoSettings: LogoSettingsService, + private readonly yardScope: YardScopeService, ) {} /** @@ -978,7 +980,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - async findAll(filter: FilterWarehouseInventoryDto): Promise { + /** + * `user` drives yard access scoping: a desk mapped to yards sees only those + * yards' inventory. Optional so internal callers that are not serving a + * request (schedulers, other services) are unaffected — they pass nothing and + * get the unscoped list, which is what they had before. + */ + async findAll( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { const createdAt = filter.dateFrom && filter.dateTo ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) @@ -1020,6 +1031,32 @@ export class WarehouseInventoryService { }); } + // Yard scoping — applied to `base` before the search branch splits it, so + // both OR arms carry the constraint. A null result means "do not narrow". + // + // Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two + // different id spaces that share a name. `warehouse_inventory.yard_id` is a + // FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard + // mapping is against `freight.yards`, the network yard, which inventory + // reaches through `warehouses.station_id`. Filtering `yardId` against + // mapped network yards matches nothing and hides every row (observed: all + // 34 rows disappeared before this was corrected). + // + // `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as + // the requested yard here; `filter.facilityId` is the station-yard filter. + const scopedYardIds = await this.yardScope.listFilterYardIds( + user as never, + filter.facilityId, + 'warehouse-inventory list', + ); + if (scopedYardIds) { + if (!scopedYardIds.length) return []; + base.warehouse = { + ...((base.warehouse as FindOptionsWhere) ?? {}), + stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds), + }; + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -1037,8 +1074,11 @@ export class WarehouseInventoryService { return items; } - findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { - return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + findReadyForLoading( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user); } /** diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 1798a8d5f..e4ab41954 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -242,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:hierarchy_positions:view", "edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:position_types:view", + "edr_freight_app:chat:view", ], }, { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 24be04f66..1e7a56094 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:bookings:wagon_cancellation_rebook", "Rebook cancelled wagons for a customer", ), + // Shared-wagon gate: two customers' cargo on one wagon is a commercial call, + // so it is signed off separately from the ordinary booking approvals — and + // never by the GL user who created the pairing. + perm( + "a1000001-0001-4000-8000-000000000029", + "edr_freight_app:bookings:approve_consolidation", + "Approve shared-wagon consolidation", + ), ]; /** @@ -459,6 +467,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +/** + * Yard access scoping. `yard_positions` maps desks to yards and the resolver + * (`YardScopeService`) narrows a caller to the yards their active position is + * mapped to. This key is the deliberate way out of that narrowing, for the HQ + * desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it, + * "unmapped" would have to mean "sees everything", which is a bypass by + * accident rather than by grant. + * + * Editing the mapping itself needs no key of its own: it is yard configuration, + * so it rides on `rule_engine:yards:view` / `:update` like every other field on + * a yard. + */ +export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f4a00001-0001-4000-8000-000000000001", + "edr_freight_app:yards:view_all", + "Access every yard (bypass yard scoping)", + ), +]; + /** * Advanced backoffice resources — full CRUD + workflow-action keys. * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing @@ -523,6 +551,12 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. +export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), + perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1465,6 +1499,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b4d00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:manual_payment:view", + "View the manual (offline) payment channel settings", + ), + perm( + "b4d00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:manual_payment:manage", + "Enable or disable manual invoice settlement per currency", + ), perm( "b4e00001-0001-4000-8000-000000000001", "edr_freight_app:settings:contract_templates:view", @@ -1641,6 +1685,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1660,6 +1705,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...YARD_SCOPE_PERMISSIONS, ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; @@ -1744,6 +1790,7 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + approveConsolidation: "edr_freight_app:bookings:approve_consolidation", // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:bookings:get_notification", clearanceGetNotification: @@ -1837,6 +1884,10 @@ export const FREIGHT_PERMS = { allocation: { manage: "edr_freight_app:allocation:manage", }, + yards: { + /** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */ + viewAll: "edr_freight_app:yards:view_all", + }, customers: { view: "edr_freight_app:customers:view", create: "edr_freight_app:customers:create", @@ -1872,6 +1923,10 @@ export const FREIGHT_PERMS = { /** Reject any pending invoice request. */ invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, + chat: { + view: 'edr_freight_app:chat:view', + sync: 'edr_freight_app:chat:sync', + }, payments: { view: "edr_freight_app:payments:view", }, @@ -2112,6 +2167,14 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Whether Finance may settle invoices by hand, per currency. Split + // view/manage on purpose: Finance reads it (the worklist offers only the + // enabled currencies) but must not switch its own channel on — same + // maker-checker split as the other sensitive finance settings. + manualPayment: { + view: "edr_freight_app:settings:manual_payment:view", + manage: "edr_freight_app:settings:manual_payment:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", @@ -2336,6 +2399,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, @@ -2392,6 +2459,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveDirector, @@ -2404,6 +2475,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), @@ -2416,6 +2491,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.export, // Manual settlement (bank transfer / counter) of USD and ETB invoices. FREIGHT_PERMS.invoices.confirmOffline, + // Read-only: the worklist offers whichever currencies are switched on. + // Flipping the switch is deliberately NOT here — see `manualPayment`. + FREIGHT_PERMS.settings.manualPayment.view, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // (the cron sweep runs as the system); these are the *manual* exceptional-operations @@ -2479,6 +2557,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, @@ -2497,6 +2579,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c131af4bd..68b812f6c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; @@ -86,6 +87,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; +import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -115,6 +117,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; +import ChatLaunchPage from "./pages/chat/ChatLaunchPage"; import { APP_TITLE, buildSidebarSections, @@ -298,6 +301,14 @@ const App = () => { } /> + + + + } + /> { } /> + {/* Shared-wagon gate: consolidated pairs wait for a human decision + before either half reaches Operations. */} + + + + } + /> { } /> + +
+ +
+ + } + /> - + ); } @@ -149,7 +156,13 @@ export function BookingActionsMenu({ - + ); } @@ -158,10 +171,14 @@ function ActionDialog({ flow, pendingAction, onSuppressRowClick, + consolidationPartnerId, + consolidationPartnerReference, }: { flow: ReturnType; pendingAction: ReturnType["pendingAction"]; onSuppressRowClick?: () => void; + consolidationPartnerId?: string | null; + consolidationPartnerReference?: string | null; }) { return ( ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 6b8e4b650..246704aca 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { Link2 } from "lucide-react"; import { + Alert, Modal, Group, Stack, @@ -37,6 +39,12 @@ interface BookingConfirmDialogProps { isPending: boolean; confirmDisabled?: boolean; extra?: ReactNode; + /** + * Reference of the booking sharing this one's wagon. When set, the dialog + * warns that the decision lands on BOTH bookings — staff must not think they + * are acting on one. + */ + pairedWithReference?: string | null; } export function BookingConfirmDialog({ @@ -52,6 +60,7 @@ export function BookingConfirmDialog({ isPending, confirmDisabled = false, extra, + pairedWithReference = null, }: BookingConfirmDialogProps) { if (!action || !action.confirmTitle) return null; @@ -125,6 +134,21 @@ export function BookingConfirmDialog({ {action.confirmDescription} )} + {pairedWithReference && ( + } + > + + This applies to {pairedWithReference} as well — + the two bookings share a wagon and are decided together. If either + fails, neither changes. + + + )} {/* Body */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx new file mode 100644 index 000000000..a5c74f52e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx @@ -0,0 +1,82 @@ +import { useQuery } from "@tanstack/react-query"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { Link2 } from "lucide-react"; + +import { bookingsService } from "@/services/bookings.service"; +import { formatDateTime } from "@/lib/format"; +import { SectionCard } from "./SectionCard"; + +const STATUS_COLOR: Record = { + PENDING: "yellow", + APPROVED: "teal", + REJECTED: "red", +}; + +/** + * Audit trail for this booking's shared wagon: every approval request against + * it, who decided, when, and why. Rendered only for a booking that is actually + * consolidated — there is nothing to show otherwise. + */ +export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) { + const { data } = useQuery({ + queryKey: ["consolidation-approvals", "history", bookingId], + queryFn: () => bookingsService.consolidationApprovalHistory(bookingId), + enabled: Boolean(bookingId), + }); + + if (!data?.length) return null; + + return ( + + + {data.map((row) => ( + + + + {row.status} + + + {row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"} + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedBy ? ` by ${row.requestedBy}` : ""} + + + {row.decidedAt ? ( + + {row.status === "APPROVED" ? "Approved" : "Rejected"}{" "} + {formatDateTime(row.decidedAt)} + {row.decidedBy ? ` by ${row.decidedBy}` : ""} + + ) : ( + + Waiting for a decision — neither booking reaches Operations until + this is approved. + + )} + + {row.decisionNote ? ( + + “{row.decisionNote}” + + ) : null} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index be7111745..24398bdf9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** + * Decisions that must be applied to BOTH halves of a consolidated pair. The two + * bookings share one wagon: accepting one alone would put half a wagon into the + * approval chain, and cancelling one alone would strand the other on a wagon it + * can no longer fill. + */ +const PAIRED_DECISIONS = { + accept: "accept", + cancel: "cancel", + operationAccept: "operationAccept", + requestChanges: "requestChanges", +} as const; + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -52,6 +65,30 @@ export function useBookingActionDialog( const onSuccess = () => closeDialog(); + // A booking on a shared wagon routes the four pairable decisions through the + // paired endpoint, which applies them to both halves all-or-nothing. Every + // other action stays per booking. + const pairedDecision = + PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS]; + if (context.consolidationPartnerId && pairedDecision) { + if (pairedDecision === "accept") { + const days = Number(inputValue.trim()); + if (!Number.isInteger(days) || days < 1 || days > 365) return; + mutations.pairedDecision.mutate( + { decision: "accept", validityDays: days }, + { onSuccess }, + ); + return; + } + mutations.pairedDecision.mutate( + pairedDecision === "cancel" + ? { decision: "cancel", reason: inputValue.trim() } + : { decision: pairedDecision, note: inputValue.trim() }, + { onSuccess }, + ); + return; + } + switch (pendingAction.id) { case "accept": { const days = Number(inputValue.trim()); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 67e84a09f..ca55338cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -40,6 +41,7 @@ import { FileText, FileUp, Flame, + Link2, MapPin, Package, Receipt, @@ -57,7 +59,10 @@ import { import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; +import { + contractsService, + type ConsolidationCandidate, +} from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, @@ -80,6 +85,18 @@ import { StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; +import { + ConsolidationPartnerPanel, + emptyPartnerLine, +} from "./gl-booking-form/ConsolidationPartnerPanel"; +import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; + +/** + * Container sizes offered on the parent-booking panel. Fixed rather than taken + * from this contract's scope: the parent booking is a different customer on a + * different contract, so its sizes are its own. + */ +const PARTNER_SIZES = ["20ft", "40ft"]; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; @@ -240,6 +257,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(copyFromParam), }); + // The booking being completed — used to name the customer on the price + // confirmation when a second booking's price is shown beside it. + const { data: completeBooking } = useQuery({ + queryKey: ["gl-complete-booking", completeBookingId], + queryFn: () => bookingsService.getById(completeBookingId!), + enabled: Boolean(completeBookingId), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -290,6 +315,18 @@ export default function GlCreateBookingForm() { const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); + // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── + // An odd 20ft total leaves one container unpaired. On a customs contract GL + // resolves that here by linking a second booking that is also odd — two odd + // counts always sum to even — completing both together onto the shared wagon. + const [consolidateOdd, setConsolidateOdd] = useState(false); + // Set once GL flips the toggle by hand, so the auto-on effect below never + // re-opens a panel GL deliberately closed. + const consolidateTouchedRef = useRef(false); + const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); + const [partner, setPartner] = useState(null); + const [partnerLines, setPartnerLines] = useState([]); + const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -834,6 +871,48 @@ export default function GlCreateBookingForm() { }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; + // Only a customs (Path B) instance being COMPLETED by GL can use the shared + // wagon: it is GL, not the customer, who links the two bookings. Anything else + // keeps the historical hard block on odd 20ft. + const oddConsolidationAvailable = Boolean( + completeBookingId && isContainer && contract?.customsClearingEnabled, + ); + + // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, + // once. GL can still switch it off — then odd is blocked exactly as before. + useEffect(() => { + if (!oddConsolidationAvailable) return; + if (consolidateTouchedRef.current) return; + if (hasOdd20ft) setConsolidateOdd(true); + }, [oddConsolidationAvailable, hasOdd20ft]); + + // Clear the partner as soon as the panel closes or stops applying, so a + // leftover selection can never ride along into a plain single-booking submit. + useEffect(() => { + if (consolidateOdd && oddConsolidationAvailable) return; + setPartner(null); + setPartnerLines([]); + setPartnerCargoDescription(""); + }, [consolidateOdd, oddConsolidationAvailable]); + + const consolidationActive = + oddConsolidationAvailable && consolidateOdd && hasOdd20ft; + + // Once a parent booking is linked, each booking's cargo is entered under its + // own labelled heading so it is clear which containers belong to whom. + const splitView = Boolean(consolidationActive && partner); + + const candidatesQuery = useQuery({ + queryKey: ["consolidation-candidates", id, completeBookingId], + queryFn: () => + contractsService.listConsolidationCandidates( + id ?? "", + completeBookingId ?? "", + ), + enabled: + partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), + }); + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -886,7 +965,64 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; + // The unpaired 20ft container is resolved by the shared wagon, so with an + // active consolidation an odd total stops being a blocker; without one it + // blocks exactly as before. + const oddBlocksSubmit = hasOdd20ft && !consolidationActive; + + // Partner side: a linked partner must be picked, carry an odd 20ft count of + // its own (odd + odd = even fills the wagon) and have complete unit details. + const partnerFt20Total = useMemo(() => { + if (!consolidationActive) return 0; + return partnerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [consolidationActive, partnerLines]); + + const partnerError = useMemo(() => { + if (!consolidationActive) return undefined; + if (!partner) return "Select the booking that shares this wagon."; + const totalQty = partnerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + if (totalQty < 1) { + return `Enter the containers for ${partner.reference}.`; + } + if (partnerFt20Total % 2 === 0) { + return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; + } + const incomplete = partnerLines.some((line) => { + const qty = Number(line.quantity || 0); + return qty >= 1 && line.units.length < qty; + }); + if (incomplete) { + return `Enter the container details for all of ${partner.reference}'s containers.`; + } + const badUnit = partnerLines.some((line) => + line.units.some( + (u) => + !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !(Number(u.vgmTons) > 0), + ), + ); + if (badUnit) { + return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + } + if (!partnerCargoDescription.trim()) { + return `Describe the cargo carried in ${partner.reference}'s containers.`; + } + return undefined; + }, [ + consolidationActive, + partner, + partnerLines, + partnerFt20Total, + partnerCargoDescription, + ]); + + const formValid = + cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() { return payload; }; + /** + * Completion DTO for the partner half of a shared wagon. Route, day and train + * are deliberately copied from THIS booking: the two bookings ride the same + * wagon, so they must ride the same train on the same day. Only the cargo and + * the billing currency belong to the partner. + */ + const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!partner || !consolidationActive) return null; + + const payload: Freight.CreateBookingUnderContractDto = { + paymentCurrency, + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), + ...(trainScheduleId ? { trainScheduleId } : {}), + ...(partnerCargoDescription.trim() + ? { cargoFreeText: partnerCargoDescription.trim() } + : {}), + containers: partnerLines + .filter((l) => Number(l.quantity) >= 1) + .map((l) => ({ + containerSize: l.containerSize, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, + units: l.units.map((u) => ({ + containerNumber: u.containerNumber.trim().toUpperCase(), + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + })), + })), + }; + + return payload; + }; + // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers @@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() { }); const validation = validateShipmentMutation.data ?? null; + // The partner is priced against ITS OWN contract, so the two totals shown in + // the confirm modal are each customer's real bill — nobody pays for the other. + const validatePartnerMutation = useMutation({ + mutationFn: (input: { + contractId: string; + bookingId: string; + dto: Freight.CreateBookingUnderContractDto; + }) => + contractsService.validateShipment( + input.contractId, + input.dto, + input.bookingId, + ), + }); + const partnerValidation = validatePartnerMutation.data ?? null; + const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; @@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() { }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); + const partnerTotal = useMemo(() => { + const items = partnerValidation?.lineItems; + if (!items?.length) return null; + return { + currency: partnerValidation?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [partnerValidation]); + + // The partner half must clear the same hard blocks as this one — the pair is + // booked all-or-nothing, so a block on either side blocks both. + const partnerBlockers = useMemo(() => { + if (!consolidationActive || !partnerValidation) return []; + return [ + ...(partnerValidation.pairingErrors ?? []), + ...(partnerValidation.capacityErrors ?? []), + ...(partnerValidation.containerClashErrors ?? []), + ...(partnerValidation.spaceErrors ?? []), + ]; + }, [consolidationActive, partnerValidation]); + + const completePairMutation = useMutation({ + mutationFn: (input: { + payload: Freight.CreateBookingUnderContractDto; + partnerPayload: Freight.CreateBookingUnderContractDto; + partnerBookingId: string; + }) => + contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { + partnerBookingId: input.partnerBookingId, + booking: input.payload, + partner: input.partnerPayload, + }), + }); + const submitPending = - mutations.createBooking.isPending || mutations.completeBooking.isPending; + mutations.createBooking.isPending || + mutations.completeBooking.isPending || + completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, @@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() { capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || - !serverTotal; + !serverTotal || + // Same bar for the shared-wagon partner: its authoritative price must be in + // hand and its own hard blocks clear before either booking is confirmed. + (consolidationActive && + (validatePartnerMutation.isPending || + !partnerTotal || + partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of @@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } + validatePartnerMutation.reset(); + const partnerPayload = buildPartnerPayload(); + if (partnerPayload && partner?.contractId) { + validatePartnerMutation.mutate({ + contractId: partner.contractId, + bookingId: partner.id, + dto: partnerPayload, + }); + } }; const handleSubmit = () => { @@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + // Shared wagon: both halves complete together, all-or-nothing on the server. + if (consolidationActive && partner && completeBookingId) { + // A hard block on the partner's own price preview blocks the pair. + if (partnerBlockers.length > 0) return; + const partnerPayload = buildPartnerPayload(); + if (!partnerPayload) return; + completePairMutation.mutate( + { + payload, + partnerPayload, + partnerBookingId: partner.id, + }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. @@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() { maxRows={4} styles={fieldStyles} /> + {/* With a parent booking linked, each booking's containers are + entered in its own labelled section, one after the other. */} + {splitView ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? "—"} + + + ) : null} {containerLines.length === 0 ? ( This contract has no container sizes in scope. @@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() { )) )} - {hasOdd20ft ? ( + {hasOdd20ft && oddConsolidationAvailable ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + + + 20ft containers travel two per wagon, so one container here + is unpaired. On a customs booking you can pair it with + another customer's odd booking and complete both onto the + shared wagon — each booking is still priced and invoiced + separately. + + { + consolidateTouchedRef.current = true; + setConsolidateOdd(e.currentTarget.checked); + }} + /> + {consolidateOdd ? ( + + + {partner ? ( + + ) : null} + + ) : ( + + With sharing off, book an even number of 20ft containers + — add one more or remove one (e.g. {ft20Total + 1} or{" "} + {ft20Total - 1} instead of {ft20Total}). + + )} + + + ) : hasOdd20ft ? ( ) : null} + + {splitView && partner ? ( + <> + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + Parent booking — ships on the same day and train, billed to + its own customer. + + + + ) : null} ) : ( @@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() { > Fix the highlighted fields before reviewing the price. + ) : partnerError ? ( + // The review button is disabled while the parent booking is + // incomplete, so the click that would reveal the errors never + // lands — say what is outstanding without waiting for it. + } + mb="sm" + > + {partnerError} + ) : null} {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} @@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} onClick={openPriceModal} - // Same hard block the customer portal applies at review time — - // an unpaired 20ft can never be planned onto a wagon. - disabled={hasOdd20ft} + // An unpaired 20ft can never be planned onto a wagon — unless + // a parent booking is linked to share it, which is what + // oddBlocksSubmit accounts for. The parent's own cargo must be + // complete too, or there is nothing to price. + disabled={oddBlocksSubmit || Boolean(partnerError)} > Review price & book @@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() { + setPartnerPickerOpen(false)} + candidates={candidatesQuery.data ?? []} + isLoading={candidatesQuery.isLoading} + isError={candidatesQuery.isError} + onSelect={(candidate) => { + setPartner(candidate); + // Seed a 20ft and a 40ft line. The parent booking sits on its OWN + // contract, whose size scope need not match this one's, so the panel + // offers both sizes rather than mirroring this contract's scope; a + // size the parent does not ship is simply left at 0. + setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine)); + setPartnerCargoDescription(""); + setPartnerPickerOpen(false); + }} + /> + { @@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() { )} + {/* Whose bill this is. Only worth naming when a second booking is + on screen — on a lone booking there is nothing to confuse it with. */} + {consolidationActive && partner ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? contract.company?.name ?? "—"} + + + ) : null} {displayTotal.lines.map((line, i) => ( @@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() { + {consolidationActive && partner ? ( + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + {validatePartnerMutation.isPending ? ( + + + + Pricing the partner booking… + + + ) : partnerBlockers.length > 0 ? ( + } + title={`Cannot book ${partner.reference}`} + > + + {partnerBlockers.map((msg, i) => ( + + {msg} + + ))} + + Both bookings are confirmed together, so this must be + fixed before either can be booked. + + + + ) : partnerTotal ? ( + <> + + {partnerTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()}{" "} + {partnerTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()}{" "} + {partnerTotal.currency} + + + ))} + + + + + Total + + + {partnerTotal.total.toLocaleString()}{" "} + + {partnerTotal.currency} + + + + + ) : ( + + No price yet for the partner booking. + + )} + + ) : null} + + {consolidationActive && partner ? ( + } + > + + These two bookings share one wagon but stay separate: each is + invoiced to its own customer and paid separately. Confirming + books both together — if either fails, neither is booked. + + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx new file mode 100644 index 000000000..e52cce097 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -0,0 +1,255 @@ +import { type KeyboardEvent } from "react"; +import { + Box, + Checkbox, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +/** + * Container editor for the PARTNER half of a shared wagon. Deliberately a + * reduced version of the main form's editor: the partner contributes only cargo + * — route, shipment day and train are inherited from the booking it shares the + * wagon with, and hazardous/reefer/return counts are derived from the per-unit + * ticks rather than typed line totals. + */ + +export interface PartnerUnitDraft { + containerNumber: string; + sealNumber: string; + vgmTons: string; + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; +} + +export interface PartnerLineDraft { + containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; + returnQuantity: string; + units: PartnerUnitDraft[]; +} + +export function emptyPartnerUnit(): PartnerUnitDraft { + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; +} + +export function emptyPartnerLine(size: string): PartnerLineDraft { + return { + containerSize: size, + quantity: "0", + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: [], + }; +} + +/** Quantities are magnitudes — swallow the minus key before it reaches the field. */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + +/** Grow or shrink a line's unit rows to match its quantity. */ +function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft { + const target = Math.max(0, Math.floor(quantity) || 0); + const units = [...line.units]; + while (units.length < target) units.push(emptyPartnerUnit()); + units.length = target; + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; +} + +interface Props { + lines: PartnerLineDraft[]; + onLinesChange: (lines: PartnerLineDraft[]) => void; + cargoDescription: string; + onCargoDescriptionChange: (value: string) => void; + /** Whether per-container hazardous / refrigerated ticks apply. */ + showHazardous: boolean; + showReefer: boolean; + /** Surface field errors only after the operator tried to continue. */ + showErrors: boolean; + error?: string; +} + +export function ConsolidationPartnerPanel({ + lines, + onLinesChange, + cargoDescription, + onCargoDescriptionChange, + showHazardous, + showReefer, + showErrors, + error, +}: Props) { + const patchLine = (index: number, patch: Partial) => { + onLinesChange( + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + }; + + const patchUnit = ( + lineIndex: number, + unitIndex: number, + patch: Partial, + ) => { + onLinesChange( + lines.map((line, i) => { + if (i !== lineIndex) return line; + const units = line.units.map((unit, u) => + u === unitIndex ? { ...unit, ...patch } : unit, + ); + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; + }), + ); + }; + + return ( + + {error && showErrors ? ( + + {error} + + ) : null} + + {lines.map((line, lineIdx) => ( + + + {line.containerSize} containers + + + patchLine(lineIdx, { quantity: e.currentTarget.value })} + // Sync off the typed value, not the captured `line` — that snapshot + // still holds the pre-edit quantity and would write it back. + onBlur={(e) => { + const typed = e.currentTarget.value; + patchLine(lineIdx, { + ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)), + quantity: typed, + }); + }} + mb={12} + /> + + {line.units.map((unit, unitIdx) => ( + + + Container {unitIdx + 1} + + + + patchUnit(lineIdx, unitIdx, { + containerNumber: e.currentTarget.value.toUpperCase(), + }) + } + /> + + patchUnit(lineIdx, unitIdx, { + sealNumber: e.currentTarget.value, + }) + } + /> + 0) + ? "Required." + : undefined + } + onChange={(e) => + patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value }) + } + /> + + {showHazardous || showReefer ? ( + + {showHazardous ? ( + + patchUnit(lineIdx, unitIdx, { + isHazardous: e.currentTarget.checked, + }) + } + /> + ) : null} + {showReefer ? ( + + patchUnit(lineIdx, unitIdx, { + isReefer: e.currentTarget.checked, + }) + } + /> + ) : null} + + ) : null} + + ))} + + ))} + + onCargoDescriptionChange(e.currentTarget.value)} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx new file mode 100644 index 000000000..c20c08994 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx @@ -0,0 +1,137 @@ +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Link2 } from "lucide-react"; + +import type { ConsolidationCandidate } from "@/services/contracts.service"; + +/** + * Picker for the booking that shares this booking's wagon. The server has + * already narrowed the list to bookings that can legally pair — same route and + * direction, customs clearing, an odd 20ft count of their own and not already + * linked to someone else — so every row here is a valid choice. + */ +interface Props { + opened: boolean; + onClose: () => void; + candidates: ConsolidationCandidate[]; + isLoading: boolean; + isError: boolean; + onSelect: (candidate: ConsolidationCandidate) => void; +} + +export function ConsolidationPartnerPicker({ + opened, + onClose, + candidates, + isLoading, + isError, + onSelect, +}: Props) { + return ( + + + + + + + Pick the parent booking + + + Customs bookings on the same route that also carry an odd number of + 20ft containers. + + + + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + } + > + Could not load the candidate bookings. Close this and try again. + + ) : candidates.length === 0 ? ( + } + title="No booking available to share this wagon" + > + + No other customs booking on this route currently carries an odd + number of 20ft containers. Either wait for one, or switch the + shared-wagon option off and book an even number of 20ft containers. + + + ) : ( + + {candidates.map((candidate) => ( + + + + + + {candidate.reference} + + + {candidate.status.replaceAll("_", " ")} + + + + {candidate.companyName ?? "—"} + {candidate.tradeDirection + ? ` · ${candidate.tradeDirection}` + : ""} + {" · "} + {candidate.hasCargo + ? `${candidate.ft20Quantity} × 20ft` + : "cargo not entered yet"} + + + + + + ))} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 65c4f25e3..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -20,11 +20,10 @@ import { FileX2, } from "lucide-react"; import { useState } from "react"; -import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.requestChangeRequestChanges.mutationOptions(), ); - const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; @@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { - - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
+ {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 90dea5adb..3127a39f8 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -114,6 +114,36 @@ export function CompanyNationalityBadge({ ); } +/** + * The company's registration was typed, not fetched from eTrade — nothing in it + * has been checked against a licence. Loud on purpose: it is the one thing a + * reviewer must not miss about this customer. Two kinds of company land here + * for different reasons, and the badge names which. + */ +export function ManualRegistrationBadge({ + cooperative, + investorLicence, +}: { + cooperative?: boolean | null; + investorLicence?: boolean | null; +}) { + if (!cooperative && !investorLicence) return null; + return ( + + {cooperative + ? "Manual entry · co-operative" + : "Manual entry · investment licence"} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code, colored by the profile's status (green active, @@ -308,9 +338,11 @@ export function InvoiceStatusBadge({ /** * Inline approval action buttons for a profile row. - * Transitions: pending → approve / reject-with-note | rejected → approve (override) | + * Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. - * Rejecting captures a note the customer sees so they can fix and reapply. + * Rejecting captures a note the customer sees so they can fix and reapply — a + * rejected role is theirs to resubmit, so it cannot be approved from here until + * they do (the API refuses it); undoing the rejection is the only way back. * * `locked` (customer hasn't submitted onboarding) withholds the review decision * only — there's no application to judge yet, and the API rejects the call @@ -496,18 +528,33 @@ export function ProfileApprovalActions({ } if (status === "rejected") { - if (!canSet("active")) return null; + // No Approve here: the role is waiting on the customer to fix what was + // flagged and resubmit it, and the API refuses rejected → active outright. + // All that's left is undoing a rejection that shouldn't have happened, + // which puts the role back in the queue rather than into service. + if (!canSet("pending")) return null; return ( - + + + Awaiting customer resubmission + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 6f869173c..864a89ae6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -4,6 +4,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 636310e38..691040c0a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -71,6 +71,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/chat", + meta: { + title: "Chat", + subtitle: "Internal messaging for EDR staff", + }, + }, { // Invoices, Payments, and USD Payments are tabs on one page now // (FinanceHubPage); the header title itself is set per-tab there. diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 293e82450..fc5b90dbf 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -12,6 +12,7 @@ import { Image as ImageIcon, LayoutDashboard, LayoutGrid, + Link2, MapPin, Network, Package, @@ -32,6 +33,7 @@ import { Users, Wallet, LifeBuoy, + MessageSquare, TrainFront, XCircle, } from "lucide-react"; @@ -95,6 +97,14 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.bookings.view, }, + // Shared-wagon gate: a consolidated pair waits for a human decision + // before either half reaches Operations. + { + label: "Shared wagon approvals", + href: "/dashboard/consolidation-approvals", + icon: , + permission: FREIGHT_PERMS.bookings.approveConsolidation, + }, { label: "Wagon cancellations", href: "/dashboard/wagon-cancellations", @@ -124,6 +134,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.support.agentView, }, + { + label: "Chat", + href: "/dashboard/chat", + icon: , + permission: FREIGHT_PERMS.chat.view, + }, ...demoItems, ], }, @@ -555,6 +571,11 @@ export const buildSidebarSections = ( href: "/dashboard/configuration/exchange-rate", permission: FREIGHT_PERMS.settings.exchangeRate.view, }, + { + label: "Manual payments", + href: "/dashboard/configuration/manual-payments", + permission: FREIGHT_PERMS.settings.manualPayment.view, + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index 1bddb77cd..7077f3fdf 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -4,34 +4,40 @@ import { Button, Checkbox, Group, + Pagination, ScrollArea, Select, Stack, Text, TextInput, } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; -import { Plus, Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { MapPin, Plus, Search } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +const PAGE_SIZE = 20; import { api } from "@/services/api"; /** - * AVAILABLE wagons standing in the train's own yard — the only ones that can - * be coupled. Pick any number and append them to the consist. + * AVAILABLE, unassigned wagons from every yard — filtered and paged on the API, + * so the picker never page-walks the whole fleet into the browser. */ -export default function AvailableWagonsPanel({ - yardId, - yardLabel, +function AvailableWagonsPanel({ + homeYardId, onAssign, assigning, exportTrainNumber, importTrainNumber, }: AvailableWagonsPanelProps) { const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); const [typeFilter, setTypeFilter] = useState("ALL"); + const [yardFilter, setYardFilter] = useState("ALL"); const [runOnly, setRunOnly] = useState(false); - const [selected, setSelected] = useState([]); + const [selected, setSelected] = useState>(() => new Set()); + const [page, setPage] = useState(1); // The train's own run, e.g. "8001-8002" — only offered when the train has one. const runLabel = exportTrainNumber @@ -39,86 +45,95 @@ export default function AvailableWagonsPanel({ : null; const wagonsQuery = useQuery( - api.wagons.list.queryOptions({ + api.wagons.listPaged.queryOptions({ input: { filters: { status: Freight.WagonStatus.Available, - currentYardId: yardId, // Loose wagons only — one already on another train cannot be coupled. unassigned: true, + search: debouncedSearch.trim() || undefined, + currentYardId: yardFilter === "ALL" ? undefined : yardFilter, + wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter, + // Rostered to this train's run — the API matches either run column. + trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined, + page, + pageSize: PAGE_SIZE, }, }, - enabled: Boolean(yardId), + // Keep the previous page on screen while the next one loads — otherwise + // paging and typing flash the list to "Loading wagons…" on every stroke. + placeholderData: (prev) => prev, }), ); - const wagons = useMemo(() => { - const q = search.trim().toLowerCase(); - return (wagonsQuery.data ?? []).filter((wagon) => { - if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; - // Rostered to this train's run — match on the export run, which fixes the - // import run anyway. - if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false; - if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; - return true; - }); - }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]); + const wagons = wagonsQuery.data?.items ?? []; + const total = wagonsQuery.data?.meta.total ?? 0; + const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1); - const runMatchCount = useMemo( - () => - exportTrainNumber - ? (wagonsQuery.data ?? []).filter( - (w) => w.exportTrainNumber === exportTrainNumber, - ).length - : 0, - [wagonsQuery.data, exportTrainNumber], + // Filters change → back to page 1 (and clamp when the list shrinks). + useEffect(() => { + setPage(1); + }, [debouncedSearch, typeFilter, yardFilter, runOnly]); + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + // Dropdowns come from the reference lists, not the current page — a yard or + // type must stay pickable even when this page holds none of it. + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 })); + + const yardOptions = useMemo(() => { + const yards = [...(yardsQuery.data ?? [])].sort((a, b) => + a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label), + ); + return [ + { value: "ALL", label: "All yards" }, + ...yards.map((yard) => ({ + value: yard.id, + label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`, + })), + ]; + }, [yardsQuery.data, homeYardId]); + + const typeOptions = useMemo( + () => [ + { value: "ALL", label: "All types" }, + // e.g. "Flat wagon (NW5)" — name with its type code. + ...(wagonTypesQuery.data ?? []).map((type) => ({ + value: type.id, + label: type.code ? `${type.name} (${type.code})` : type.name, + })), + ], + [wagonTypesQuery.data], ); - const typeOptions = useMemo(() => { - const byId = new Map(); - for (const wagon of wagonsQuery.data ?? []) { - if (wagon.wagonType) { - // e.g. "Flat wagon (NW5)" — name with its type code. - byId.set( - wagon.wagonType.id, - wagon.wagonType.code - ? `${wagon.wagonType.name} (${wagon.wagonType.code})` - : wagon.wagonType.name, - ); - } - } - return [ - { value: "ALL", label: "All types" }, - ...[...byId.entries()].map(([value, label]) => ({ value, label })), - ]; - }, [wagonsQuery.data]); + const toggle = useCallback((wagonId: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(wagonId); + else next.delete(wagonId); + return next; + }); + }, []); - const toggle = (wagonId: string, checked: boolean) => { - setSelected((prev) => - checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId), - ); - }; - - const allSelected = - wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); - const someSelected = wagons.some((w) => selected.includes(w.id)); + // Select-all covers this page only — the rest of the matches are not loaded. + const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id)); + const someSelected = wagons.some((w) => selected.has(w.id)); const toggleAll = (checked: boolean) => { setSelected((prev) => { - if (checked) { - const ids = new Set(prev); - wagons.forEach((w) => ids.add(w.id)); - return [...ids]; - } - const visible = new Set(wagons.map((w) => w.id)); - return prev.filter((id) => !visible.has(id)); + const next = new Set(prev); + if (checked) wagons.forEach((w) => next.add(w.id)); + else wagons.forEach((w) => next.delete(w.id)); + return next; }); }; const handleAssign = () => { - if (!selected.length) return; - onAssign(selected); - setSelected([]); + if (!selected.size) return; + onAssign([...selected]); + setSelected(new Set()); }; return ( @@ -138,100 +153,102 @@ export default function AvailableWagonsPanel({ onChange={(v) => setTypeFilter(v ?? "ALL")} /> +