# 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: true` — **migrations run automatically on API boot**, with
  `migrationsTransactionMode: 'each'`.
- Consequences you must design for:
  - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
    data migration can execute twice. Keep one instance.
  - 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.
- **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.
