This commit is contained in:
Nathnael
2026-08-17 07:49:12 +00:00
parent aae0066a5d
commit 339996d27f
3 changed files with 420 additions and 377 deletions

388
CLAUDE.md
View File

@@ -1,102 +1,362 @@
# EDR Platform — Developer Guide # 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 ## 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 ## Apps
| App | Package name | Purpose | Port | The two domains are **not built the same way**. Check which stack you are in before
| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- | copying a pattern across:
| `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 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 ## Packages
| Package | Purpose | | Package | Location | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------- | | ----------------------- | ----------------------------- | ------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums | | `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | | `@edr/api-common` | `packages/api-common` | 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` | `packages/ui-common` | Shared React components and theme |
| `@edr/ui-common` | Shared React components and theme | | `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | | `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
| `@edr/tsconfig` | Shared TypeScript configurations | | `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
| `@edr/prettier-config` | Shared Prettier configuration | | `@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 ## Commands
| Command | Description | | Command | Description |
| -------------------- | ---------------------------------- | | ----------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies | | `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode | | `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Run only freight API + web | | `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:passenger` | Run only passenger API + web | | `pnpm dev:freight:api` | Freight API only |
| `pnpm build` | Build every package and app | | `pnpm dev:freight:portal` | Freight portal only |
| `pnpm test` | Run all tests | | `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm lint` | Lint everything | | `pnpm dev:passenger` | Passenger API + web |
| `pnpm type-check` | Type-check every package | | `pnpm dev:payment` | Payment API |
| `pnpm format` | Format all files with Prettier | | `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 format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
- **Conventional commits** are enforced via commitlint on every commit. `--config` explicitly there.
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
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** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. - **All entities** extend `BaseEntity` from `@edr/api-common` `createdAt`, `updatedAt`,
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. `deletedAt` (soft delete).
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. - **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. TypeScript properties are `camelCase`.
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class. - **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Controllers** never contain business logic. - **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` ### Data access — the real model
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
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 Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
- `edr-freight-web/portal`: 5173 It carries one obligation:
- `edr-freight-web/backoffice`: 5183
- `edr-passenger-api`: 3002
- `edr-payment-api`: 3003
- `edr-passenger-web/portal`: 5174
- `edr-passenger-web/backoffice`: 5184
## 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. Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. so they join the caller's transaction.
- `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. **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 ## 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`. 2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` 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). 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`. 6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common` ## Adding a new shared component to `@edr/ui-common`
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`. 1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
2. Export from `src/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.

View File

@@ -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.

96
docs/MAP.md Normal file
View File

