# 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, 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 The two domains are **not built the same way**. Check which stack you are in before copying a pattern across: | 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 | 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` | 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 | **`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". `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** 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. `docs/MAP.md` lists the ~60 freight modules grouped by domain. ### 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. **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. 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` 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.