Files
edr-platform/CLAUDE.md
Nathnael 339996d27f docs
2026-08-17 07:49:49 +00:00

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

Looking for where something lives? Read 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:

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:

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-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. 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<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. Freight and payment use TypeORM migrations; passenger uses Prisma (apps/edr-passenger-api/prisma/migrations) — the rules below are about the TypeORM side.

  • 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. 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:

    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 headerbackoffice 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/<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 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.