Files
edr-platform/CLAUDE_NEW.md

18 KiB

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:

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:

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-commoncreatedAt, 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: falsemigrations 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.