28 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 |
edr-hr-api |
@edr/hr-api |
NestJS + TypeORM | 3005 |
edr-hr-web |
@edr/hr-web |
React + Vite | 5185 |
finance-api |
@edr/finance-api |
NestJS + TypeORM | 3004 |
finance-web |
@edr/finance-web |
React + Vite | 5186 |
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.
apps/edr-hr-api/ owns the hr schema and is the HR extension of IAM: employee
profiles hang off iam.employees, "departments" are iam.units with an HR
satellite (hr.unit_hr_profiles), and job positions extend iam.positions. HR
stores no copy of an employee's name, unit or organization — those are read
through and joined at query time.
IAM access is split in two, and the split is the design:
- Reads go through
IamDirectoryService— raw SQL projections overiam.*, no entities involved. - Writes go through
IamOperationsService, which resolves IAM's own services out of the container (ModuleRef.get(..., {strict: false})) and calls them. HR never writes aniam.*table itself, so IAM's validation, transactions and audit trail all still apply.
That means hr-api does import IamModule (IamModule.forRoot({...})) and
does register @tria-plc/iamapi-common entities. It sidesteps the stale
iamEntities trap below by registering them as globs over the package's
dist/ rather than a hand-maintained class list, with autoLoadEntities: false
— see apps/edr-hr-api/src/config/database.config.ts. A package bump cannot
leave that list stale, because there is no list.
One consequence worth knowing before touching lifecycle code: IAM's
deactivateEmployee ends every position the employee holds, and
activateEmployee does not put them back. HR surfaces this rather than
hiding it (accessAlignment on the profile response).
apps/finance-api/ owns the finance schema — general ledger, chart of
accounts, receivables, payables, budgets and fixed assets. It follows hr-api's
shape exactly (embedded IamModule.forRoot, IAM entities registered as globs
over both package dists, autoLoadEntities: false, migrations in
finance.migrations run only by migration:run).
Two rules define it, and neither is negotiable:
- Finance writes only
finance.*. Revenue, cash and payroll are projected read-only out offreight,passenger,edr_paymentandhrwith schema-qualified raw SQL, plus payment events offPAYMENT_EVENTS_EXCHANGE. No source app writesfinance.*, and Finance writes none of theirs. - Money is
numeric(14,2)in major units. The column NAMES lie upstream: everything on the payment-intent path is called*Minorbut holds MAJOR amounts, while passenger's PrismaIntcolumns really are minor. Never infer the unit from the name —apps/finance-api/src/common/money.tsis the authority, and it also records which upstream rows are known bad.
Note both finance-api and hr-api serve POST /api/v1/auth/login: embedding
IamModule brings IAM's auth controller with it. finance-web therefore
authenticates against finance-api itself and needs no x-client-app header
(both verified against the running services). edr-hr-web points its login at
freight-api instead, on the assumption that an IAM-embedding app has no auth
routes — that assumption is wrong, and the coupling is unnecessary.
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
- One database, schema-separated. Every app connects to the SAME Postgres database
and is isolated by schema, not by database —
iam,freight,passenger,edr_payment,audit. This mirrors production, where the Smart Office database holds thefreightschema alongside the rest (seedump-smart_office_prod-*.sql). There is no longer a per-domain database; do not add one. - Postgres is external to
docker-compose.yaml(no service there). For a local one,infrastructure/docker/docker-compose.db.dev.ymlstarts a singlepostgreson 5432 (edr_database) and creates every schema viainfrastructure/docker/initdb/. - Freight API connection comes from
DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME(defaults:localhost:5432,edr_database). Development points these at a remote database.edr-gps-trackerandedr-payment-apiread the same DB_* convention;edr-passenger-apiusesDATABASE_URL(Prisma,?schema=passenger) plusDATABASE_*/DATABASE_SCHEMA=iamfor its read-only TypeORM IAM connection — all three must resolve to this one database. - The connection sits behind a connection pooler. Do not pass
extra.options: '-c search_path=…'— the pooler rejects it with08P01 unsupported startup parameter in options: search_path.search_pathis applied per-connection in a poolconnecthandler instead. Seeapps/edr-freight-api/src/config/database.config.tsbefore touching connection options. - Each app owns its own schema, and its own migration history in that schema:
iam.typeorm_migrationsandfreight.migrations(both applied by freight-api),passenger._prisma_migrations(Prisma), and payment-api's own table inedr_payment. An app never writes another app's schema. - IAM tables live in the
iamschema (iam.users,iam.user_credentials), freight tables infreight.edr-freight-apiis the authoritative owner of theiamschema — it ships theiam:migration:run|show|revertscripts. Passenger's IAM connection is read-only (synchronize: false,migrationsRun: false). - Cross-schema references stay soft. Co-location makes hard FKs possible, but there
are zero FKs from
freight.*intoiam.*and that is deliberate: references are plain UUID columns plus denormalized display fields, so IAM stays independently deployable and the audit trail outlives a deleted user. Keep it that way in new modules. - The e2e harnesses are the one exception and stay hermetic:
edr_freight_e2eon 5533 (e2e/freight/) and the passenger test DB on 5544 (e2e/docker-compose.yml). Do not point them at the shared database. psqlis installed (Homebrew, v17.9 — verified 2026-08-19); the earlier claim that it was not is out of date. Either use it directly against the local database, or use theedr-dbskill (below) / a short Node script usingpgrun fromapps/edr-freight-api, wherepgresolves.
Hard rules
These are non-negotiable. Everything else is a strong default.
- pnpm only. Never run
npm installoryarn. - TypeScript strict mode is on in every package and app. Do not weaken it, and do not
reach for
anyto make an error go away. - Never
synchronize: true. Not in production, not anywhere. It is currentlyfalsein 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
BaseEntityfrom@edr/api-common—createdAt,updatedAt,deletedAt(soft delete). - All columns are
snake_casein the database (@Column({ name: 'snake_case' })); TypeScript properties arecamelCase. - 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:
- Entity CRUD → the custom repository class. Extends
BaseRepository<Entity>from@edr/api-common. Services inject the repository class, neverRepository<T>directly. - Read projections, queue endpoints, cross-table reports → raw SQL via
this.dataSource.query(...)ormanager.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
EXPLAINagainst 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'smigrationbuild target (docker build --target migration), withmigrationsTransactionMode: 'each'.- CI:
.github/workflows/deploy.ymlbuilds themigrationimage and runs it (docker run --rm --env-file ...) before building/deploying the app image. - e2e:
docker-compose.e2e.yaml'sfreight-migration-e2eservice runs once andfreight-api-e2edepends on it (condition: service_completed_successfully). - Local dev (
docker-compose.yaml) has no equivalent migration service yet — run migrations yourself beforedocker compose up freight-api, e.g.docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .thendocker run --rm --env-file apps/edr-freight-api/.env freight-migration. Don't usepnpm run migratefor this — it runs viats-node, which never writes compiled output todist/, and the freight migrations glob only matchesdist/migrations/*.js. It silently applies zero freight migrations while exiting 0.
- CI:
-
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'siamEntitiesarray is a hand-maintained list of@tria-plc/iamapi-commonentity classes. The live app never notices when it's stale (autoLoadEntities: truepapers over gaps via IAM's ownforFeature()registrations), but the standalone migrationDataSource(data-source.ts, noautoLoadEntities) does not have that fallback — a missing entity throwsEntity metadata for X#y was not foundatinitialize(), before a single migration runs. Every@tria-plc/iamapi-commonversion bump is a candidate for this to break again — diff the package's entity classes againstiamEntitieswhen bumping it.
-
Give every migration a unique timestamp.
apps/edr-freight-api/src/migrationsholds 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 -dThe prefix must be unused and higher than the newest recorded row. Note the
freight.migrationstable 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 byWHERE col IS NULL. -
Never assume a recorded migration actually applied.
AddGrnNumberToWarehouseInventorywas recorded inmigrationswhile 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 realcreateParamDecorator, 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 anx-client-appheader —backofficefor employees,portalfor 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-commonholds 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 aBlob, so the synchronousextractErrorMessagefinds no.messageand degrades to"Request failed with status code 400". Useawait 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
- Create
modules/<feature>/withentities/,dto/, and the four<feature>.{module,controller,service,repository}.tsfiles. - The entity extends
BaseEntityfrom@edr/api-common. - The repository extends
BaseRepository<Entity>from@edr/api-common. - The service injects the repository class (not
Repository<T>directly). - The controller uses
@ApiTags()+@ApiOperation()for Swagger, and guards the route. - Register the module in the app's
app.module.ts.
Adding a new shared component to @edr/ui-common
- Create
src/components/<Name>/<Name>.tsxandsrc/components/<Name>/index.ts. - Export from
src/index.ts. - Component is a functional component with a
ComponentNamePropsinterface (named-exported alongside the default).
Definition of done
A change is done when all of these hold. State explicitly which you ran.
- It type-checks.
pnpm turbo type-check --filter=<each touched package>passes. If you editedpackages/types, you ranpnpm turbo build --filter=@edr/typesfirst. - Raw SQL is verified. Every new or edited SQL statement ran under
EXPLAINagainst the dev database without error. - 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.
- No new test failures.
pnpm testfor@edr/freight-apihas been red ondev, 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. - Formatting is clean for the files you touched. Git hooks do not run automatically
(see below), and
pnpm lintdoes not work at all, sonoUnusedLocalsfrom the type-check is your only unused-code signal. - 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.
- 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 |
Binding payment.* on the payment exchange |
Silently receives NOTHING — wire keys are three words (payment.passenger.succeeded) and AMQP * matches exactly one |
Bind payment.<service>.* for one service, or payment.# for all |
| Summing money across currencies | Passenger bookings are charged in ETB, DJF and USD; adding them overstates ETB revenue (6.3M on the dev replica) | Group by currency; convert only at an explicitly recorded rate |
Reading a DATE column in raw SQL |
node-postgres parses it to LOCAL midnight, so toISOString().slice(0,10) returns the PREVIOUS day east of UTC — a month-end lands in the wrong period |
Cast in SQL: period_end::text. Never round-trip a DATE through a JS Date |
A CHECK listing enum values wider than the column |
varchar(16) accepted every status until the 17-character one was first used, then failed mid-operation |
Size the column for the LONGEST permitted value |
| A service opening its own transaction inside a caller's | The inner write commits independently; a later failure leaves an orphaned posted row | Pass the caller's EntityManager through (see JournalsService.createPosted) |
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 targetdev. - When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.