diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,362 @@ # EDR Platform — Developer Guide +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes +you to the right module or page without a repo-wide grep. + ## Overview -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries. +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. ## Apps -| App | Package name | Purpose | Port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | +The two domains are **not built the same way**. Check which stack you are in before +copying a pattern across: -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs. +| App | Package name | Stack | Default port | +| ------------------------------ | --------------------------- | ---------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | +| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | + +Those are the **fallbacks compiled into the code**, not what you will be running. Every +port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite +apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by +the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and +`./wt ports` for who currently holds what. + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +`apps/edr-gps-tracker/` is a separate service with its own `.env.example`. ## Packages -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | +| Package | Location | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | +| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme | +| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema | +| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations | +| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) | +| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config | + +The three `config/*` packages are nested one level deeper than the rest — `packages/config` +itself is not a package. + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. ## Commands -| Command | Description | -| -------------------- | ---------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Run only freight API + web | -| `pnpm dev:passenger` | Run only passenger API + web | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | +| Command | Description | +| ----------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | +| `pnpm lint` | **Does not work** — see below | -## Standards +**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so +`turbo run lint` dies with `eslint: not found` even though every package declares a +`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency, +tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is +"lint clean". -- **TypeScript strict mode** is enabled in every package and app. -- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. -- **Conventional commits** are enforced via commitlint on every commit. -- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside). +`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to +nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass +`--config` explicitly there. + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`), + freight tables in `freight`. +- `psql` is not installed on the dev machine. To query the database, use the `edr-db` + skill (below) or write a short Node script using `pg` and run it from + `apps/edr-freight-api`, where `pg` resolves. + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through migrations. - **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. -- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. -- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. -- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. -- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class. -- **Controllers** never contain business logic. +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. -## Auth +## Architecture -Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo. +### NestJS module shape -When auth integration is needed, use placeholder TODO comments: +`module → controller → service → repository`, with `entities/` and `dto/` alongside. +`docs/MAP.md` lists the ~60 freight modules grouped by domain. -- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth` -- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one` +### Data access — the real model -The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet. +There are two sanctioned ways to read and write, and you must pick the right one: -## Port Assignments +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. -- `edr-freight-api`: 3001 -- `edr-freight-web/portal`: 5173 -- `edr-freight-web/backoffice`: 5183 -- `edr-passenger-api`: 3002 -- `edr-payment-api`: 3003 -- `edr-passenger-web/portal`: 5174 -- `edr-passenger-web/backoffice`: 5184 +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: -## Database Layout +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). -- `postgres-freight` (port 5433): database `edr_freight` — freight API only. -- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. -- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). -- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma** +(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side. + +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. +- Consequences you must design for: + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. +- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds + 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp + and breaks ties non-deterministically. Check before adding one: + + ```bash + ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d + ``` + + The prefix must be unused *and* higher than the newest recorded row. Note the + `freight.migrations` table has far more rows (~309) than this folder has files — most + come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from + `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** — + `backoffice` for employees, `portal` for customers. Without it the API 403s with + "Missing or unrecognized x-client-app header". Browsers send it; curl must add it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 — + check the installed version before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. ## Adding a new module to a NestJS app -1. Create `modules//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files. +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. 2. The entity extends `BaseEntity` from `@edr/api-common`. 3. The repository extends `BaseRepository` from `@edr/api-common`. 4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger. +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. 6. Register the module in the app's `app.module.ts`. ## Adding a new shared component to `@edr/ui-common` 1. Create `src/components//.tsx` and `src/components//index.ts`. 2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default). +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a + fully green suite is not the bar — but confirm that for yourself rather than assuming it, + then run the specs covering what you touched and confirm you introduced no new failure. +5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically + (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the + type-check is your only unused-code signal. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | +| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` | +| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -1,313 +0,0 @@ -# EDR Platform — Developer Guide - -> This file is the contract. If something here contradicts the code, the code is the -> truth and this file is a bug — fix it in the same PR. - -## Overview - -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight -Management and Passenger Management applications, a payment microservice, plus shared -types, NestJS utilities, and React component libraries. - -The freight domain is the largest and most active area. Its core flow is: -**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload -→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** -Fees (storage, demurrage, double handling, truck detention) and allocation rules -(warehouse/yard/zone) hang off the warehouse stage. - -## Apps - -| App | Package name | Purpose | Default port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | - -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. -Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace -packages (see `pnpm-workspace.yaml`). - -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. - -## Packages - -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | - -**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a -type in `packages/types/src` changes nothing for consumers until you rebuild: - -```bash -pnpm turbo build --filter=@edr/types -``` - -If a type-check fails on a field you just added to `@edr/types`, this is why. - -## Commands - -| Command | Description | -| --------------------------- | ---------------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Freight API + portal + backoffice | -| `pnpm dev:freight:api` | Freight API only | -| `pnpm dev:freight:portal` | Freight portal only | -| `pnpm dev:freight:backoffice` | Freight backoffice only | -| `pnpm dev:passenger` | Passenger API + web | -| `pnpm dev:payment` | Payment API | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests (turbo) | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | - -Prefer targeted turbo filters over whole-repo runs — they are minutes faster: - -```bash -pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice -``` - -`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, -gate-pass scenarios). Read the script before running one; several write real rows. - -## Environment & database - -- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and - no port `5433`/`5434` is published anywhere in the repo. -- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, - `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a - remote database. -- The connection sits behind a **connection pooler**. Do **not** pass - `extra.options: '-c search_path=…'` — the pooler rejects it with - `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied - per-connection in a pool `connect` handler instead. See - `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. -- Each app owns its own database. **No cross-database joins**; cross-domain data flows - through API calls or message queues. -- `psql` is not installed on the dev machine. To query the database, write a short Node - script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). - -## Hard rules - -These are non-negotiable. Everything else is a strong default. - -- **pnpm only.** Never run `npm install` or `yarn`. -- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not - reach for `any` to make an error go away. -- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` - in every config and it has already corrupted this database twice (see *Migrations*). - All schema changes go through TypeORM migrations. -- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, - `deletedAt` (soft delete). -- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); - TypeScript properties are `camelCase`. -- **Controllers contain no business logic.** They validate, delegate, and shape the response. -- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. -- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. -- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and - offer the safe version. - -## Architecture - -### NestJS module shape - -`module → controller → service → repository`, with `entities/` and `dto/` alongside. - -### Data access — the real model - -There are two sanctioned ways to read and write, and you must pick the right one: - -1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from - `@edr/api-common`. Services inject the repository class, never `Repository` directly. -2. **Read projections, queue endpoints, cross-table reports → raw SQL** via - `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. - -Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. -It carries one obligation: - -> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** -> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through -> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). - -Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, -so they join the caller's transaction. - -**Never do slow I/O inside a database transaction.** Queue the work and fan it out after -commit. An SMS awaited inside a transaction once held capacity locks open for the whole -gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to -no timeout and will wait forever. - -### Migrations - -Migrations are the most dangerous surface in this repo. Two production-grade incidents have -already come from it. - -- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate - one-shot step, via the Dockerfile's `migration` build target (`docker build --target - migration`), with `migrationsTransactionMode: 'each'`. - - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it - (`docker run --rm --env-file ...`) *before* building/deploying the app image. - - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and - `freight-api-e2e` depends on it (`condition: service_completed_successfully`). - - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run - migrations yourself before `docker compose up freight-api`, e.g. - `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` - then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't - use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled - output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. - It silently applies zero freight migrations while exiting 0. -- Consequences you must design for: - - A watch-mode hot reload does **not** re-run migrations. If you add a column that new - code reads, apply it to the dev database yourself (idempotently) or fully restart. - - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a - hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never - notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own - `forFeature()` registrations), but the standalone migration `DataSource` - (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing - entity throws `Entity metadata for X#y was not found` at `initialize()`, before a - single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate - for this to break again** — diff the package's entity classes against `iamEntities` - when bumping it. -- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or - more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before - adding one, check the filename prefix is unused *and* higher than the newest recorded row. -- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and - backfills guarded by `WHERE col IS NULL`. -- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` - was recorded in `migrations` while its column was absent — it had been dropped out of band. - TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. -- **A repair migration's `down()` should be a no-op.** Reverting a repair must not - re-introduce the outage it fixed. - -### Auth - -Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. - -- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. -- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. -- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. -- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. - Add a permission there before referencing it. -- IAM has its own migrations, run ahead of freight migrations from the same data source, and - its own CLI scripts (`iam:migration:run`, `iam:seed:run`). - -Ownership checks are separate from permission checks. A staff user passes -`hasFreightPermission`; a customer must additionally pass an ownership assertion such as -`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. - -## Frontend conventions - -- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version - before copying a snippet. -- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the - freight web apps. Prefer it over re-implementing a component. -- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` - delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no - `.message` and degrades to `"Request failed with status code 400"`. Use - `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches - keep the synchronous version — their bodies are already parsed JSON. -- Server-side guards must be reflected in the UI. If the API will reject the action, the - button should be disabled, hidden, or explain the blocker — not fire and surface a 400. -- Prefer disabling a control with a visible reason over silently hiding it. - -## Notifications - -In-app notifications resolve recipients from the company's **linked portal users**. If a -company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. -SMS and email still send, because they address the company's phone and email directly. Check -this before debugging a "missing notification". - -## PDF generation - -Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled -generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than -assume a headless browser exists. - -## Adding a new module to a NestJS app - -1. Create `modules//` with `entities/`, `dto/`, and the four - `.{module,controller,service,repository}.ts` files. -2. The entity extends `BaseEntity` from `@edr/api-common`. -3. The repository extends `BaseRepository` from `@edr/api-common`. -4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. -6. Register the module in the app's `app.module.ts`. - -## Adding a new shared component to `@edr/ui-common` - -1. Create `src/components//.tsx` and `src/components//index.ts`. -2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface - (named-exported alongside the default). - -## Definition of done - -A change is done when **all** of these hold. State explicitly which you ran. - -1. **It type-checks.** `pnpm turbo type-check --filter=` passes. - If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. -2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the - dev database without error. -3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds - something the new code reads — applied to the dev database, since watch mode will not run it. -4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, - so a fully green suite is not the bar. Run the specs covering what you touched and confirm - you introduced no new failure. -5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these - automatically (see below), so run them yourself. -6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the - endpoint, or ran the query. If you could not, say so plainly. -7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in - the summary. Never describe unverified work as done. - -### Hooks do not run - -`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed -at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, -`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever -fire.** Nothing validates your commit message or formats your staged files. Run the checks by -hand; do not assume the hook caught it. - -## Known traps - -| Trap | What happens | What to do | -| --- | --- | --- | -| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | -| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | -| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | -| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | -| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | -| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | -| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | - -## Project skills - -Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: - -| Skill | Use for | -| --- | --- | -| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | -| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | -| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | - -## Working style - -- **Verify before asserting.** Read the code or query the database. Do not infer behaviour - from a filename. -- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the - trade-off before changing files. -- **Small, reviewable commits**, one logical change each, conventional message. -- **Branch from `dev`; PRs target `dev`.** -- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..bf8184833 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1141,16 +1142,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1559,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 +1597,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 +1613,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 6bcd21f08..d3a556f50 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -function pickUserName(user: IamUserRow): string | null { - const { name } = user; - if (typeof name === 'string' && name.trim()) return name.trim(); - if (name && typeof name === 'object') { - const localized = - name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); - if (localized?.trim()) return localized.trim(); - } - return user.username?.trim() || user.email?.trim() || null; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 24be04f66..380a62f02 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2497,6 +2497,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 65c4f25e3..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -20,11 +20,10 @@ import { FileX2, } from "lucide-react"; import { useState } from "react"; -import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.requestChangeRequestChanges.mutationOptions(), ); - const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; @@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { - - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index db57819d5..9cbc0e5e0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -3,34 +3,28 @@ import { ActionIcon, Box, Card, - Group, - Select, Stack, Text, - TextInput, ThemeIcon, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; -import { bookingsService } from "@/services/bookings.service"; +import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters"; /** * Operations "Clearance Documents" hub — the worklist for self-clearance @@ -43,16 +37,14 @@ import { const PAGE_SIZE = 10; /** - * Status filter options (values = `statuses` param). FULLY_EXECUTED is the - * post-approval status of intercity (domestic) bookings — kept in the list as - * history, otherwise an approved intercity row vanishes from the hub. + * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of + * intercity (domestic) bookings, kept in as history so an approved intercity + * row doesn't just vanish. Sent whenever the Status pill has no narrower pick. */ -const BOOKING_STATUS_OPTIONS = [ - { - value: - "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", - label: "All statuses", - }, +const DEFAULT_STATUSES = + "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED"; + +const STATUS_OPTIONS = [ { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "CLEARANCE_READY", label: "Clearance ready" }, @@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [ { value: "CUSTOMER", label: "Customer" }, ]; -function startOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x.toISOString(); -} - -function endOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x.toISOString(); -} - export default function ClearanceDocumentsPage() { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [bookingStatuses, setBookingStatuses] = useState( - BOOKING_STATUS_OPTIONS[0].value, - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState(null); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [customerKindFilter, setCustomerKindFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); - const search = debouncedQuery.trim() || undefined; + const filterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS, + // No pick ⇒ no `statuses` param at all; the query fills in + // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row. + toParams: ({ v }) => ({ statuses: v[0] }), + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, + options: filterOptions(TRADE_DIRECTION_OPTIONS), + }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS }, + { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, + { + key: "created", + label: "Created", + type: "date", + toParams: dateRangeParams("createdFrom", "createdTo"), + }, + ], + [filterOptions], + ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); - }, [setPagination]); + const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE }); - const page = pagination.pageIndex + 1; + const filter: BookingListFilter = useMemo( + () => ({ + ...(controls.params as unknown as BookingListFilter), + // Self-clearance instances carry bookingType=ONE_TIME whatever their + // contract kind, so customsClearingEnabled=false + the status scope + // above are what isolate exactly this worklist. + customsClearingEnabled: "false", + statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES, + }), + [controls.params], + ); const bookingsQuery = useQuery({ - queryKey: [ - "clearance-documents", - "bookings", - bookingStatuses, - directionFilter, - freightTypeFilter, - ownershipFilter, - customerKindFilter, - createdFrom, - createdTo, - page, - search, - ], - queryFn: () => - // Self-clearance instances carry bookingType=ONE_TIME whatever their - // contract kind, so customsClearingEnabled=false + the three per-booking - // clearance statuses are what isolate exactly this worklist. - bookingsService.list({ - statuses: bookingStatuses, - customsClearingEnabled: "false", - page, - pageSize: PAGE_SIZE, - search, - ...(directionFilter ? { tradeDirection: directionFilter } : {}), - ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), - ...(ownershipFilter - ? { isGovernment: ownershipFilter as "true" | "false" } - : {}), - ...(customerKindFilter - ? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" } - : {}), - ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), - ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), - }), + queryKey: ["clearance-documents", "bookings", filter], + queryFn: () => bookingsService.list(filter), placeholderData: keepPreviousData, }); @@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() { ); const total = bookingsQuery.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const showEmpty = !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; const tableStatus = bookingsQuery.isLoading @@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 130 }} - aria-label="Filter by direction" - /> - { - setCustomerKindFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 140 }} - aria-label="Filter by booked by" - /> - ` row + // read, just reshaped into FilterDefs. Falls back to a plain client-only + // Status filter for the 3 slugs with no server-side list filters at all. + const filterDefs: FilterDef[] = useMemo(() => { + const dateDef: FilterDef = { + key: "created", + label: "Registered", + type: "date", + secondary: true, + toParams: dateRangeParams("createdFrom", "createdTo"), + }; + if (config?.listFilters?.length) { + return [ + ...config.listFilters.map((filter): FilterDef => ({ + key: filter.key, + label: filter.label, + type: "enum", + multiple: false, + options: filter.dynamicOptions + ? (dynamicOptions[filter.dynamicOptions] ?? []) + : (filter.options ?? []), + })), + dateDef, + ]; + } + const fallback = FALLBACK_STATUS_OPTIONS[slug]; + return fallback + ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef] + : [dateDef]; + }, [config, dynamicOptions, slug]); + + const controls = useFilters(filterDefs, { pageSize: 10 }); + + // On the server-paged path the page window, the search and the registration + // date range are all resolved by the API — nothing is filtered client-side. + // `controls.params` already carries every filter's mapped param name (status/ + // currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to + // createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter + // object; the unpaged one is the same minus pagination and the date range + // (which stays client-only for the non-server-paged slugs, see below). + const serverListFilters = useMemo((): FleetListFilters | undefined => { + if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined; + const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params; + return rest as FleetListFilters; + }, [slug, controls.params]); + + const pagedFilters = useMemo( + (): FleetListFilters => controls.params as unknown as FleetListFilters, + [controls.params], + ); + + const listQuery = useQuery({ + ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + enabled: !serverPaged, + }); + const pagedQuery = useQuery({ + ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), + enabled: serverPaged, + placeholderData: keepPreviousData, + }); + + const activeQuery = serverPaged ? pagedQuery : listQuery; + const { isLoading, isError, error } = activeQuery; + const allRows = useMemo( + () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), + [serverPaged, pagedQuery.data, listQuery.data], + ); + const create = useMutation(api.fleet.create.mutationOptions()); + const update = useMutation(api.fleet.update.mutationOptions()); + const remove = useMutation(api.fleet.remove.mutationOptions()); + const purge = useMutation(api.fleet.purge.mutationOptions()); useEffect(() => { registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); @@ -349,16 +322,22 @@ const FleetResourcePage = () => { // The API already applied every filter and cut the page — re-filtering here // would drop rows the server deliberately returned. if (serverPaged) return allRows; - const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; // The date range applies even when the API already filtered the list — // it is not one of the server-side filters. - if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; - if (usesServerListFilters) return true; - if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { + const created = controls.values.created; + if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) { return false; } + // Every other filter (status/yard/wagon type/…) was already applied + // server-side for these slugs — re-checking here against a plain field + // equality would be wrong for one (a wagon's "trainNumber" filter + // matches either of two DIFFERENT columns server-side, not one). + if (usesServerListFilters) return true; + const status = controls.values.status; + if (status && String(record.status ?? "") !== status.v[0]) return false; + const term = controls.searchText.trim().toLowerCase(); if (!term) return true; return config.searchKeys.some((key) => String(record[key] ?? "") @@ -366,19 +345,23 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]); + }, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]); const totalCount = serverPaged ? (pagedQuery.data?.meta.total ?? 0) : filteredRows.length; const pageCount = serverPaged ? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1) - : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + : Math.max(1, Math.ceil(filteredRows.length / controls.pageSize)); const pagedRows = useMemo(() => { if (serverPaged) return filteredRows; - const start = pagination.pageIndex * pagination.pageSize; - return filteredRows.slice(start, start + pagination.pageSize); - }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]); + const start = (controls.page - 1) * controls.pageSize; + return filteredRows.slice(start, start + controls.pageSize); + }, [filteredRows, controls.page, controls.pageSize, serverPaged]); + + // Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid + // (not a DataTable) just needs the raw pieces out of it below. + const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount); const columns = useMemo((): ColumnDef[] => { if (!config) return []; @@ -604,77 +587,41 @@ const FleetResourcePage = () => { - - { - setDateFrom(from); - setDateTo(to); - }} - presets={getDateRangePresets()} - clearable - size="sm" - radius="lg" - w={240} - /> - {listFilterSelects ? ( - - {listFilterSelects.map((filter) => ( -