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 d0ab805a8..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"; @@ -117,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"; @@ -136,6 +140,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -254,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 cf77072e0..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; @@ -80,7 +99,19 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; + /** + * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the + * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign + * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never + * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. + */ buyerCountryCode: string | null; + /** + * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format + * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — + * this is not validated against a fixed digit pattern, only looked up by name. + */ + buyerCountryCodes: Record; /** * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails @@ -89,6 +120,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has + * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike + * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already + * succeeds with it null), so an unmapped zone falls back to null rather than failing the + * mapping. + */ + buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -115,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; @@ -143,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; @@ -169,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", @@ -208,8 +259,12 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), - buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), + // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a + // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. + buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, + buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, + buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), @@ -223,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-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-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 2bc60d669..e28dff30f 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -662,6 +662,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/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 21676c160..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -84,7 +84,13 @@ export class PdfRenderService { const page = await browser.newPage(); const thermal = opts.thermal ?? false; const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; - await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); @@ -154,7 +160,7 @@ export class PdfRenderService { // browser context regardless, same as the closure form would be. const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; - return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); } private injectPdfPrintStyles(html: string): string { 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 7406613fc..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 @@ -60,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { expect(doc.BuyerDetails).toEqual({ City: null, + // company.country is "Ethiopia" (the domestic default) — resolves to context's flat + // buyerCountryCode fallback, not null, per resolveCountryCode. + Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -146,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, @@ -335,6 +342,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); + it("derives City from the buyer's zone via the city code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), + seller, + context({ buyerCityCodes: { Kirkos: "101" } }), + ); + expect(doc.BuyerDetails.City).toBe("101"); + }); + + it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), + seller, + context({ buyerCityCodes: {} }), + ); + expect(doc.BuyerDetails.City).toBeNull(); + }); + + it("maps a buyer country name to its code via the country code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Djibouti" } }), + seller, + context({ buyerCountryCodes: { Djibouti: "071" } }), + ); + expect(doc.BuyerDetails.Country).toBe("071"); + }); + + it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ); + expect(doc.BuyerDetails.Country).toBe("231"); + }); + + it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Kenya" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ), + ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + }); + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); 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 582e3d326..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 @@ -234,8 +234,13 @@ export interface EimsMapperContext { * from a registered invoice"). */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already + * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. + */ buyerCountryCode?: string | null; + /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ + buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -252,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the + * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already + * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail + * the mapping. + */ + buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, - * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending - * a guessed code onto a tax document is worse than refusing to file. + * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already + * numeric, otherwise looked up by name (case- and space-insensitive). + * + * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax + * document is worse than refusing to file. City is optional (`required: false`, City's own + * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to + * null instead of blocking the invoice. */ function resolveLocationCode( - field: "Region" | "Wereda", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -319,12 +335,61 @@ function resolveLocationCode( )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; + if (opts.required === false) return null; + throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } +/** + * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies + * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). + * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same + * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's + * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. + */ +function resolveCountryCode( + country: string | null | undefined, + codes: Record, + domesticFallback: string | null, + invoiceNumber: string, +): string | null { + const raw = (country ?? "").trim(); + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped) return mapped; + + if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", + ); +} + +/** + * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an + * error to and that must never throw — currently only `EimsSellerCacheService`, resolving + * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, + * name lookup, `undefined` on no match — the caller falls back to static config either way. + */ +export function resolveOptionalCode( + value: string | null | undefined, + codes: Record, +): string | undefined { + const raw = (value ?? "").trim(); + if (LOCATION_CODE.test(raw)) return raw; + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -398,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), @@ -440,7 +511,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // No dedicated city column on Company — Zone is the closest match; optional (see + // resolveLocationCode's City comment). + City: resolveLocationCode( + "City", + company.zone, + context.buyerCityCodes, + "EIMS_BUYER_CITY_CODES", + invoice.invoiceNumber, + { required: false }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -455,7 +535,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, 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 7755a870b..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`, 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.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } 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-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/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-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index b77804eda..4c1c82c10 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, 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 36c355bed..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,8 +10,10 @@ 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"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** @@ -474,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 ab9f953c5..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,13 +26,10 @@ 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, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -125,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, - buildEimsSeller(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; @@ -471,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, @@ -478,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, @@ -588,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-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + return { service, resolveCompanyData, extractRegistrationData, cfg }; +}; + +const CODES = { + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, + buyerCityCodes: { Bole: "101" }, +}; + +describe("EimsSellerCacheService.getSellerDetails", () => { + it("static config wins over a conflicting e-Trade value", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("101"); + }); + + it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { + jest.useFakeTimers(); + try { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + this.cached = { + LegalName: data.companyName || undefined, + Phone: data.mobilePhone || data.regularPhone || undefined, + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the + // same buyer code maps, since the geography is objective, not buyer-specific, despite the + // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to + // getSellerDetails' static-config fallback. + Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), + Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), + City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} 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 93df29da2..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 @@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code + buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, @@ -57,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/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 0ee22ec88..5d55a7ee8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. DocumentsModule, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService, 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/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/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 e9c0d453a..1e7a56094 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -467,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 @@ -531,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( @@ -1659,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, @@ -1678,6 +1705,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...YARD_SCOPE_PERMISSIONS, ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; @@ -1856,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", @@ -1891,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", }, @@ -2543,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 d303e3b77..68b812f6c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -117,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, @@ -300,6 +301,14 @@ const App = () => { } /> + + + + } + /> - 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 ff8fa940f..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 @@ -33,6 +33,7 @@ import { Users, Wallet, LifeBuoy, + MessageSquare, TrainFront, XCircle, } from "lucide-react"; @@ -133,6 +134,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.support.agentView, }, + { + label: "Chat", + href: "/dashboard/chat", + icon: , + permission: FREIGHT_PERMS.chat.view, + }, ...demoItems, ], }, diff --git a/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts new file mode 100644 index 000000000..65522aafd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts @@ -0,0 +1,13 @@ +import { api } from "@/auth/http"; + +/** + * Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat + * itself is a separate app (chat.edr.et); this backoffice only ever asks for + * a fresh sign-in link into it. + */ +export const chatApi = { + getSsoUrl: async (): Promise => { + const { data } = await api.get<{ url: string }>("/chat/sso"); + return data.url; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 6dce79940..de43dff41 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -33,6 +33,10 @@ export const FREIGHT_PERMS = { staffUsers: { view: "edr_freight_app:staff:users:view", }, + chat: { + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index ebdba3e11..d0ae6bba3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -131,6 +131,15 @@ export default function BookingRequestsPage() { [yardRefs], ); + // Service-type options — same reference-data payload the booking form uses. + const { data: refData } = useQuery( + api.bookings.referenceData.queryOptions({ staleTime: 5 * 60_000 }), + ); + const serviceTypeOptions = useMemo( + () => (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name })), + [refData], + ); + // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — // the header's document-review alarm opens exactly the undecided requests // it is counting down for. No sync effect needed any more: controls.values @@ -148,6 +157,7 @@ export default function BookingRequestsPage() { options: filterOptions(TRADE_DIRECTION_OPTIONS), }, { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions }, { key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, { // Wins over the `paymentStatus` filter above — the queue is by @@ -173,7 +183,7 @@ export default function BookingRequestsPage() { toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, ], - [filterOptions, yardOptions], + [filterOptions, yardOptions, serviceTypeOptions], ); const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 48ce552a8..39a0e9d77 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -27,6 +27,7 @@ import { import type { Freight } from "@edr/types"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; +import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; import type { KpiItem } from "@/components/page"; import { @@ -134,6 +135,20 @@ export default function DocumentClearanceDetailPage() { hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + // Operations sent this GL-created booking back for changes. Customs bookings + // are never self-booked (see BookingChangesRequestedAlert) — the note and the + // resubmit belong here, on the page GL works from, not the customer's portal. + const bookingNeedsChanges = booking?.status === "OPERATION_CHANGES_REQUESTED"; + const isGlBookingOwner = + hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && + !isDjiboutiGl(user); + const changeRequestNote = + [...(booking?.reviewNotes ?? [])] + .filter((n) => n.type === "CHANGES_REQUESTED") + .sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; + const docsPhaseComplete = clearance?.milestones?.some( (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", @@ -279,6 +294,22 @@ export default function DocumentClearanceDetailPage() { } /> + {bookingNeedsChanges ? ( + void refetch()} + /> + ) : null} + {requestedLines ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx new file mode 100644 index 000000000..78f0e7c36 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -0,0 +1,78 @@ +import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core"; +import { MessageSquare, TriangleAlert } from "lucide-react"; +import { useState } from "react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { chatApi } from "@/features/chat/chatApi"; + +/** + * Chat itself lives at chat.edr.et (Element), not in this app — this page's + * only job is a one-click sign-in link into it. No iframe: Element's own CSP + * refuses to be framed. + * + * The link is minted per click, never on mount and never cached: Synapse's + * login_token is single-use and expires in 5 minutes, and Element reports a + * spent one as "Incorrect username and/or password". A held-onto url is + * therefore wrong on the second click, on a remount served from cache, and on + * any click more than 5 minutes after the page loaded. + */ +export default function ChatLaunchPage() { + const [state, setState] = useState<"idle" | "loading" | "error">("idle"); + + const open = async () => { + // Opened before the await so it still counts as the user's click — a + // window.open() after it is treated as a popup and blocked. + // + // No "noopener" in the features: passing it makes window.open return null, + // which would leave this blank tab orphaned and send Element into the + // current tab instead. Clearing .opener on the handle does the same job. + const tab = window.open("", "_blank"); + if (tab) tab.opener = null; + setState("loading"); + try { + const url = await chatApi.getSsoUrl(); + if (tab) tab.location.replace(url); + else window.location.assign(url); // popup blocked — go in this tab + setState("idle"); + } catch { + tab?.close(); + setState("error"); + } + }; + + return ( + + + +
+ + {state === "error" && ( + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + + )} + + + + + Opens EDR Chat in a new tab, already signed in as you. + + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index db57819d5..9cbc0e5e0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -3,34 +3,28 @@ import { ActionIcon, Box, Card, - Group, - Select, Stack, Text, - TextInput, ThemeIcon, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; -import { bookingsService } from "@/services/bookings.service"; +import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters"; /** * Operations "Clearance Documents" hub — the worklist for self-clearance @@ -43,16 +37,14 @@ import { const PAGE_SIZE = 10; /** - * Status filter options (values = `statuses` param). FULLY_EXECUTED is the - * post-approval status of intercity (domestic) bookings — kept in the list as - * history, otherwise an approved intercity row vanishes from the hub. + * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of + * intercity (domestic) bookings, kept in as history so an approved intercity + * row doesn't just vanish. Sent whenever the Status pill has no narrower pick. */ -const BOOKING_STATUS_OPTIONS = [ - { - value: - "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", - label: "All statuses", - }, +const DEFAULT_STATUSES = + "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED"; + +const STATUS_OPTIONS = [ { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "CLEARANCE_READY", label: "Clearance ready" }, @@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [ { value: "CUSTOMER", label: "Customer" }, ]; -function startOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x.toISOString(); -} - -function endOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x.toISOString(); -} - export default function ClearanceDocumentsPage() { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [bookingStatuses, setBookingStatuses] = useState( - BOOKING_STATUS_OPTIONS[0].value, - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState(null); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [customerKindFilter, setCustomerKindFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); - const search = debouncedQuery.trim() || undefined; + const filterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS, + // No pick ⇒ no `statuses` param at all; the query fills in + // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row. + toParams: ({ v }) => ({ statuses: v[0] }), + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, + options: filterOptions(TRADE_DIRECTION_OPTIONS), + }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS }, + { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, + { + key: "created", + label: "Created", + type: "date", + toParams: dateRangeParams("createdFrom", "createdTo"), + }, + ], + [filterOptions], + ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); - }, [setPagination]); + const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE }); - const page = pagination.pageIndex + 1; + const filter: BookingListFilter = useMemo( + () => ({ + ...(controls.params as unknown as BookingListFilter), + // Self-clearance instances carry bookingType=ONE_TIME whatever their + // contract kind, so customsClearingEnabled=false + the status scope + // above are what isolate exactly this worklist. + customsClearingEnabled: "false", + statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES, + }), + [controls.params], + ); const bookingsQuery = useQuery({ - queryKey: [ - "clearance-documents", - "bookings", - bookingStatuses, - directionFilter, - freightTypeFilter, - ownershipFilter, - customerKindFilter, - createdFrom, - createdTo, - page, - search, - ], - queryFn: () => - // Self-clearance instances carry bookingType=ONE_TIME whatever their - // contract kind, so customsClearingEnabled=false + the three per-booking - // clearance statuses are what isolate exactly this worklist. - bookingsService.list({ - statuses: bookingStatuses, - customsClearingEnabled: "false", - page, - pageSize: PAGE_SIZE, - search, - ...(directionFilter ? { tradeDirection: directionFilter } : {}), - ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), - ...(ownershipFilter - ? { isGovernment: ownershipFilter as "true" | "false" } - : {}), - ...(customerKindFilter - ? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" } - : {}), - ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), - ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), - }), + queryKey: ["clearance-documents", "bookings", filter], + queryFn: () => bookingsService.list(filter), placeholderData: keepPreviousData, }); @@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() { ); const total = bookingsQuery.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const showEmpty = !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; const tableStatus = bookingsQuery.isLoading @@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 130 }} - aria-label="Filter by direction" - /> - { - setCustomerKindFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 140 }} - aria-label="Filter by booked by" - /> - ` row + // read, just reshaped into FilterDefs. Falls back to a plain client-only + // Status filter for the 3 slugs with no server-side list filters at all. + const filterDefs: FilterDef[] = useMemo(() => { + const dateDef: FilterDef = { + key: "created", + label: "Registered", + type: "date", + secondary: true, + toParams: dateRangeParams("createdFrom", "createdTo"), + }; + if (config?.listFilters?.length) { + return [ + ...config.listFilters.map((filter): FilterDef => ({ + key: filter.key, + label: filter.label, + type: "enum", + multiple: false, + options: filter.dynamicOptions + ? (dynamicOptions[filter.dynamicOptions] ?? []) + : (filter.options ?? []), + })), + dateDef, + ]; + } + const fallback = FALLBACK_STATUS_OPTIONS[slug]; + return fallback + ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef] + : [dateDef]; + }, [config, dynamicOptions, slug]); + + const controls = useFilters(filterDefs, { pageSize: 10 }); + + // On the server-paged path the page window, the search and the registration + // date range are all resolved by the API — nothing is filtered client-side. + // `controls.params` already carries every filter's mapped param name (status/ + // currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to + // createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter + // object; the unpaged one is the same minus pagination and the date range + // (which stays client-only for the non-server-paged slugs, see below). + const serverListFilters = useMemo((): FleetListFilters | undefined => { + if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined; + const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params; + return rest as FleetListFilters; + }, [slug, controls.params]); + + const pagedFilters = useMemo( + (): FleetListFilters => controls.params as unknown as FleetListFilters, + [controls.params], + ); + + const listQuery = useQuery({ + ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + enabled: !serverPaged, + }); + const pagedQuery = useQuery({ + ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), + enabled: serverPaged, + placeholderData: keepPreviousData, + }); + + const activeQuery = serverPaged ? pagedQuery : listQuery; + const { isLoading, isError, error } = activeQuery; + const allRows = useMemo( + () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), + [serverPaged, pagedQuery.data, listQuery.data], + ); + const create = useMutation(api.fleet.create.mutationOptions()); + const update = useMutation(api.fleet.update.mutationOptions()); + const remove = useMutation(api.fleet.remove.mutationOptions()); + const purge = useMutation(api.fleet.purge.mutationOptions()); useEffect(() => { registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); @@ -349,16 +322,22 @@ const FleetResourcePage = () => { // The API already applied every filter and cut the page — re-filtering here // would drop rows the server deliberately returned. if (serverPaged) return allRows; - const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; // The date range applies even when the API already filtered the list — // it is not one of the server-side filters. - if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; - if (usesServerListFilters) return true; - if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { + const created = controls.values.created; + if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) { return false; } + // Every other filter (status/yard/wagon type/…) was already applied + // server-side for these slugs — re-checking here against a plain field + // equality would be wrong for one (a wagon's "trainNumber" filter + // matches either of two DIFFERENT columns server-side, not one). + if (usesServerListFilters) return true; + const status = controls.values.status; + if (status && String(record.status ?? "") !== status.v[0]) return false; + const term = controls.searchText.trim().toLowerCase(); if (!term) return true; return config.searchKeys.some((key) => String(record[key] ?? "") @@ -366,19 +345,23 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]); + }, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]); const totalCount = serverPaged ? (pagedQuery.data?.meta.total ?? 0) : filteredRows.length; const pageCount = serverPaged ? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1) - : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + : Math.max(1, Math.ceil(filteredRows.length / controls.pageSize)); const pagedRows = useMemo(() => { if (serverPaged) return filteredRows; - const start = pagination.pageIndex * pagination.pageSize; - return filteredRows.slice(start, start + pagination.pageSize); - }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]); + const start = (controls.page - 1) * controls.pageSize; + return filteredRows.slice(start, start + controls.pageSize); + }, [filteredRows, controls.page, controls.pageSize, serverPaged]); + + // Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid + // (not a DataTable) just needs the raw pieces out of it below. + const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount); const columns = useMemo((): ColumnDef[] => { if (!config) return []; @@ -604,77 +587,41 @@ const FleetResourcePage = () => { - - { - setDateFrom(from); - setDateTo(to); - }} - presets={getDateRangePresets()} - clearable - size="sm" - radius="lg" - w={240} - /> - {listFilterSelects ? ( - - {listFilterSelects.map((filter) => ( - setContainerSize(val as EmptyContainerSize | null)} + data={[ + { value: "20", label: "20 ft" }, + { value: "40", label: "40 ft" }, + ]} + /> + + + + + )} + /> + ( + + English Name + + + + + + )} + /> + ( + + Code * + + + + + + )} + /> + ( + + Location Type * + + + + )} + /> + ( + + Parent Location + + + + )} + /> + + +
+ Coordinates + {GOOGLE_MAPS_API_KEY ? ( +
+ + + {pin ? : null} + + +
+ ) : ( + // Name the missing variable rather than rendering a dead grey box. +

+ Map picker unavailable — VITE_GOOGLE_MAPS_API_KEY is + not set. Type the coordinates below instead. +

+ )} + {GOOGLE_MAPS_API_KEY ? ( +

+ Click the map to drop a pin, or type the values. +

+ ) : null} +
+ +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> + ( + + Area + + + + + + )} + /> +
+ + ( + + Boundary (GeoJSON) + +