@@ -0,0 +1,96 @@
# Repo map — start here
Routing table for "where does X live". Read this before a repo-wide grep. Paths are from
the repo root. The rules and traps are in [`../CLAUDE.md`](../CLAUDE.md); this file only
answers *where*.
## Pick your stack first
| If you are working on… | Code lives in | Stack |
| --------------------------------- | --------------------------------- | -------------------- |
| Freight API / business logic | `apps/edr-freight-api/src` | NestJS + TypeORM |
| Freight customer UI | `apps/edr-freight-web/portal` | React + Vite + Mantine v9 |
| Freight staff UI | `apps/edr-freight-web/backoffice` | React + Vite + Mantine v9 |
| Passenger API | `apps/edr-passenger-api` | NestJS + **Prisma** |
| Passenger UI | `apps/edr-passenger-web/*` | **Next.js** |
| Payments (intents, webhooks) | `apps/edr-payment-api` | NestJS + TypeORM |
| Gateway integrations | `packages/payment-providers` | — |
| A shared type or enum | `packages/types/src` | rebuild after editing |
| A shared React component | `packages/ui-common/src` | — |
| A Nest decorator/filter/base class| `packages/api-common/src` | — |
## Freight API entry points
| File | What it is |
| --- | --- |
| `src/main.ts` | Boot, port (`PORT`, falls back to 3001), global pipes |
| `src/app.module.ts` | Every module is registered here — the index of the API |
| `src/config/database.config.ts` | Connection, pooler `search_path` handling, `iamEntities` list |
| `src/data-source.ts` | Standalone DataSource used by migrations only (no `autoLoadEntities`) |
| `src/migrations/` | TypeORM migrations (39 files; check for timestamp clashes) |
| `src/seed/freight-permissions.registry.ts` | Every freight permission; declare before use |
| `src/scripts/` | One-off and `seed:*` scripts — several write real rows |
## Freight modules by domain
All under `apps/edr-freight-api/src/modules/`.
| Domain | Modules |
| --- | --- |
| **Booking & commercial** | `bookings` `contracts` `contract-templates` `consignments` `cargoes` `companies` `user-trade-access` `transit-agents` `shipping-lines` |
| **Warehouse & yard** | `warehouses` `facilities` `container-management` |
| **Rail operations** | `trains` `train-schedules` `train-scheduling` `train-sets` `wagons` `wagon-types` `locomotives` `routes` `scheduling` `scheduling-reschedule` `interchange-documents` |
| **Road / first & last mile** | `first-mile` `last-mile` `last-mile-requests` `drivers` `vehicles` `truck-types` `fleet-history` `fuel` `maintenance` `gps-tracking` `tracking` |
| **Money** | `billing` `payment` `exchange-settings` |
| **Identity & access** | `auth` `otp` `verifayda` `audit` |
| **Documents & files** | `files` `file-upload-settings` `signatures` `stamp-settings` `logo-settings` `minio` |
| **Comms** | `notifications` `notification-inbox` `support-chat` `support-content` |
| **Ops & admin** | `backoffice` `overview` `reports` `dropdown-settings` `rule-engine` `compliance` `procurement` `incidents` `import-operations` `eims` `ai` `health` |
Each module follows `module → controller → service → repository`, with `entities/` and
`dto/` alongside.
## Freight web
Pages live in `src/pages/`, roughly mirroring the API domains.
- **portal** (customer): `bookings` `contracts` `consignments` `billing` `payments`
`tracking` `accounts` `customers` `shipping-line` `support` `settings`, plus
`MyPortalPage/`, `MySignaturePage.tsx`, `EDRFreightLandingPage.tsx`.
- **backoffice** (staff): `bookings` `contracts` `contract_templates` `consignments`
`customers` `billing` `invoices` `documents` `fleet` `dashboard` `admin` `configuration`
`auth` `ai`, plus many single-file pages (`AuditLogsPage.tsx`, `ActivityLogPage.tsx`,
`BulkUploadPage.tsx`, `ContentManagementPage.tsx`, …).
Shared components and theme come from `@edr/ui-common` — check there before writing one.
## Tests
| Suite | Location | Run with |
| --- | --- | --- |
| Unit / spec | beside the code, `*.spec.ts` | `pnpm --filter @edr/freight-api test` |
| Freight e2e (Cypress, containerized) | `e2e/freight` | `pnpm e2e:freight:ci` |
| Passenger e2e | `e2e/` + `e2e/run.sh` | `pnpm test:e2e:passenger` |
| UI e2e (Playwright) | `e2e-ui/` | `pnpm test:e2e:ui` |
| Integration | `integration/` | `pnpm it:up`, `pnpm it:test` |
The freight e2e stack has no host Xvfb — use `ci` (containerized), not `run`/`open`.
Its compose project name is fixed (`edr-freight-e2e`), so only one can run on this
machine at a time.
## Documents
| Doc | Covers | Trust |
| --- | --- | --- |
| `../CLAUDE.md` | The contract: rules, traps, definition of done | Current — fix in the same PR if wrong |
| `docs/TESTING.md` | Test strategy | — |
| `docs/e2e-test-matrix.md`, `docs/ui-e2e-test-matrix.md` | Coverage matrices | — |
| `docs/ISSUES.md`, `docs/SOLUTIONS.md` | Running log of problems and fixes | Historical |
| `docs/uploads.md` | File upload handling | — |
| `docs/qa/edr-freight-qa-test-plan.md` | QA test plan | — |
| `../DEPLOYMENT.md` | Deploy process | — |
| `../ITMLS_DB_Design.md`, `../orgstructure.md` | Design notes | Historical |
| `../E2E_TEST_REPORT.md`, `../checkpoint.md` | Point-in-time snapshots | **Stale by design — dated artifacts, not references** |
Root-level `*.sql` and `*.dump` files are ad-hoc data snapshots, not part of the schema.
Migrations are the only source of truth for schema.