mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -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"
|
||||
;;
|
||||
|
||||
388
CLAUDE.md
388
CLAUDE.md
@@ -1,102 +1,362 @@
|
||||
# EDR Platform — Developer Guide
|
||||
|
||||
> This file is the contract. If something here contradicts the code, the code is the
|
||||
> truth and this file is a bug — fix it in the same PR.
|
||||
|
||||
**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
|
||||
you to the right module or page without a repo-wide grep.
|
||||
|
||||
## Overview
|
||||
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
||||
Management and Passenger Management applications, a payment microservice, plus shared
|
||||
types, NestJS utilities, and React component libraries.
|
||||
|
||||
The freight domain is the largest and most active area. Its core flow is:
|
||||
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
||||
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
||||
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
||||
(warehouse/yard/zone) hang off the warehouse stage.
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Package name | Purpose | Port |
|
||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
The two domains are **not built the same way**. Check which stack you are in before
|
||||
copying a pattern across:
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
|
||||
| App | Package name | Stack | Default port |
|
||||
| ------------------------------ | --------------------------- | ---------------------- | ------------ |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
|
||||
|
||||
Those are the **fallbacks compiled into the code**, not what you will be running. Every
|
||||
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
|
||||
apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
|
||||
the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
|
||||
`./wt ports` for who currently holds what.
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||
packages (see `pnpm-workspace.yaml`).
|
||||
|
||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
||||
|
||||
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||
| Package | Location | Purpose |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
|
||||
| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
|
||||
| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
|
||||
| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
|
||||
| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
|
||||
| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
|
||||
|
||||
The three `config/*` packages are nested one level deeper than the rest — `packages/config`
|
||||
itself is not a package.
|
||||
|
||||
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
||||
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
||||
|
||||
```bash
|
||||
pnpm turbo build --filter=@edr/types
|
||||
```
|
||||
|
||||
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Run only freight API + web |
|
||||
| `pnpm dev:passenger` | Run only passenger API + web |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||
| `pnpm dev:freight:api` | Freight API only |
|
||||
| `pnpm dev:freight:portal` | Freight portal only |
|
||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||
| `pnpm dev:passenger` | Passenger API + web |
|
||||
| `pnpm dev:payment` | Payment API |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests (turbo) |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| `pnpm lint` | **Does not work** — see below |
|
||||
|
||||
## Standards
|
||||
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
|
||||
`turbo run lint` dies with `eslint: not found` even though every package declares a
|
||||
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
|
||||
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
|
||||
"lint clean".
|
||||
|
||||
- **TypeScript strict mode** is enabled in every package and app.
|
||||
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
|
||||
- **Conventional commits** are enforced via commitlint on every commit.
|
||||
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
|
||||
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
|
||||
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
|
||||
`--config` explicitly there.
|
||||
|
||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||
```
|
||||
|
||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||
|
||||
## Environment & database
|
||||
|
||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||
no port `5433`/`5434` is published anywhere in the repo.
|
||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||
remote database.
|
||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||
per-connection in a pool `connect` handler instead. See
|
||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||
through API calls or message queues.
|
||||
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
|
||||
freight tables in `freight`.
|
||||
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
|
||||
skill (below) or write a short Node script using `pg` and run it from
|
||||
`apps/edr-freight-api`, where `pg` resolves.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are non-negotiable. Everything else is a strong default.
|
||||
|
||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||
reach for `any` to make an error go away.
|
||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||
All schema changes go through migrations.
|
||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
|
||||
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
|
||||
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
|
||||
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
|
||||
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
|
||||
- **Controllers** never contain business logic.
|
||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||
`deletedAt` (soft delete).
|
||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||
TypeScript properties are `camelCase`.
|
||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||
offer the safe version.
|
||||
|
||||
## Auth
|
||||
## Architecture
|
||||
|
||||
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
|
||||
### NestJS module shape
|
||||
|
||||
When auth integration is needed, use placeholder TODO comments:
|
||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
|
||||
|
||||
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
|
||||
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
|
||||
### Data access — the real model
|
||||
|
||||
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
|
||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||
|
||||
## Port Assignments
|
||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
|
||||
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
|
||||
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
|
||||
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
|
||||
|
||||
- `edr-freight-api`: 3001
|
||||
- `edr-freight-web/portal`: 5173
|
||||
- `edr-freight-web/backoffice`: 5183
|
||||
- `edr-passenger-api`: 3002
|
||||
- `edr-payment-api`: 3003
|
||||
- `edr-passenger-web/portal`: 5174
|
||||
- `edr-passenger-web/backoffice`: 5184
|
||||
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||
It carries one obligation:
|
||||
|
||||
## Database Layout
|
||||
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||
|
||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||
so they join the caller's transaction.
|
||||
|
||||
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||
no timeout and will wait forever.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
|
||||
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
|
||||
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
|
||||
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
|
||||
and breaks ties non-deterministically. Check before adding one:
|
||||
|
||||
```bash
|
||||
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
|
||||
```
|
||||
|
||||
The prefix must be unused *and* higher than the newest recorded row. Note the
|
||||
`freight.migrations` table has far more rows (~309) than this folder has files — most
|
||||
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
|
||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||
backfills guarded by `WHERE col IS NULL`.
|
||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||
re-introduce the outage it fixed.
|
||||
|
||||
### Auth
|
||||
|
||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||
|
||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||
Add a permission there before referencing it.
|
||||
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
|
||||
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
|
||||
`backoffice` for employees, `portal` for customers. Without it the API 403s with
|
||||
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
|
||||
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||
|
||||
Ownership checks are separate from permission checks. A staff user passes
|
||||
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
|
||||
check the installed version before copying a snippet.
|
||||
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||
freight web apps. Prefer it over re-implementing a component.
|
||||
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||
keep the synchronous version — their bodies are already parsed JSON.
|
||||
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||
|
||||
## Notifications
|
||||
|
||||
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||
SMS and email still send, because they address the company's phone and email directly. Check
|
||||
this before debugging a "missing notification".
|
||||
|
||||
## PDF generation
|
||||
|
||||
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||
assume a headless browser exists.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
|
||||
`<feature>.{module,controller,service,repository}.ts` files.
|
||||
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||
4. The service injects the repository class (not `Repository<T>` directly).
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||
6. Register the module in the app's `app.module.ts`.
|
||||
|
||||
## Adding a new shared component to `@edr/ui-common`
|
||||
|
||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||
2. Export from `src/index.ts`.
|
||||
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
|
||||
3. Component is a functional component with a `ComponentNameProps` interface
|
||||
(named-exported alongside the default).
|
||||
|
||||
## Definition of done
|
||||
|
||||
A change is done when **all** of these hold. State explicitly which you ran.
|
||||
|
||||
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
|
||||
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
|
||||
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
|
||||
dev database without error.
|
||||
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
|
||||
something the new code reads — applied to the dev database, since watch mode will not run it.
|
||||
4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
|
||||
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
|
||||
then run the specs covering what you touched and confirm you introduced no new failure.
|
||||
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
|
||||
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
|
||||
type-check is your only unused-code signal.
|
||||
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||
endpoint, or ran the query. If you could not, say so plainly.
|
||||
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||
the summary. Never describe unverified work as done.
|
||||
|
||||
### Hooks do not run
|
||||
|
||||
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||
hand; do not assume the hook caught it.
|
||||
|
||||
## Known traps
|
||||
|
||||
| Trap | What happens | What to do |
|
||||
| --- | --- | --- |
|
||||
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
|
||||
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
|
||||
|
||||
## Project skills
|
||||
|
||||
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||
|
||||
| Skill | Use for |
|
||||
| --- | --- |
|
||||
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||
|
||||
## Working style
|
||||
|
||||
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||
from a filename.
|
||||
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||
trade-off before changing files.
|
||||
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||
- **Branch from `dev`; PRs target `dev`.**
|
||||
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||
|
||||
313
CLAUDE_NEW.md
313
CLAUDE_NEW.md
@@ -1,313 +0,0 @@
|
||||
# EDR Platform — Developer Guide
|
||||
|
||||
> This file is the contract. If something here contradicts the code, the code is the
|
||||
> truth and this file is a bug — fix it in the same PR.
|
||||
|
||||
## Overview
|
||||
|
||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
||||
Management and Passenger Management applications, a payment microservice, plus shared
|
||||
types, NestJS utilities, and React component libraries.
|
||||
|
||||
The freight domain is the largest and most active area. Its core flow is:
|
||||
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
||||
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
||||
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
||||
(warehouse/yard/zone) hang off the warehouse stage.
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Package name | Purpose | Default port |
|
||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||
packages (see `pnpm-workspace.yaml`).
|
||||
|
||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||
|
||||
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
||||
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
||||
|
||||
```bash
|
||||
pnpm turbo build --filter=@edr/types
|
||||
```
|
||||
|
||||
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------- | ---------------------------------------- |
|
||||
| `pnpm install` | Install all workspace dependencies |
|
||||
| `pnpm dev` | Run every app in dev mode |
|
||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||
| `pnpm dev:freight:api` | Freight API only |
|
||||
| `pnpm dev:freight:portal` | Freight portal only |
|
||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||
| `pnpm dev:passenger` | Passenger API + web |
|
||||
| `pnpm dev:payment` | Payment API |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests (turbo) |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
|
||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||
|
||||
```bash
|
||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||
```
|
||||
|
||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||
|
||||
## Environment & database
|
||||
|
||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||
no port `5433`/`5434` is published anywhere in the repo.
|
||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||
remote database.
|
||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||
per-connection in a pool `connect` handler instead. See
|
||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||
through API calls or message queues.
|
||||
- `psql` is not installed on the dev machine. To query the database, write a short Node
|
||||
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are non-negotiable. Everything else is a strong default.
|
||||
|
||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||
reach for `any` to make an error go away.
|
||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||
All schema changes go through TypeORM migrations.
|
||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||
`deletedAt` (soft delete).
|
||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||
TypeScript properties are `camelCase`.
|
||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||
offer the safe version.
|
||||
|
||||
## Architecture
|
||||
|
||||
### NestJS module shape
|
||||
|
||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||
|
||||
### Data access — the real model
|
||||
|
||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||
|
||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
|
||||
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
|
||||
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
|
||||
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
|
||||
|
||||
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||
It carries one obligation:
|
||||
|
||||
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||
|
||||
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||
so they join the caller's transaction.
|
||||
|
||||
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||
no timeout and will wait forever.
|
||||
|
||||
### Migrations
|
||||
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it.
|
||||
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
|
||||
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
|
||||
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
|
||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||
backfills guarded by `WHERE col IS NULL`.
|
||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||
re-introduce the outage it fixed.
|
||||
|
||||
### Auth
|
||||
|
||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||
|
||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||
Add a permission there before referencing it.
|
||||
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||
|
||||
Ownership checks are separate from permission checks. A staff user passes
|
||||
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version
|
||||
before copying a snippet.
|
||||
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||
freight web apps. Prefer it over re-implementing a component.
|
||||
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||
keep the synchronous version — their bodies are already parsed JSON.
|
||||
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||
|
||||
## Notifications
|
||||
|
||||
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||
SMS and email still send, because they address the company's phone and email directly. Check
|
||||
this before debugging a "missing notification".
|
||||
|
||||
## PDF generation
|
||||
|
||||
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||
assume a headless browser exists.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
|
||||
`<feature>.{module,controller,service,repository}.ts` files.
|
||||
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||
4. The service injects the repository class (not `Repository<T>` directly).
|
||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||
6. Register the module in the app's `app.module.ts`.
|
||||
|
||||
## Adding a new shared component to `@edr/ui-common`
|
||||
|
||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||
2. Export from `src/index.ts`.
|
||||
3. Component is a functional component with a `ComponentNameProps` interface
|
||||
(named-exported alongside the default).
|
||||
|
||||
## Definition of done
|
||||
|
||||
A change is done when **all** of these hold. State explicitly which you ran.
|
||||
|
||||
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
|
||||
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
|
||||
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
|
||||
dev database without error.
|
||||
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
|
||||
something the new code reads — applied to the dev database, since watch mode will not run it.
|
||||
4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**,
|
||||
so a fully green suite is not the bar. Run the specs covering what you touched and confirm
|
||||
you introduced no new failure.
|
||||
5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these
|
||||
automatically (see below), so run them yourself.
|
||||
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||
endpoint, or ran the query. If you could not, say so plainly.
|
||||
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||
the summary. Never describe unverified work as done.
|
||||
|
||||
### Hooks do not run
|
||||
|
||||
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||
hand; do not assume the hook caught it.
|
||||
|
||||
## Known traps
|
||||
|
||||
| Trap | What happens | What to do |
|
||||
| --- | --- | --- |
|
||||
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||
|
||||
## Project skills
|
||||
|
||||
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||
|
||||
| Skill | Use for |
|
||||
| --- | --- |
|
||||
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||
|
||||
## Working style
|
||||
|
||||
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||
from a filename.
|
||||
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||
trade-off before changing files.
|
||||
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||
- **Branch from `dev`; PRs target `dev`.**
|
||||
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
/**
|
||||
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||
* plain `String(name)` there yields "[object Object]" in an audit trail.
|
||||
*/
|
||||
export interface IamUserRow {
|
||||
name?: Record<string, string> | string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Best display name for a user row: English label → any locale → login → email. */
|
||||
export function pickUserName(user: IamUserRow): string | null {
|
||||
const { name } = user;
|
||||
if (typeof name === "string" && name.trim()) return name.trim();
|
||||
if (name && typeof name === "object") {
|
||||
const localized =
|
||||
name.en ??
|
||||
Object.values(name).find((v) => typeof v === "string" && v.trim());
|
||||
if (localized?.trim()) return localized.trim();
|
||||
}
|
||||
return user.username?.trim() || user.email?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display names for a set of IAM user ids — one query for the whole set.
|
||||
* `iam.users` is owned by the auth system and has no entity here, so it is read
|
||||
* directly. A miss is not an error: the caller still holds the id and can fall
|
||||
* back to it.
|
||||
*/
|
||||
export async function resolveIamUserNames(
|
||||
dataSource: DataSource,
|
||||
userIds: (string | null | undefined)[],
|
||||
): Promise<Map<string, string>> {
|
||||
const resolved = new Map<string, string>();
|
||||
const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
|
||||
if (ids.length === 0) return resolved;
|
||||
|
||||
const rows = (await dataSource.query(
|
||||
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||
[ids],
|
||||
)) as Array<IamUserRow & { id: string }>;
|
||||
for (const row of rows) {
|
||||
const name = pickUserName(row);
|
||||
if (name) resolved.set(row.id, name);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export interface ChatConfig {
|
||||
enabled: boolean;
|
||||
/** Synapse base URL reachable from this container (client + admin APIs). */
|
||||
baseUrl: string;
|
||||
/** Synapse's public_baseurl — what Element itself is configured to call. Only
|
||||
* used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */
|
||||
publicBaseUrl: string;
|
||||
/** Public Element Web origin — the SSO handoff link points here. */
|
||||
webUrl: string;
|
||||
/** Matrix server_name — the `:domain` half of every MXID. */
|
||||
serverName: string;
|
||||
/** HS256 secret. Must exactly match Synapse's jwt_config.secret. */
|
||||
jwtSecret: string;
|
||||
/** Bearer token for a Synapse server admin account (room/user provisioning). */
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
'MATRIX_BASE_URL',
|
||||
'MATRIX_PUBLIC_BASE_URL',
|
||||
'MATRIX_CHAT_WEB_URL',
|
||||
'MATRIX_SERVER_NAME',
|
||||
'MATRIX_JWT_SECRET',
|
||||
'MATRIX_ADMIN_TOKEN',
|
||||
] as const;
|
||||
|
||||
export default registerAs('chat', (): ChatConfig => {
|
||||
const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
publicBaseUrl: '',
|
||||
webUrl: '',
|
||||
serverName: '',
|
||||
jwtSecret: '',
|
||||
adminToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''),
|
||||
publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''),
|
||||
webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''),
|
||||
serverName: process.env.MATRIX_SERVER_NAME!,
|
||||
jwtSecret: process.env.MATRIX_JWT_SECRET!,
|
||||
adminToken: process.env.MATRIX_ADMIN_TOKEN!,
|
||||
};
|
||||
});
|
||||
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import eimsConfigFactory from "./eims.config";
|
||||
|
||||
const REQUIRED = {
|
||||
EIMS_ENABLED: "true",
|
||||
EIMS_CLIENT_ID: "cid",
|
||||
EIMS_CLIENT_SECRET: "secret",
|
||||
EIMS_API_KEY: "apikey",
|
||||
EIMS_TIN: "0000000000",
|
||||
};
|
||||
|
||||
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
|
||||
const prior: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
prior[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("eims.config — private key / certificate resolution", () => {
|
||||
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a PEM with real newlines untouched", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws naming all three key/cert options when none are set", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY_PATH: undefined,
|
||||
EIMS_PRIVATE_KEY_BASE64: undefined,
|
||||
EIMS_PRIVATE_KEY: undefined,
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
},
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).toThrow(
|
||||
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("is satisfied by any single one of the three key options", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
|
||||
it("resolves a known region/wereda/zone with no env var set at all", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const cfg = eimsConfigFactory();
|
||||
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
|
||||
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
|
||||
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var entry overrides the baked-in code for the same name", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_REGION_CODES: "Somali=99",
|
||||
},
|
||||
() => {
|
||||
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_CITY_CODES: "Fafen=01",
|
||||
},
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerCityCodes;
|
||||
expect(codes.Fafen).toBe("01");
|
||||
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
|
||||
expect(codes.Bole).toBe("01");
|
||||
expect(codes.Arada).toBe("01");
|
||||
expect(codes.Kirkos).toBe("01");
|
||||
expect(codes.Yeka).toBe("01");
|
||||
expect(codes["Nifas Silk Lafto"]).toBe("13");
|
||||
expect(codes["Nefas Silk-Lafto"]).toBe("13");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
/**
|
||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||
@@ -89,6 +120,14 @@ export interface EimsInvoiceConfig {
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||
* mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
@@ -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<string, string> => {
|
||||
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(", ")}`,
|
||||
|
||||
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
|
||||
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
|
||||
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
|
||||
*
|
||||
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
|
||||
* someone hunted down the code and added it to an env var by hand — happened three times in one
|
||||
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
|
||||
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
|
||||
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
|
||||
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
|
||||
* not all ~1000 real woredas), extend as new gaps surface.
|
||||
*
|
||||
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
|
||||
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
|
||||
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
|
||||
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
|
||||
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
|
||||
*
|
||||
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
|
||||
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
|
||||
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
|
||||
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
|
||||
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
|
||||
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
|
||||
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
|
||||
*/
|
||||
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
|
||||
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
|
||||
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
|
||||
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
|
||||
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
|
||||
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
|
||||
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
|
||||
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
|
||||
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
|
||||
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
|
||||
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
|
||||
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
|
||||
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
|
||||
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
|
||||
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
|
||||
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
|
||||
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
|
||||
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
|
||||
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
|
||||
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
|
||||
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
|
||||
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
|
||||
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
|
||||
["Amhara", "Awi", "Injibara", "03", "09", "01"],
|
||||
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
|
||||
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
|
||||
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
|
||||
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
|
||||
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
|
||||
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
|
||||
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
|
||||
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
|
||||
["Oromia", "Arsi", "Asella", "04", "05", "01"],
|
||||
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
|
||||
["Oromia", "Bale", "Robe", "04", "07", "01"],
|
||||
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
|
||||
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
|
||||
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
|
||||
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
|
||||
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
|
||||
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
|
||||
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
|
||||
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
|
||||
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
|
||||
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
|
||||
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
|
||||
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
|
||||
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
|
||||
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
|
||||
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
|
||||
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
|
||||
["Somali", "Fafan", "Awbare", "05", "01", "03"],
|
||||
["Somali", "Sitti", "Shinile", "05", "02", "01"],
|
||||
["Somali", "Erer", "Fiq", "05", "03", "01"],
|
||||
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
|
||||
["Somali", "Nogob", "Segeg", "05", "05", "01"],
|
||||
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
|
||||
["Somali", "Shabelle", "Gode", "05", "07", "01"],
|
||||
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
|
||||
["Somali", "Liben", "Filtu", "05", "09", "01"],
|
||||
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
|
||||
["Somali", "Dollo", "Warder", "05", "11", "01"],
|
||||
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
|
||||
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
|
||||
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
|
||||
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
|
||||
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
|
||||
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
|
||||
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
|
||||
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
|
||||
["Gambela", "Nuer", "Lare", "08", "02", "01"],
|
||||
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
|
||||
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
|
||||
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
|
||||
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
|
||||
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
|
||||
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
|
||||
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
|
||||
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
|
||||
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
|
||||
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
|
||||
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
|
||||
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
|
||||
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
|
||||
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
|
||||
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
|
||||
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
|
||||
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
|
||||
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
|
||||
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
|
||||
];
|
||||
|
||||
/** First occurrence wins on a name collision — see the class comment. */
|
||||
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of ROWS) {
|
||||
const [name, code] = pick(row);
|
||||
if (!(name in map)) map[name] = code;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
|
||||
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
|
||||
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
|
||||
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
|
||||
|
||||
/**
|
||||
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
|
||||
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
|
||||
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
|
||||
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
|
||||
* same code rather than wait on a fuller table.
|
||||
*/
|
||||
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
|
||||
["Bole", "Bole Sub-City"],
|
||||
["Kirkos", "Kirkos Sub-City"],
|
||||
["Nifas Silk Lafto", "Nifas Silk Lafto"],
|
||||
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
|
||||
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
|
||||
["Yeka", "Yeka Sub-City"],
|
||||
["Arada", "Arada Sub-City"],
|
||||
];
|
||||
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
|
||||
const row = ROWS.find((r) => r[1] === csvZoneName);
|
||||
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Which desks work at which yard — the input to yard access scoping.
|
||||
*
|
||||
* Many-to-many: a position (what the user-management tree calls a department)
|
||||
* can cover several yards, and a yard is staffed by several positions. The
|
||||
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||
*
|
||||
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||
* soft-deleted position silently drops out of scope rather than granting it.
|
||||
*
|
||||
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||
* same pair later.
|
||||
*/
|
||||
export class YardPositions3560000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||
position_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
|
||||
ON freight.yard_positions (yard_id, position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||
ON freight.yard_positions (position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||
* scoping.
|
||||
*
|
||||
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||
* database. A migration is the one path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||
* the freight application row is absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT gen_random_uuid(),
|
||||
$1::varchar,
|
||||
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||
* delete trips the position/role permission foreign keys — and a half-removed
|
||||
* permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
YardViewAllPermission3570000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -662,6 +662,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): 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");
|
||||
|
||||
@@ -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<string, string>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
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<string, string>,
|
||||
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<string, string>,
|
||||
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, string>,
|
||||
): 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,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal file
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal file
@@ -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<Record<NotificationType, { alias: string; name: string }>> = {
|
||||
[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<typeof chatConfig>,
|
||||
private readonly matrix: MatrixClient,
|
||||
) {}
|
||||
|
||||
async bridge(input: NotifyInput): Promise<void> {
|
||||
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 = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
|
||||
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
|
||||
}`;
|
||||
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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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-<positionKey>), 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<void> {
|
||||
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<PositionHolder[]> {
|
||||
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<number> {
|
||||
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<string>,
|
||||
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<ReconcileResult> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
|
||||
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<string, { name: string; userIds: Set<string> }>();
|
||||
for (const h of holders) {
|
||||
const entry = byPosition.get(h.positionKey) ?? {
|
||||
name: h.positionName,
|
||||
userIds: new Set<string>(),
|
||||
};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal file
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal file
@@ -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<typeof chatConfig>,
|
||||
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()}` };
|
||||
}
|
||||
}
|
||||
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal file
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal file
@@ -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 {}
|
||||
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
@@ -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._=\-/]+$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
@@ -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<typeof chatConfig>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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, '-');
|
||||
}
|
||||
|
||||
/** `@<localpart>:<server_name>` — 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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token: string = this.config.adminToken,
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
): Promise<T | null> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
const res = await this.request<{ joined: Record<string, unknown> }>(
|
||||
'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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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: "<user> 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return this.request(
|
||||
'POST',
|
||||
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
|
||||
{ erase: false },
|
||||
);
|
||||
}
|
||||
|
||||
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.revertToRegularCompany(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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<string, unknown> | null) {
|
||||
const companiesRepo = {
|
||||
findById: jest.fn(async () => company),
|
||||
update: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "company-1",
|
||||
...row,
|
||||
})),
|
||||
existsByTin: jest.fn(async () => false),
|
||||
};
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
|
||||
updateStatus: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
// 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<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => ({ 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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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<CompanyProfile> = {
|
||||
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<CompanyProfile>[] = [];
|
||||
const profileRepo = {
|
||||
update: jest.fn(async (_id: string, patch: Partial<CompanyProfile>) => {
|
||||
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<unknown>) =>
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Company> = {};
|
||||
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<Company> = {
|
||||
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<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
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<CompanyChangeRequest[]> {
|
||||
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<CompanyRevision[]> {
|
||||
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<Map<string, string>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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 ──────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) ??
|
||||
[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Company, "attributes"> | 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<Company, "attributes"> | null | undefined,
|
||||
): boolean {
|
||||
return isCooperative(company) || hasInvestorLicence(company);
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
|
||||
@@ -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, string> | 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<Map<string, string>> {
|
||||
const resolved = new Map<string, string>();
|
||||
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<IamUserRow & { id: string }>;
|
||||
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. */
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EimsBulkCancelItemResult[]> {
|
||||
const results = new Map<string, EimsBulkCancelItemResult>();
|
||||
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<EimsBulkCancelRequest, EimsBulkCancelResponse>(
|
||||
"/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<EimsInvoiceStatusView> {
|
||||
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
|
||||
@@ -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<EimsConfig>("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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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<void> {
|
||||
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" },
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<EimsSellerDetails> | null = null;
|
||||
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
|
||||
private refreshing: Promise<void> | 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<void> {
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = this.doRefresh().finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
private async doRefresh(): Promise<void> {
|
||||
try {
|
||||
const cfg = this.config.get<EimsConfig>("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<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): 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> = {}): EimsConfig => ({
|
||||
systemType: EIMS_SYSTEM_TYPE,
|
||||
privateKeyPath: "/dev/null",
|
||||
certificatePath: "/dev/null",
|
||||
privateKeyBase64: "",
|
||||
certificateBase64: "",
|
||||
privateKeyPem: "",
|
||||
certificatePem: "",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
autoSubmit: false,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<User>,
|
||||
) {}
|
||||
@@ -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<void> {
|
||||
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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<YardPositionRow[]> {
|
||||
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<YardPositionRow[]> {
|
||||
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<YardPositionRow[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { yardIds: string[]; at: number }>();
|
||||
|
||||
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<string[] | null> {
|
||||
// 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<boolean> {
|
||||
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<void> {
|
||||
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<string[] | null> {
|
||||
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<string>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<WarehouseInventory[]> {
|
||||
/**
|
||||
* `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<WarehouseInventory[]> {
|
||||
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<Warehouse>) ?? {}),
|
||||
stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds),
|
||||
};
|
||||
}
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? [
|
||||
@@ -1037,8 +1074,11 @@ export class WarehouseInventoryService {
|
||||
return items;
|
||||
}
|
||||
|
||||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
findReadyForLoading(
|
||||
filter: FilterWarehouseInventoryDto,
|
||||
user?: unknown,
|
||||
): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chat"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.chat.view}>
|
||||
<ChatLaunchPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="customers"
|
||||
element={
|
||||
|
||||
@@ -20,11 +20,10 @@ import {
|
||||
FileX2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { openFileInNewTab } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
@@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
id: string;
|
||||
kind: "reject" | "request-changes";
|
||||
@@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void fetchViewableFile(
|
||||
openFileInNewTab(
|
||||
c.fileId,
|
||||
c.fileName ?? humanize(c.code),
|
||||
).then(view)
|
||||
)
|
||||
}
|
||||
style={{
|
||||
textDecoration:
|
||||
@@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void fetchViewableFile(
|
||||
fileId,
|
||||
`Document ${i + 1}`,
|
||||
).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)}
|
||||
>
|
||||
Document {i + 1}
|
||||
</Anchor>
|
||||
@@ -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 }) {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TimelineEntry["kind"], { label: string; color: string }> = {
|
||||
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 }) {
|
||||
<Stack gap="md">
|
||||
{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 (
|
||||
<Card key={entry.id} withBorder>
|
||||
<Stack gap="sm">
|
||||
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* 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) && (
|
||||
<Stack gap={2}>
|
||||
{requestedLine && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{requestedLine}
|
||||
</Text>
|
||||
)}
|
||||
{decidedLine && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{decidedLine}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{entry.note && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Note:</strong> {entry.note}
|
||||
<strong>
|
||||
{entry.kind === "changes_requested"
|
||||
? "What was asked for:"
|
||||
: "Note:"}
|
||||
</strong>{" "}
|
||||
{entry.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Badge
|
||||
color="orange"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{cooperative
|
||||
? "Manual entry · co-operative"
|
||||
: "Manual entry · investment licence"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Awaiting customer resubmission
|
||||
</Text>
|
||||
<Tooltip
|
||||
multiline
|
||||
w={260}
|
||||
label="Puts the role back in the pending queue and clears the rejection note. Use only if the rejection itself was a mistake — it does not approve the role."
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("pending")}
|
||||
>
|
||||
Undo rejection
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Users,
|
||||
Wallet,
|
||||
LifeBuoy,
|
||||
MessageSquare,
|
||||
TrainFront,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -133,6 +134,12 @@ export const buildSidebarSections = (
|
||||
icon: <LifeBuoy />,
|
||||
permission: FREIGHT_PERMS.support.agentView,
|
||||
},
|
||||
{
|
||||
label: "Chat",
|
||||
href: "/dashboard/chat",
|
||||
icon: <MessageSquare />,
|
||||
permission: FREIGHT_PERMS.chat.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
|
||||
13
apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
Normal file
13
apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
Normal file
@@ -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<string> => {
|
||||
const { data } = await api.get<{ url: string }>("/chat/sso");
|
||||
return data.url;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 ? (
|
||||
<BookingChangesRequestedAlert
|
||||
bookingId={id!}
|
||||
reference={booking?.reference}
|
||||
note={changeRequestNote}
|
||||
scheduledDate={booking?.scheduledDate}
|
||||
canResubmit={isGlBookingOwner}
|
||||
editHref={
|
||||
booking?.contractId
|
||||
? `/dashboard/contracts/${booking.contractId}/bookings/${id}/complete?copyFrom=${id}`
|
||||
: undefined
|
||||
}
|
||||
onResubmitted={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{requestedLines ? (
|
||||
|
||||
@@ -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 (
|
||||
<PageContainer>
|
||||
<PageHeader title="Chat" subtitle="Internal messaging for EDR staff" />
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Center>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
{state === "error" && (
|
||||
<Alert
|
||||
icon={<TriangleAlert size={18} />}
|
||||
color="red"
|
||||
title="Couldn't get a sign-in link"
|
||||
variant="light"
|
||||
>
|
||||
Something went wrong reaching chat. Try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack align="center" gap="sm">
|
||||
<MessageSquare size={40} strokeWidth={1.5} />
|
||||
<Text c="dimmed" ta="center" maw={360}>
|
||||
Opens EDR Chat in a new tab, already signed in as you.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={open}
|
||||
loading={state === "loading"}
|
||||
leftSection={<MessageSquare size={16} />}
|
||||
>
|
||||
Open EDR Chat
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(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() {
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search booking, contract, customer or shipping line…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
data={BOOKING_STATUS_OPTIONS}
|
||||
value={bookingStatuses}
|
||||
onChange={(v) => {
|
||||
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
radius="lg"
|
||||
w={220}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 130 }}
|
||||
aria-label="Filter by direction"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight type"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Booked by"
|
||||
data={CUSTOMER_KIND_OPTIONS}
|
||||
value={customerKindFilter}
|
||||
onChange={(v) => {
|
||||
setCustomerKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by booked by"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by ownership"
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Created date range"
|
||||
value={[createdFrom, createdTo]}
|
||||
onChange={([from, to]) => {
|
||||
setCreatedFrom(from ? new Date(from) : null);
|
||||
setCreatedTo(to ? new Date(to) : null);
|
||||
resetPage();
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
aria-label="Created date range"
|
||||
/>
|
||||
</Group>
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="clearance-documents"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
@@ -420,18 +289,7 @@ export default function ClearanceDocumentsPage() {
|
||||
state: { from: "/dashboard/contracts/clearance-documents" },
|
||||
})
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
|
||||
@@ -115,6 +115,15 @@ export default function ContractRequestsPage() {
|
||||
[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],
|
||||
);
|
||||
|
||||
// Static shape only (no facet counts) — this is what useFilters needs to
|
||||
// parse the URL and build API params. Counts are attached separately below,
|
||||
// for rendering only, once the summary query (which itself depends on
|
||||
@@ -143,6 +152,13 @@ export default function ContractRequestsPage() {
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: serviceTypeOptions,
|
||||
},
|
||||
{
|
||||
key: "paymentCurrency",
|
||||
label: "Currency",
|
||||
@@ -170,7 +186,7 @@ export default function ContractRequestsPage() {
|
||||
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
|
||||
},
|
||||
],
|
||||
[filterOptions, yardOptions],
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
);
|
||||
|
||||
const controls = useFilters(filterDefs, {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Banknote,
|
||||
Contact,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileSignature,
|
||||
FileText,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
PersonCard,
|
||||
ProfileApprovalActions,
|
||||
@@ -69,7 +71,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
openFileInNewTab,
|
||||
} from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
@@ -81,12 +83,7 @@ import type {
|
||||
} from "@/types/customer";
|
||||
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
useFileViewer,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
@@ -146,7 +143,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
@@ -271,9 +267,7 @@ export default function CustomerDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
@@ -282,9 +276,7 @@ export default function CustomerDetailPage() {
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
@@ -339,7 +331,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canReview],
|
||||
[canReview],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -522,9 +514,7 @@ export default function CustomerDetailPage() {
|
||||
aria-label="View"
|
||||
data-stop-row-click
|
||||
onClick={() =>
|
||||
void fetchViewableFile(row.original.id, row.original.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(row.original.id, row.original.name)
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
@@ -564,7 +554,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canRequestDocChange],
|
||||
[canRequestDocChange],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
@@ -755,6 +745,10 @@ export default function CustomerDetailPage() {
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ManualRegistrationBadge
|
||||
cooperative={company.cooperative}
|
||||
investorLicence={company.investorLicence}
|
||||
/>
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -803,6 +797,25 @@ export default function CustomerDetailPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Nothing below came from eTrade for these customers. A
|
||||
co-operative holds no trade licence at all; a foreign investor's
|
||||
comes from the Investment Commission, not the trade registry.
|
||||
Either way every registration field was typed, and the reviewer
|
||||
is the only check there is. */}
|
||||
{(company.cooperative || company.investorLicence) && (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Registration entered by hand — not verified against eTrade"
|
||||
>
|
||||
{company.cooperative
|
||||
? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving."
|
||||
: "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -872,7 +885,9 @@ export default function CustomerDetailPage() {
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
: company.investorLicence
|
||||
? "Foreign investment licence — typed by the customer, not from eTrade"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
@@ -1163,9 +1178,7 @@ export default function CustomerDetailPage() {
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, textAlign: "left" }}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
@@ -1176,9 +1189,7 @@ export default function CustomerDetailPage() {
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
<Eye size={15} />
|
||||
@@ -1280,6 +1291,23 @@ export default function CustomerDetailPage() {
|
||||
{/* DOCUMENTS */}
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Reviewing a customer means reading every document, so offer the
|
||||
whole set at once — each opens in its own tab. The loop is
|
||||
synchronous inside the click handler on purpose: that is what
|
||||
keeps the browser treating all of them as user-initiated. */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
disabled={documents.length === 0}
|
||||
onClick={() =>
|
||||
documents.forEach((d) => openFileInNewTab(d.id, d.name))
|
||||
}
|
||||
>
|
||||
Open all {documents.length > 0 && `(${documents.length})`}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
@@ -1316,9 +1344,7 @@ export default function CustomerDetailPage() {
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
@@ -1424,7 +1450,6 @@ export default function CustomerDetailPage() {
|
||||
onClose={() => setChangeRequestDoc(null)}
|
||||
/>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CompanyNationalityBadge,
|
||||
CompanyStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
ProfileChips,
|
||||
formatDate,
|
||||
} from "@/components/customers";
|
||||
@@ -142,6 +143,10 @@ export default function CustomersPage() {
|
||||
{c.name}
|
||||
</Text>
|
||||
<CompanyNationalityBadge nationality={c.nationality} />
|
||||
<ManualRegistrationBadge
|
||||
cooperative={c.cooperative}
|
||||
investorLicence={c.investorLicence}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
TIN {c.tin}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -13,7 +11,7 @@ import {
|
||||
FREIGHT_PERMS,
|
||||
} from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonStatusActions from "@/components/wagons/WagonStatusActions";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -43,11 +40,46 @@ import {
|
||||
type FleetListFilters,
|
||||
type FleetRecord,
|
||||
} from "@/services/fleet/fleet.service";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { DataTable, DataTableFooter } from "@edr/ui-common";
|
||||
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
|
||||
|
||||
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
|
||||
|
||||
const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
|
||||
|
||||
// trains/containers/cargoes have no server-side `listFilters` config (see
|
||||
// resources.ts) — they get a plain client-only Status filter instead, off a
|
||||
// fixed enum rather than "whatever status happens to exist in the currently
|
||||
// loaded rows" (which would create a circular dependency: filterDefs feeds
|
||||
// useFilters, which feeds the query that produces those rows).
|
||||
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
|
||||
{ value: "AVAILABLE", label: "Available" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "IN_SERVICE", label: "In service" },
|
||||
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
||||
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
||||
{ value: "DEACTIVATED", label: "Deactivated" },
|
||||
];
|
||||
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
|
||||
{ value: "AVAILABLE", label: "Available" },
|
||||
{ value: "LOADED", label: "Loaded" },
|
||||
{ value: "IN_TRANSIT", label: "In transit" },
|
||||
{ value: "MAINTENANCE", label: "Maintenance" },
|
||||
{ value: "DAMAGED", label: "Damaged" },
|
||||
];
|
||||
const CARGO_STATUS_OPTIONS: FilterOption[] = [
|
||||
{ value: "PENDING", label: "Pending" },
|
||||
{ value: "LOADED", label: "Loaded" },
|
||||
{ value: "IN_TRANSIT", label: "In transit" },
|
||||
{ value: "DELIVERED", label: "Delivered" },
|
||||
{ value: "UNLOADED", label: "Unloaded" },
|
||||
];
|
||||
const FALLBACK_STATUS_OPTIONS: Partial<Record<FleetResourceSlug, FilterOption[]>> = {
|
||||
trains: TRAIN_STATUS_OPTIONS,
|
||||
containers: CONTAINER_STATUS_OPTIONS,
|
||||
cargoes: CARGO_STATUS_OPTIONS,
|
||||
};
|
||||
|
||||
const FleetResourcePage = () => {
|
||||
const location = useLocation();
|
||||
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
|
||||
@@ -70,18 +102,9 @@ const FleetResourcePage = () => {
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
// Wagons and locomotives page in the database; the rest still list in full
|
||||
// and page in the browser (see `pagedHandlers` in fleet.service).
|
||||
const serverPaged = isFleetServerPaginated(slug);
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
// Registration date range. Server-side list filters (status/yard/train) are
|
||||
// applied by the API; this narrows what comes back, alongside search.
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
@@ -95,77 +118,6 @@ const FleetResourcePage = () => {
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
|
||||
if (!serverFilteredSlugs.includes(slug)) return undefined;
|
||||
const filters: FleetListFilters = {};
|
||||
const status = listFilterValues.status;
|
||||
const currentYardId = listFilterValues.currentYardId;
|
||||
const availability = listFilterValues.availability;
|
||||
const trainNumber = listFilterValues.trainNumber;
|
||||
const trainId = listFilterValues.trainId;
|
||||
if (status && status !== "ALL") {
|
||||
(filters as { status?: string }).status = status;
|
||||
}
|
||||
if (currentYardId && currentYardId !== "ALL") {
|
||||
filters.currentYardId = currentYardId;
|
||||
}
|
||||
if (availability && availability !== "ALL") {
|
||||
(filters as { availability?: string }).availability = availability;
|
||||
}
|
||||
if (trainNumber && trainNumber !== "ALL") {
|
||||
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
||||
}
|
||||
if (trainId && trainId !== "ALL") {
|
||||
filters.trainId = trainId;
|
||||
}
|
||||
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
|
||||
const wagonTypeId = listFilterValues.wagonTypeId;
|
||||
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
|
||||
}
|
||||
// The plain locomotives list has no server-side search — its page window
|
||||
// does, so the term is only sent on the paginated path.
|
||||
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
|
||||
filters.search = debouncedSearch.trim();
|
||||
}
|
||||
return filters;
|
||||
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
|
||||
|
||||
// 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.
|
||||
const pagedFilters = useMemo(
|
||||
(): FleetListFilters => ({
|
||||
...serverListFilters,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(dateFrom ? { createdFrom: dateFrom } : {}),
|
||||
...(dateTo ? { createdTo: dateTo } : {}),
|
||||
}),
|
||||
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
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());
|
||||
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||
api.wagonTypes.list.queryOptions(),
|
||||
);
|
||||
@@ -202,40 +154,8 @@ const FleetResourcePage = () => {
|
||||
enabled: slug === "wagons",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
setStatusFilter("ALL");
|
||||
setListFilterValues({});
|
||||
}, [slug, setPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (!hasStatusColumn || usesServerListFilters) return [];
|
||||
if (slug === "vehicles" || slug === "drivers") {
|
||||
return [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "INACTIVE", label: "Inactive" },
|
||||
];
|
||||
}
|
||||
const statuses = new Set(
|
||||
allRows
|
||||
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
|
||||
.filter(Boolean),
|
||||
);
|
||||
return [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...[...statuses].sort().map((status) => ({ value: status, label: status })),
|
||||
];
|
||||
}, [allRows, hasStatusColumn, usesServerListFilters, slug]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
|
||||
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
|
||||
@@ -291,25 +211,78 @@ const FleetResourcePage = () => {
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => {
|
||||
const dynamicOpts = filter.dynamicOptions
|
||||
? (dynamicOptions[filter.dynamicOptions] ?? [])
|
||||
: [];
|
||||
const staticOpts =
|
||||
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
|
||||
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
|
||||
return {
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...opts,
|
||||
],
|
||||
};
|
||||
});
|
||||
}, [config?.listFilters, listFilterValues, dynamicOptions]);
|
||||
// One pill per configured server list filter (status/yard/wagon type/train…),
|
||||
// built off `config.listFilters` — same source the old plain `<Select>` 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<string, unknown>;
|
||||
// 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<FleetRecord>[] => {
|
||||
if (!config) return [];
|
||||
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
showSearch={config.supportsSearch}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
aria-label="Created date range"
|
||||
placeholder="Created date range"
|
||||
value={[dateFrom, dateTo]}
|
||||
onChange={([from, to]) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={240}
|
||||
/>
|
||||
{listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
key={filter.key}
|
||||
aria-label={filter.label}
|
||||
placeholder={filter.data[0]?.label ?? filter.label}
|
||||
data={filter.data}
|
||||
value={filter.value}
|
||||
onChange={(value) => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
[filter.key]: value ?? "ALL",
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={200}
|
||||
searchable={filter.data.length > 8}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
<Group gap={4} wrap="wrap">
|
||||
<Text size="xs" fw={500} c="dimmed">Status:</Text>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={statusFilter === option.value ? "filled" : "outline"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
viewId={`fleet-${slug}`}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={(value) => setViewMode(value as FleetViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Table2 size={14} />
|
||||
<span>Table</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<LayoutGrid size={14} />
|
||||
<span>Cards</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
|
||||
/>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
@@ -696,18 +643,8 @@ const FleetResourcePage = () => {
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found`}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
pagination={dtPagination}
|
||||
tableOptions={dtTableOptions}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
|
||||
rows={pagedRows}
|
||||
status={tableStatus}
|
||||
emptyMessage={`No ${itemLabel} found`}
|
||||
pagination={pagination}
|
||||
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={dtTableOptions!.onPaginationChange!}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
|
||||
null,
|
||||
);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
// Yards only: which desks work at this yard (input to yard access scoping).
|
||||
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.slug === "yards" ? (
|
||||
<Tooltip label="Desks that work at this yard">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
onClick={() => setDesksYard(row.original)}
|
||||
>
|
||||
Desks
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
@@ -968,6 +984,21 @@ const RuleEngineResourcePage = () => {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<YardDesksModal
|
||||
opened={!!desksYard}
|
||||
onClose={() => setDesksYard(null)}
|
||||
readOnly={!canUpdateControls}
|
||||
yard={
|
||||
desksYard
|
||||
? {
|
||||
id: String(desksYard.id),
|
||||
code: String(desksYard.code ?? ""),
|
||||
label: String(desksYard.label ?? ""),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<RuleEngineFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
import { yardPositionsService } from "@/services/yardPositions.service";
|
||||
|
||||
interface YardDesksModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
yard: { id: string; code: string; label: string } | null;
|
||||
/** Read-only when the caller lacks the yards update permission. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const positionLabel = (
|
||||
name: { am?: string; en?: string } | null,
|
||||
fallback: string,
|
||||
) => name?.en?.trim() || name?.am?.trim() || fallback;
|
||||
|
||||
/**
|
||||
* Which desks staff a yard — the input to yard access scoping.
|
||||
*
|
||||
* Saving REPLACES the yard's whole set (the API's PUT is a replace), which is
|
||||
* why the control is a multi-select holding the complete list rather than
|
||||
* add/remove buttons.
|
||||
*/
|
||||
export function YardDesksModal({
|
||||
opened,
|
||||
onClose,
|
||||
yard,
|
||||
readOnly = false,
|
||||
}: YardDesksModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const positions = useQuery({
|
||||
queryKey: ["yard-positions", "positions"],
|
||||
queryFn: yardPositionsService.listPositions,
|
||||
enabled: opened,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const mapping = useQuery({
|
||||
queryKey: ["yard-positions", "yard", yard?.id],
|
||||
queryFn: () => yardPositionsService.listByYard(yard!.id),
|
||||
enabled: opened && !!yard?.id,
|
||||
});
|
||||
|
||||
// Reset to what the server holds whenever the modal opens on a new yard, so a
|
||||
// cancelled edit never leaks into the next one.
|
||||
useEffect(() => {
|
||||
if (mapping.data) setSelected(mapping.data.map((row) => row.positionId));
|
||||
}, [mapping.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => yardPositionsService.setForYard(yard!.id, selected),
|
||||
onSuccess: () => {
|
||||
toast.success("Yard desks updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["yard-positions"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Failed to update yard desks")),
|
||||
});
|
||||
|
||||
const options = (positions.data ?? []).map((position) => ({
|
||||
value: position.id,
|
||||
label: positionLabel(position.name, position.id.slice(0, 8)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Positions mapped here are the desks that work at this yard. Yard
|
||||
access scoping reads this mapping — a staff member acting on this
|
||||
desk is scoped to this yard.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{positions.isLoading || mapping.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<MultiSelect
|
||||
data={options}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
disabled={readOnly}
|
||||
label="Positions"
|
||||
placeholder={selected.length ? undefined : "Select positions"}
|
||||
description="Saving replaces the whole set — anything removed here loses this yard."
|
||||
searchable
|
||||
clearable
|
||||
hidePickedOptions
|
||||
maxDropdownHeight={280}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
loading={save.isPending}
|
||||
disabled={readOnly || mapping.isLoading}
|
||||
title={readOnly ? "You cannot edit yards" : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
containers: [
|
||||
{
|
||||
containerNumber,
|
||||
containerSize: containerSize ?? undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Container Type"
|
||||
placeholder="Select container size"
|
||||
value={containerSize}
|
||||
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
|
||||
data={[
|
||||
{ value: "20", label: "20 ft" },
|
||||
{ value: "40", label: "40 ft" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
|
||||
@@ -2776,6 +2776,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
||||
),
|
||||
|
||||
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||
"bookings",
|
||||
"referenceData",
|
||||
() => bookingsService.getReferenceData() as Promise<Freight.BookingReferenceData>,
|
||||
() => ["bookings", "reference-data"],
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||
bookingsService.remove(id),
|
||||
),
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface BookingListFilter {
|
||||
/** Bookings drawn down under this contract (contract detail's Shipments tab). */
|
||||
contractId?: string;
|
||||
freightType?: string;
|
||||
/** Service type (rule-engine service_types.id). */
|
||||
serviceTypeId?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
||||
@@ -171,6 +173,7 @@ export const bookingsService = {
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
@@ -205,6 +208,7 @@ export const bookingsService = {
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ContractListFilter {
|
||||
tab?: string;
|
||||
companyId?: string;
|
||||
freightType?: string;
|
||||
/** Service type (rule-engine service_types.id). */
|
||||
serviceTypeId?: string;
|
||||
tradeDirection?: string;
|
||||
contractKind?: string;
|
||||
paymentCurrency?: string;
|
||||
@@ -197,6 +199,7 @@ function buildListParams(filter?: ContractListFilter) {
|
||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.contractKind) params.contractKind = filter.contractKind;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
|
||||
@@ -25,6 +25,53 @@ export async function downloadBookingFile(
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a stored file in its own browser tab.
|
||||
*
|
||||
* Two things make this less trivial than an `<a target="_blank">`:
|
||||
* - `GET /files/:id` is authenticated, so the bytes have to come through the
|
||||
* axios client and be handed over as a blob URL (same reason as
|
||||
* {@link fetchViewableFile}).
|
||||
* - The tab therefore has to be opened *synchronously*, inside the click
|
||||
* gesture, and filled once the download resolves — a `window.open()` after an
|
||||
* `await` is blocked as a popup. That also means a loop over several
|
||||
* documents opens one tab each, all within the same gesture.
|
||||
*
|
||||
* `noopener` is deliberately not passed: it makes `window.open` return null, and
|
||||
* the handle is what lets us navigate the tab. `opener` is nulled instead.
|
||||
*/
|
||||
export function openFileInNewTab(id: string, filename: string): void {
|
||||
const tab = window.open("", "_blank");
|
||||
if (tab) {
|
||||
tab.opener = null;
|
||||
tab.document.title = filename;
|
||||
if (tab.document.body) {
|
||||
tab.document.body.textContent = `Opening ${filename}…`;
|
||||
}
|
||||
}
|
||||
void filesService.download(id).then(
|
||||
(blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (tab) tab.location.replace(url);
|
||||
// Popup blocked — fall back to a save, so the click still does something.
|
||||
else {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
// Revoking immediately would cancel the tab's own load of the URL.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (tab?.document.body) {
|
||||
tab.document.body.textContent = `Could not open ${filename}.`;
|
||||
}
|
||||
console.error(`Failed to open file ${id}`, error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
|
||||
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// NOTE: `auth/http`'s response interceptor already unwraps the API's
|
||||
// `{ success, data }` envelope, so `response.data` IS the payload here — a
|
||||
// second `.data` hop reads undefined and silently yields an empty list.
|
||||
|
||||
/** A desk mapped to a yard, joined to its IAM position for display. */
|
||||
export interface YardPositionRow {
|
||||
id: string;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardLabel: string;
|
||||
positionId: string;
|
||||
positionName: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
}
|
||||
|
||||
export interface SelectablePosition {
|
||||
id: string;
|
||||
name: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
unitKey: string | null;
|
||||
}
|
||||
|
||||
export interface MyYardScope {
|
||||
/** null = unrestricted (super admin or `yards:view_all`). */
|
||||
yardIds: string[] | null;
|
||||
unrestricted: boolean;
|
||||
/** False while the backend is still shadow-logging instead of denying. */
|
||||
enforced: boolean;
|
||||
}
|
||||
|
||||
export const yardPositionsService = {
|
||||
listByYard: async (yardId: string): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions`, {
|
||||
params: { yardId },
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
listPositions: async (): Promise<SelectablePosition[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/positions`);
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
myScope: async (): Promise<MyYardScope> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/my-yards`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the yard's whole desk set — send every position that should remain
|
||||
* mapped, not just the additions.
|
||||
*/
|
||||
setForYard: async (
|
||||
yardId: string,
|
||||
positionIds: string[],
|
||||
): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, {
|
||||
positionIds,
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
@@ -111,7 +111,11 @@ export interface CompanyChangeRequest {
|
||||
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges: DocumentChangeIntent[];
|
||||
note: string | null;
|
||||
/** Who filed the request — resolved from `iam.users`, null when unknown. */
|
||||
submittedByName: string | null;
|
||||
submittedAt: string | null;
|
||||
/** Who approved / rejected / sent it back. */
|
||||
reviewedByName: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -139,6 +143,8 @@ export interface CompanyRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
/** Who made the edit — resolved from `iam.users`, null when unknown. */
|
||||
actorName: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: string;
|
||||
@@ -228,6 +234,13 @@ export interface Company {
|
||||
* manager to check the owner against, and it holds no freight-forwarder role.
|
||||
*/
|
||||
cooperative?: boolean;
|
||||
/**
|
||||
* A foreign investor on an Ethiopian Investment Commission licence: eTrade
|
||||
* holds no record for its TIN, so every registration field below was typed by
|
||||
* the customer and verified by nobody. The reviewer is the check — compare
|
||||
* them against the investment licence on the Documents tab.
|
||||
*/
|
||||
investorLicence?: boolean;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
|
||||
@@ -1,250 +1,258 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
BarChart,
|
||||
Building2,
|
||||
ChartAreaIcon,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Globe,
|
||||
Settings,
|
||||
Users2,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/shared/common/ui/sidebar";
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
roles?: string[];
|
||||
/** Sidebar section this item is bucketed under. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Section render order; groups with no role-visible items are skipped.
|
||||
const GROUP_ORDER = [
|
||||
"Overview",
|
||||
"Organizations",
|
||||
"Content",
|
||||
"Records",
|
||||
"Configuration",
|
||||
"Archive",
|
||||
"System",
|
||||
];
|
||||
|
||||
export const AppMenuTabs = () => {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userRoles = user?.roles.map((role) => role.key) || [];
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "organizations",
|
||||
href: "/user-management/organizations",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "organizationAdmins",
|
||||
href: "/user-management/organization_admins",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "externalUsers",
|
||||
href: "/user-management/external_users",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/user_management-dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "userManagement",
|
||||
href: "/user-management/user_management",
|
||||
icon: <UsersRound className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "contentManagement",
|
||||
href: "/user-management/content-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "webManagement",
|
||||
href: "/user-management/web-management",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Bulk",
|
||||
href: "/user-management/bulk-upload",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Position",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "settings",
|
||||
href: "/user-management/organization-settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Add Site",
|
||||
href: "/user-management/add-site",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "migratedRecords",
|
||||
href: "/user-management/migrated-records-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Sector Reports",
|
||||
href: "/user-management/sector-reports",
|
||||
icon: <ChartAreaIcon className="h-4 w-4" />,
|
||||
roles: ["unit_admin", "admin", "organization_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archive-users",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Organizations",
|
||||
href: "/user-management/archived-organizations",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archives",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Units & Positions",
|
||||
href: "/user-management/archived",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "activityLog",
|
||||
href: "/user-management/activity_log",
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "setting",
|
||||
href: "/user-management/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "Letter Template",
|
||||
href: "/user-management/templates",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredMenu = menuItems.filter((item) =>
|
||||
item.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = filteredMenu.filter((item) => item.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group} className="pb-0">
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const label = t(`organization.${item.label}`, item.label);
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.href)}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
BarChart,
|
||||
Building2,
|
||||
ChartAreaIcon,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Globe,
|
||||
MapPin,
|
||||
Settings,
|
||||
Users2,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/shared/common/ui/sidebar";
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
roles?: string[];
|
||||
/** Sidebar section this item is bucketed under. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Section render order; groups with no role-visible items are skipped.
|
||||
const GROUP_ORDER = [
|
||||
"Overview",
|
||||
"Organizations",
|
||||
"Content",
|
||||
"Records",
|
||||
"Configuration",
|
||||
"Archive",
|
||||
"System",
|
||||
];
|
||||
|
||||
export const AppMenuTabs = () => {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userRoles = user?.roles.map((role) => role.key) || [];
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "organizations",
|
||||
href: "/user-management/organizations",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "organizationAdmins",
|
||||
href: "/user-management/organization_admins",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "externalUsers",
|
||||
href: "/user-management/external_users",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/user_management-dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "userManagement",
|
||||
href: "/user-management/user_management",
|
||||
icon: <UsersRound className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "contentManagement",
|
||||
href: "/user-management/content-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "webManagement",
|
||||
href: "/user-management/web-management",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Bulk",
|
||||
href: "/user-management/bulk-upload",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Position",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Locations",
|
||||
href: "/user-management/locations",
|
||||
icon: <MapPin className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "settings",
|
||||
href: "/user-management/organization-settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Add Site",
|
||||
href: "/user-management/add-site",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "migratedRecords",
|
||||
href: "/user-management/migrated-records-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Sector Reports",
|
||||
href: "/user-management/sector-reports",
|
||||
icon: <ChartAreaIcon className="h-4 w-4" />,
|
||||
roles: ["unit_admin", "admin", "organization_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archive-users",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Organizations",
|
||||
href: "/user-management/archived-organizations",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archives",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Units & Positions",
|
||||
href: "/user-management/archived",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "activityLog",
|
||||
href: "/user-management/activity_log",
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "setting",
|
||||
href: "/user-management/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "Letter Template",
|
||||
href: "/user-management/templates",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredMenu = menuItems.filter((item) =>
|
||||
item.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = filteredMenu.filter((item) => item.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group} className="pb-0">
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const label = t(`organization.${item.label}`, item.label);
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.href)}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
APIProvider,
|
||||
Map as GoogleMap,
|
||||
Marker,
|
||||
type MapMouseEvent,
|
||||
} from "@vis.gl/react-google-maps";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import type {
|
||||
Location,
|
||||
LocationPayload,
|
||||
LocationType,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { useLocations } from "@/user-management/hooks/useLocations";
|
||||
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
/** Addis Ababa — where every EDR location is within a map pan. */
|
||||
const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 };
|
||||
|
||||
const NO_PARENT = "__none__";
|
||||
|
||||
const numeric = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`);
|
||||
|
||||
const locationSchema = z.object({
|
||||
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().trim().optional(),
|
||||
code: z.string().trim().min(1, "Code is required"),
|
||||
locationTypeId: z.string().uuid("Location type is required"),
|
||||
parentId: z.string().optional(),
|
||||
latitude: numeric("Latitude"),
|
||||
longitude: numeric("Longitude"),
|
||||
area: numeric("Area"),
|
||||
boundaryJson: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((v) => {
|
||||
if (!v) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
return typeof parsed === "object" && parsed !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Boundary must be a JSON object"),
|
||||
});
|
||||
|
||||
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||
|
||||
interface LocationFormProps {
|
||||
mode: "create" | "edit";
|
||||
location?: Location;
|
||||
locationTypes: LocationType[];
|
||||
/** Every location, for the parent picker — the API has no filter endpoint. */
|
||||
allLocations: Location[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function LocationForm({
|
||||
mode,
|
||||
location,
|
||||
locationTypes,
|
||||
allLocations,
|
||||
onSuccess,
|
||||
}: LocationFormProps) {
|
||||
const localizedName = useLocalizedName();
|
||||
const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } =
|
||||
useLocations();
|
||||
|
||||
const form = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: {
|
||||
nameAm: location?.names?.am ?? "",
|
||||
nameEn: location?.names?.en ?? "",
|
||||
code: location?.code ?? "",
|
||||
locationTypeId: location?.locationTypeId ?? "",
|
||||
parentId: location?.parentId ?? NO_PARENT,
|
||||
latitude: location?.latitude ?? "",
|
||||
longitude: location?.longitude ?? "",
|
||||
area: location?.area ?? "",
|
||||
boundaryJson: location?.boundaryJson
|
||||
? JSON.stringify(location.boundaryJson, null, 2)
|
||||
: "",
|
||||
},
|
||||
});
|
||||
|
||||
const [lat, lng] = [form.watch("latitude"), form.watch("longitude")];
|
||||
const pin =
|
||||
lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng))
|
||||
? { lat: Number(lat), lng: Number(lng) }
|
||||
: null;
|
||||
|
||||
const dropPin = (event: MapMouseEvent) => {
|
||||
const point = event.detail.latLng;
|
||||
if (!point) return;
|
||||
form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true });
|
||||
form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true });
|
||||
};
|
||||
|
||||
// ponytail: self only, not descendants — the API accepts any parentId, so a
|
||||
// deep cycle (A → B → A) is still possible. Walk the chain here if it bites.
|
||||
const parentOptions = allLocations.filter((item) => item.id !== location?.id);
|
||||
|
||||
const submit = (values: LocationFormValues) => {
|
||||
const payload: LocationPayload = {
|
||||
names: {
|
||||
am: values.nameAm,
|
||||
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||
},
|
||||
code: values.code,
|
||||
locationTypeId: values.locationTypeId,
|
||||
parentId:
|
||||
values.parentId && values.parentId !== NO_PARENT
|
||||
? values.parentId
|
||||
: undefined,
|
||||
latitude: values.latitude || undefined,
|
||||
longitude: values.longitude || undefined,
|
||||
area: values.area || undefined,
|
||||
boundaryJson: values.boundaryJson
|
||||
? (JSON.parse(values.boundaryJson) as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
createLocation(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (location) {
|
||||
updateLocation(
|
||||
{ id: location.id, payload },
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="አዲስ አበባ" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Addis Ababa" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="LOC-001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locationTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location Type *</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{locationTypes.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{localizedName(type.names)} · L{type.level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parentId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Parent Location</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No parent (top level)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PARENT}>
|
||||
No parent (top level)
|
||||
</SelectItem>
|
||||
{parentOptions.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{localizedName(item.names)} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Coordinates</FormLabel>
|
||||
{GOOGLE_MAPS_API_KEY ? (
|
||||
<div className="h-64 w-full overflow-hidden rounded-md border">
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={pin ?? DEFAULT_CENTER}
|
||||
defaultZoom={pin ? 12 : 6}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
onClick={dropPin}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{pin ? <Marker position={pin} /> : null}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</div>
|
||||
) : (
|
||||
// Name the missing variable rather than rendering a dead grey box.
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Map picker unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||
not set. Type the coordinates below instead.
|
||||
</p>
|
||||
)}
|
||||
{GOOGLE_MAPS_API_KEY ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click the map to drop a pin, or type the values.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="9.032000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="38.746900" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Area</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="1000.25" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="boundaryJson"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Boundary (GeoJSON)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder='{"type":"Polygon","coordinates":[]}'
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isCreatingLocation || isUpdatingLocation}
|
||||
>
|
||||
{mode === "create" ? "Create Location" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import type {
|
||||
LocationType,
|
||||
LocationTypePayload,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||
|
||||
const locationTypeSchema = z.object({
|
||||
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().trim().optional(),
|
||||
code: z.string().trim().min(1, "Code is required"),
|
||||
description: z.string().trim().optional(),
|
||||
// Level is the hierarchy depth (1 = country, 2 = region, …). Server takes a
|
||||
// number, so an empty string would post NaN.
|
||||
level: z.coerce.number().int().min(1, "Level must be 1 or greater"),
|
||||
});
|
||||
|
||||
export type LocationTypeFormValues = z.input<typeof locationTypeSchema>;
|
||||
|
||||
interface LocationTypeFormProps {
|
||||
mode: "create" | "edit";
|
||||
locationType?: LocationType;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function LocationTypeForm({
|
||||
mode,
|
||||
locationType,
|
||||
onSuccess,
|
||||
}: LocationTypeFormProps) {
|
||||
const {
|
||||
createLocationType,
|
||||
updateLocationType,
|
||||
isCreatingLocationType,
|
||||
isUpdatingLocationType,
|
||||
} = useLocationTypes();
|
||||
|
||||
const form = useForm<LocationTypeFormValues, unknown, z.output<typeof locationTypeSchema>>({
|
||||
resolver: zodResolver(locationTypeSchema),
|
||||
defaultValues: {
|
||||
nameAm: locationType?.names?.am ?? "",
|
||||
nameEn: locationType?.names?.en ?? "",
|
||||
code: locationType?.code ?? "",
|
||||
description: locationType?.description ?? "",
|
||||
level: locationType?.level ?? 1,
|
||||
},
|
||||
});
|
||||
|
||||
const submit = (values: z.output<typeof locationTypeSchema>) => {
|
||||
const payload: LocationTypePayload = {
|
||||
names: {
|
||||
am: values.nameAm,
|
||||
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||
},
|
||||
code: values.code,
|
||||
description: values.description || undefined,
|
||||
level: values.level,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
createLocationType(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (locationType) {
|
||||
updateLocationType(
|
||||
{ id: locationType.id, payload },
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ከተማ" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="City" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="CITY" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Level *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} placeholder="City level location" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isCreatingLocationType || isUpdatingLocationType}
|
||||
>
|
||||
{mode === "create" ? "Create Location Type" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user