diff --git a/.claude/skills/edr-db/SKILL.md b/.claude/skills/edr-db/SKILL.md
new file mode 100644
index 000000000..47333128d
--- /dev/null
+++ b/.claude/skills/edr-db/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: edr-db
+description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema.
+---
+
+# EDR dev-DB runner
+
+One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`):
+
+```bash
+node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
+node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
+node .claude/skills/edr-db/query.cjs columns
# bare column names, for diffing vs the entity
+```
+
+Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`,
+defaulting to the shared dev database (`edr_dev`).
+
+## Rules that go with it
+
+- **HARD RULE: every raw SQL statement you write into a service must pass
+ `explain` here before you ship it.** A typo'd column is a runtime 500 the
+ type-checker cannot catch.
+- Never assume a recorded migration applied — check `migrations ` **and**
+ `columns
` together. Recorded-but-absent = schema drift; fix with a
+ NEW repair migration (idempotent DDL, no-op `down()`), never by editing the
+ recorded one.
+- Writes to dev data are fine for seeding/diagnosis but keep them idempotent
+ (`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
+ and non-idempotent statements have double-run here before.
+- Timestamps for new migrations: must be unique across `src/migrations/` AND
+ higher than `SELECT max(timestamp) FROM public.migrations`.
+
+## Diagnosing a pasted 400/500 (the recurring loop)
+
+1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`.
+2. Read the service method — list its guard `throw`s. Most "bugs" are a guard
+ working as designed (handover unsigned, fee unpaid, not PAID, wrong direction).
+3. Check the actual DB state for that record with this runner.
+4. Only then decide: guard doing its job (fix the UI affordance) vs real defect.
diff --git a/.claude/skills/edr-db/query.cjs b/.claude/skills/edr-db/query.cjs
new file mode 100644
index 000000000..b4797bd45
--- /dev/null
+++ b/.claude/skills/edr-db/query.cjs
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+/**
+ * Dev-DB runner for the EDR freight database. psql is NOT installed on this
+ * machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect
+ * the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from
+ * anywhere in the repo.
+ *
+ * node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
+ * node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
+ * node .claude/skills/edr-db/query.cjs columns
');
+ process.exit(2);
+ }
+ const c = new Client(cfg);
+ await c.connect();
+ try {
+ if (first === 'columns') {
+ const r = await c.query(
+ `SELECT column_name, data_type, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_schema='freight' AND table_name=$1
+ ORDER BY ordinal_position`,
+ [rest[0]],
+ );
+ console.table(r.rows);
+ } else if (first === 'migrations') {
+ const like = rest[0] ? `%${rest[0]}%` : '%';
+ const r = await c.query(
+ `SELECT id, timestamp, name FROM public.migrations
+ WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
+ [like],
+ );
+ console.table(r.rows);
+ } else if (first === 'drift') {
+ // Quick drift signal: DB columns for the table. Compare by eye against
+ // the entity's @Column names; a recorded-but-absent column = drift.
+ const r = await c.query(
+ `SELECT column_name FROM information_schema.columns
+ WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`,
+ [rest[0]],
+ );
+ console.log(r.rows.map((x) => x.column_name).join('\n'));
+ } else if (first === 'explain') {
+ await c.query('EXPLAIN ' + rest.join(' '));
+ console.log('OK — statement is valid against', cfg.database);
+ } else {
+ const sql = [first, ...rest].join(' ');
+ const started = Date.now();
+ const r = await c.query(sql);
+ if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows);
+ console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`);
+ }
+ } finally {
+ await c.end();
+ }
+}
+
+main().catch((e) => {
+ console.error('FAIL:', e.message);
+ process.exit(1);
+});
diff --git a/.claude/skills/standup/SKILL.md b/.claude/skills/standup/SKILL.md
new file mode 100644
index 000000000..272f280e7
--- /dev/null
+++ b/.claude/skills/standup/SKILL.md
@@ -0,0 +1,40 @@
+---
+name: standup
+description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone.
+---
+
+# Work report (standup / ticket summary)
+
+Ground every line in git. Do not reconstruct from conversation memory — commits
+are the record.
+
+## Gather
+
+```bash
+# Commits in the window (adjust dates; author matches "Hagernesh")
+git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \
+ --pretty=format:"%h|%ad|%s" --date=short
+
+# What each commit actually contains (subjects lie sometimes)
+git show --stat --pretty=format:"%s" | head -12
+
+# In-flight work = part of "today" even if uncommitted
+git status --short
+git log origin/dev..HEAD --oneline # branch commits not yet in dev
+```
+
+## Known pitfalls in this repo
+
+- **Check subjects against contents.** Commit titles here sometimes mismatch the
+ diff (e.g. a commit titled "unload export" that actually contained ISO
+ container validation). Use `git show --stat` before reporting a title as fact.
+- A day with no commits usually still has uncommitted/in-flight work — report it
+ as its own section with per-item status (done / uncommitted / blocked).
+- Merge commits from other authors are noise; filter with `--author`.
+
+## Output format
+
+One table per day: `# | Task (plain language, not the commit subject verbatim) |
+Commit / Status`. Follow with a short "carry-over / blocked" list naming what
+blocks each item. Keep it ticket-ready: no jargon that needs the repo open to
+decode.
diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md
new file mode 100644
index 000000000..a2c715db4
--- /dev/null
+++ b/.claude/skills/verify/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: verify
+description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting.
+---
+
+# Verify a change (EDR definition of done)
+
+Run these in order. Report which you ran and what each said — never call
+unverified work done.
+
+## 1. Type-check exactly what you touched
+
+```bash
+pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal
+```
+
+Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited
+`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`:
+
+```bash
+pnpm turbo build --filter=@edr/types
+```
+
+## 2. Validate every raw SQL statement
+
+Each new/edited `dataSource.query` / `manager.query` string must pass:
+
+```bash
+node .claude/skills/edr-db/query.cjs explain ""
+```
+
+## 3. Migration checklist (if you added one)
+
+- Timestamp unique in `src/migrations/` **and** greater than
+ `node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`.
+- DDL idempotent (`IF NOT EXISTS`, guarded backfills).
+- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB
+ yourself or fully restart the API, then confirm with
+ `query.cjs columns
`.
+
+## 4. Tests — honest bar
+
+`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite
+is not the bar. The bar: run the specs nearest what you touched and introduce
+**no new failure**. If you touched a service constructor, update its `.spec.ts`
+mocks (constructor-arity breaks are this repo's most common test regression).
+
+## 5. Observe the behaviour
+
+Compiling is not working. Hit the endpoint, drive the UI flow, or query the
+resulting rows. If you genuinely could not observe it, say so explicitly in the
+summary — do not imply it was seen working.
+
+## 6. Before commit
+
+- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this
+ repo (husky shims exist but no user hooks) — nothing will catch it for you.
+- Lint the files you touched if in doubt: `pnpm turbo lint --filter=`.
+- Do not commit or push unless the user asked.
diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md
new file mode 100644
index 000000000..11075e9f0
--- /dev/null
+++ b/CLAUDE_NEW.md
@@ -0,0 +1,294 @@
+# 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` 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.
+
+- `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..)`.
+- 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//` 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` 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.
diff --git a/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts
new file mode 100644
index 000000000..aa2f998a2
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts
@@ -0,0 +1,55 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Repairs `freight.warehouse_inventory.grn_number`.
+ *
+ * AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but
+ * the column is absent on at least one environment - it was added, then dropped
+ * out-of-band (a stray `synchronize: true`, same class of damage that
+ * RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has
+ * the original recorded, it will never re-run it.
+ *
+ * Without the column, everything that reads or writes a GRN fails with
+ * `column ... grn_number does not exist`:
+ * - bulkReceive() -> INSERT names grn_number (receive to warehouse)
+ * - importQueueByStatuses() -> Unloaded + Dispatch queues
+ * - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs
+ * - grnDocument() -> GRN PDF
+ *
+ * Idempotent: a no-op on environments where the column survived.
+ */
+export class RepairGrnNumberColumn2090000000000 implements MigrationInterface {
+ name = 'RepairGrnNumberColumn2090000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.warehouse_inventory
+ ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
+ `);
+
+ // Recover the GRN for rows received before the column existed: it was also
+ // written into the receive note as "GRN Number: ".
+ await queryRunner.query(`
+ UPDATE freight.warehouse_inventory
+ SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
+ WHERE grn_number IS NULL
+ AND notes IS NOT NULL
+ AND notes ~ 'GRN Number: '
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
+ ON freight.warehouse_inventory(grn_number)
+ WHERE grn_number IS NOT NULL
+ `);
+ }
+
+ /**
+ * Deliberately a no-op. Dropping the column is what broke these environments
+ * in the first place, and the original 1828 migration already owns its own
+ * down(). Reverting this repair must not re-introduce the outage.
+ */
+ public async down(): Promise {
+ // intentionally empty
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts
new file mode 100644
index 000000000..2bf9f82fd
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts
@@ -0,0 +1,48 @@
+import {
+ Body,
+ Controller,
+ NotFoundException,
+ Param,
+ ParseUUIDPipe,
+ Post,
+} from "@nestjs/common";
+import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
+
+import { BookingStaff } from "../../common/booking-guards";
+import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
+import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
+import { CustomerResetService } from "./customer-reset.service";
+
+/**
+ * Staff-triggered password reset. The customer receives the code and sets their
+ * own password — staff never see or handle a credential.
+ */
+@ApiTags("backoffice")
+@Controller("backoffice/customers")
+@ApiBearerAuth()
+export class CustomerResetController {
+ constructor(private readonly customerResetService: CustomerResetService) {}
+
+ @Post(":companyId/reset-password")
+ @BookingStaff(FREIGHT_PERMS.customers.resetPassword)
+ @ApiOperation({
+ summary: "Send a password-reset code to a customer's primary contact",
+ })
+ async resetPassword(
+ @Param("companyId", ParseUUIDPipe) companyId: string,
+ @Body() dto: BackofficeResetPasswordDto,
+ ) {
+ const maskedTarget = await this.customerResetService.sendResetToCustomer(
+ companyId,
+ dto.channel,
+ );
+
+ if (!maskedTarget) {
+ throw new NotFoundException(
+ `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
+ );
+ }
+
+ return { channel: dto.channel, maskedTarget };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts
new file mode 100644
index 000000000..4c8f67599
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts
@@ -0,0 +1,59 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { ExternalProfile } from "../companies/entities/external-profile.entity";
+import { ResetChannel } from "./dto/forgot-password.dto";
+import { ForgotPasswordService } from "./forgot-password.service";
+
+@Injectable()
+export class CustomerResetService {
+ private readonly logger = new Logger(CustomerResetService.name);
+
+ constructor(
+ @InjectRepository(ExternalProfile)
+ private readonly externalProfileRepository: Repository,
+ private readonly forgotPasswordService: ForgotPasswordService,
+ ) {}
+
+ /**
+ * Send a reset code to the company's primary contact. Returns the masked
+ * destination, or null when there is no eligible account for that channel.
+ *
+ * Unlike the public flow this reports failure honestly — the caller is an
+ * authenticated staff member, so there is nothing to enumerate.
+ */
+ async sendResetToCustomer(
+ companyId: string,
+ channel: ResetChannel,
+ ): Promise {
+ const profile = await this.externalProfileRepository.findOne({
+ where: { companyId, isPrimaryContact: true },
+ });
+
+ if (!profile) {
+ this.logger.warn(`Company ${companyId} has no primary contact profile`);
+ return null;
+ }
+
+ // Resolve through the same active-account gate the public flow uses, so a
+ // suspended customer cannot be reactivated by a staff-triggered reset.
+ const user = await this.forgotPasswordService.resolveActiveUserById(
+ profile.userId,
+ );
+ if (!user) {
+ this.logger.warn(
+ `Primary contact ${profile.userId} of company ${companyId} is not an active account`,
+ );
+ return null;
+ }
+
+ const target = await this.forgotPasswordService.requestReset(user, channel);
+ if (!target) return null;
+
+ this.logger.log(
+ `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
+ );
+ return this.forgotPasswordService.maskTarget(target);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts
new file mode 100644
index 000000000..be2f9bdac
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts
@@ -0,0 +1,35 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { IsEnum, IsNotEmpty, IsString } from "class-validator";
+
+/** The channel the reset code is delivered over. */
+export enum ResetChannel {
+ Email = "email",
+ Phone = "phone",
+}
+
+export class ForgotPasswordRequestDto {
+ @ApiProperty({
+ description: "Email, username, or phone number of the account to reset",
+ example: "name@company.com",
+ })
+ @IsString()
+ @IsNotEmpty()
+ identifier!: string;
+
+ @ApiProperty({ enum: ResetChannel })
+ @IsEnum(ResetChannel)
+ channel!: ResetChannel;
+}
+
+export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
+ @ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
+ @IsString()
+ @IsNotEmpty()
+ otp!: string;
+}
+
+export class BackofficeResetPasswordDto {
+ @ApiProperty({ enum: ResetChannel })
+ @IsEnum(ResetChannel)
+ channel!: ResetChannel;
+}
diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts
new file mode 100644
index 000000000..da49982d2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts
@@ -0,0 +1,69 @@
+import { Body, Controller, Logger, Post } from "@nestjs/common";
+import { ApiOperation, ApiTags } from "@nestjs/swagger";
+import { Public } from "@edr/api-common";
+
+import {
+ ForgotPasswordRequestDto,
+ ForgotPasswordVerifyDto,
+} from "./dto/forgot-password.dto";
+import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
+
+/**
+ * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
+ * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
+ * this API does not set). These routes drive freight's own email-or-phone OTP
+ * service instead, then hand back a ticket for IAM's public `set-password`.
+ */
+@ApiTags("auth")
+@Controller("auth")
+@Public()
+export class ForgotPasswordController {
+ private readonly logger = new Logger(ForgotPasswordController.name);
+
+ constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
+
+ @Post("forgot-password/request")
+ @ApiOperation({
+ summary: "Send a password-reset code over email or SMS",
+ description:
+ "Always reports success. An unknown, inactive, or channel-less account is " +
+ "indistinguishable from a real one, so this cannot be used to enumerate accounts.",
+ })
+ async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
+ const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
+
+ if (user) {
+ try {
+ await this.forgotPasswordService.requestReset(user, dto.channel);
+ } catch (error) {
+ // A delivery failure must not change the response shape either — log it
+ // and let the caller sit on the OTP screen.
+ this.logger.error(
+ `Reset code delivery failed for user ${user.id}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
+ } else {
+ this.logger.log("Reset requested for an unknown or inactive account");
+ }
+
+ return { success: true };
+ }
+
+ @Post("forgot-password/verify")
+ @ApiOperation({
+ summary: "Exchange a valid reset code for a single-use set-password ticket",
+ description:
+ "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
+ "alongside the same identifier and the new password.",
+ })
+ verify(@Body() dto: ForgotPasswordVerifyDto): Promise {
+ return this.forgotPasswordService.verifyAndMintTicket(
+ dto.identifier,
+ dto.channel,
+ dto.otp,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
new file mode 100644
index 000000000..42dc723d5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
@@ -0,0 +1,169 @@
+import { randomBytes } from "node:crypto";
+
+import { BadRequestException, Injectable, Logger } from "@nestjs/common";
+import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
+import { DataSource, Repository } from "typeorm";
+
+import { hashPassword } from "@tria-plc/api-common/utils/argon";
+import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
+import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
+import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
+
+import { OtpService, OtpTarget } from "../otp/otp.service";
+import { ResetChannel } from "./dto/forgot-password.dto";
+
+/**
+ * How long the reset ticket minted for `PATCH /api/auth/set-password` stays
+ * valid. The IAM `setPassword` handler enforces this via `expiresAt`.
+ */
+const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
+
+/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
+const RESET_OTP_TTL_MS = 10 * 60 * 1000;
+
+export interface ResetTicket {
+ userId: string;
+ verificationCode: string;
+}
+
+@Injectable()
+export class ForgotPasswordService {
+ private readonly logger = new Logger(ForgotPasswordService.name);
+
+ constructor(
+ @InjectRepository(User)
+ private readonly userRepository: Repository,
+ @InjectDataSource()
+ private readonly dataSource: DataSource,
+ private readonly otpService: OtpService,
+ ) {}
+
+ /**
+ * Resolve an account that is actually eligible for a password reset.
+ *
+ * IAM's `set-password` handler flips `isActive: true` on the user as a side
+ * effect, so a reset on a deactivated account would silently resurrect it.
+ * Gating here — rather than at the set-password call — is what keeps that
+ * from being reachable. Mirrors IAM's own login lookup: match on any of
+ * email / username / phone, and require an active credential row.
+ */
+ async resolveActiveUser(identifier: string): Promise {
+ const id = identifier.trim();
+ if (!id) return null;
+
+ return await this.activeUserQuery()
+ .andWhere(
+ "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
+ { id },
+ )
+ .getOne();
+ }
+
+ /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
+ async resolveActiveUserById(userId: string): Promise {
+ if (!userId) return null;
+ return await this.activeUserQuery()
+ .andWhere("u.id = :userId", { userId })
+ .getOne();
+ }
+
+ /**
+ * Base query for accounts eligible to reset. `.where()` is claimed here so
+ * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
+ * which would silently drop the `isActive` gate.
+ */
+ private activeUserQuery() {
+ return this.userRepository
+ .createQueryBuilder("u")
+ .innerJoin("u.userCredentials", "uc", "uc.isActive = true")
+ .where("u.isActive = true")
+ .orderBy("u.createdAt", "DESC");
+ }
+
+ /** The address the code goes to, taken from the account — never from input. */
+ private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
+ if (channel === ResetChannel.Email) {
+ return user.email ? { email: user.email } : null;
+ }
+ return user.phoneNumber ? { phone: user.phoneNumber } : null;
+ }
+
+ /**
+ * Send a reset code to the account's own email/phone. Returns the target so
+ * authenticated (backoffice) callers can echo a masked version; unauthenticated
+ * callers must discard it.
+ *
+ * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
+ * upserts. A reset request therefore overwrites any pending signup code for
+ * the same address — last code sent wins. That is the pre-existing behaviour
+ * between any two flows sharing this table.
+ */
+ async requestReset(
+ user: User,
+ channel: ResetChannel,
+ ): Promise {
+ const target = this.targetFor(user, channel);
+ if (!target) return null;
+
+ await this.otpService.sendOtp(target);
+ return target;
+ }
+
+ /**
+ * Prove possession of the OTP, then mint an IAM reset ticket the caller can
+ * spend on the public `PATCH /api/auth/set-password`.
+ *
+ * Minting a `UserVerification` row rather than writing `UserCredential`
+ * ourselves keeps IAM as the single owner of the password write path (old
+ * credential deactivation, argon hashing, changed-at bookkeeping).
+ */
+ async verifyAndMintTicket(
+ identifier: string,
+ channel: ResetChannel,
+ otp: string,
+ ): Promise {
+ const user = await this.resolveActiveUser(identifier);
+ const target = user && this.targetFor(user, channel);
+
+ if (!user?.id || !target) {
+ // Same shape as a wrong code: a caller probing for accounts learns nothing
+ // beyond what the request step already (deliberately) refuses to tell them.
+ throw new BadRequestException("Invalid verification code");
+ }
+
+ await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
+
+ const code = randomBytes(24).toString("base64url");
+ const verificationCode = await hashPassword(code);
+ const userId = user.id;
+
+ await this.dataSource.transaction(async (manager) => {
+ const repo = manager.getRepository(UserVerification);
+ // Retire any outstanding codes so only the ticket we just minted can be
+ // spent — `findVerificationForPrimaryReset` reads the newest row.
+ await repo.update({ userId }, { isUsed: true });
+ await repo.insert({
+ userId,
+ otpType: EOtpType.RESET_PASSWORD,
+ verificationCode,
+ expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
+ isUsed: false,
+ attemptCount: 0,
+ });
+ });
+
+ this.logger.log(`Reset ticket minted for user ${userId}`);
+ return { userId, verificationCode: code };
+ }
+
+ /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
+ maskTarget(target: OtpTarget): string {
+ if (target.email) {
+ const [local, domain] = target.email.split("@");
+ const head = local.slice(0, 1);
+ return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
+ }
+ const phone = target.phone ?? "";
+ return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
index 16fbeffda..6415cf4c1 100644
--- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
+++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
+import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
+import { ExternalProfile } from '../companies/entities/external-profile.entity';
+import { OtpModule } from '../otp/otp.module';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
+import { CustomerResetController } from './customer-reset.controller';
+import { CustomerResetService } from './customer-reset.service';
+import { ForgotPasswordController } from './forgot-password.controller';
+import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
- imports: [TypeOrmModule.forFeature([User])],
- controllers: [FreightMeController, CheckAvailabilityController],
- providers: [FreightMeService, CheckAvailabilityService],
+ imports: [
+ TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
+ OtpModule,
+ ],
+ controllers: [
+ FreightMeController,
+ CheckAvailabilityController,
+ ForgotPasswordController,
+ CustomerResetController,
+ ],
+ providers: [
+ FreightMeService,
+ CheckAvailabilityService,
+ ForgotPasswordService,
+ CustomerResetService,
+ ],
})
export class FreightAuthModule {}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 50b5dd767..dac739fbf 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1125,6 +1125,30 @@ export class BookingsService {
);
}
+ /**
+ * Batched version of the findById flag: marks each page item whose booking
+ * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
+ * dashboard) can show "Approve delivery" for exactly the generated→signed
+ * window. One query for the whole page.
+ */
+ private async attachHandoverFlags(bookings: Booking[]): Promise {
+ const ids = bookings.map((b) => b.id);
+ if (!ids.length) return;
+ const rows: Array<{ bookingId: string }> = await this.dataSource.query(
+ `SELECT DISTINCT booking_id AS "bookingId"
+ FROM freight.booking_handovers
+ WHERE booking_id = ANY($1::uuid[])
+ AND signed_at IS NULL AND deleted_at IS NULL
+ AND mile_type = 'SELF_HAUL'`,
+ [ids],
+ );
+ const pending = new Set(rows.map((r) => r.bookingId));
+ for (const b of bookings) {
+ (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
+ pending.has(b.id);
+ }
+ }
+
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -1135,7 +1159,7 @@ export class BookingsService {
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
- return this.bookingsRepository.findAllPaginated({
+ const result = await this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
@@ -1167,6 +1191,8 @@ export class BookingsService {
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
+ await this.attachHandoverFlags(result.items ?? []);
+ return result;
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts
new file mode 100644
index 000000000..73271e0af
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts
@@ -0,0 +1,88 @@
+import { Repository } from "typeorm";
+
+import { CompanyChangeRequestRepository } from "./company-change-request.repository";
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from "./entities/company-change-request.entity";
+
+type Row = Pick & { createdAt: Date };
+
+const COMPANY_ID = "company-1";
+
+/**
+ * Stands in for the TypeORM repository over a fixed set of rows, honouring the
+ * `where.status` filter and the `createdAt DESC` ordering findOne relies on.
+ */
+function mockRepositoryOver(rows: Row[]) {
+ return {
+ findOne: jest.fn(
+ ({ where }: { where: Partial & { companyId: string } }) =>
+ Promise.resolve(
+ rows
+ .filter(
+ (row) =>
+ where.companyId === COMPANY_ID &&
+ (where.status === undefined || row.status === where.status),
+ )
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
+ null,
+ ),
+ ),
+ } as unknown as Repository;
+}
+
+function subject(rows: Row[]) {
+ return new CompanyChangeRequestRepository(mockRepositoryOver(rows));
+}
+
+describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
+ const rejected: Row = {
+ id: "rejected",
+ status: ChangeRequestStatus.Rejected,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ };
+
+ it("returns the pending request when one is open", async () => {
+ const pending: Row = {
+ id: "pending",
+ status: ChangeRequestStatus.Pending,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+ };
+
+ const result = await subject([rejected, pending]).findLatestOpenByCompanyId(
+ COMPANY_ID,
+ );
+
+ expect(result?.id).toBe("pending");
+ });
+
+ it("returns the latest rejected request when nothing is pending", async () => {
+ const result = await subject([rejected]).findLatestOpenByCompanyId(
+ COMPANY_ID,
+ );
+
+ expect(result?.id).toBe("rejected");
+ });
+
+ it("returns null once a resubmit of a rejected request is approved", async () => {
+ const approved: Row = {
+ id: "approved",
+ status: ChangeRequestStatus.Approved,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+ };
+
+ const result = await subject([
+ rejected,
+ approved,
+ ]).findLatestOpenByCompanyId(COMPANY_ID);
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when the company has no requests", async () => {
+ const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID);
+
+ expect(result).toBeNull();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
index 24d988452..eb44d56cb 100644
--- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
+++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository {
const pending = await this.findPendingByCompanyId(companyId);
if (pending) return pending;
- return this.repository.findOne({
- where: { companyId, status: ChangeRequestStatus.Rejected },
+ const latest = await this.repository.findOne({
+ where: { companyId },
order: { createdAt: "DESC" },
});
+ return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
}
async findById(id: string): Promise {
diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts
index 15aec5ac6..9bf14cd61 100644
--- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts
+++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts
@@ -1,4 +1,4 @@
-import { Injectable } from "@nestjs/common";
+import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record = {
[ProfileType.transporter]: "TR",
};
+const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+/** Numbers per series letter: A00001..A99999, then B00001. */
+const SERIES_SIZE = 99_999;
+
@Injectable()
export class CompanyProfileRepository extends BaseRepository {
constructor(
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository {
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
- const nextId = result[0].next_id as number;
+ const nextId = Number(result[0].next_id);
+ const offset = nextId - 1;
+ const seriesIndex = Math.floor(offset / SERIES_SIZE);
+
+ if (seriesIndex >= SERIES_LETTERS.length) {
+ throw new InternalServerErrorException(
+ `Company profile reference series exhausted for type "${type}"`,
+ );
+ }
+
+ const letter = SERIES_LETTERS[seriesIndex];
+ const number = (offset % SERIES_SIZE) + 1;
const prefix = PREFIX_MAP[type];
- return `${prefix}-${String(nextId).padStart(5, "0")}`;
+ return `${prefix}-${letter}${String(number).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise {
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
index 72696766f..2266b0c65 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
@@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity {
type!: ProfileType;
/**
- * Official profile reference (e.g. "EX-00001"). Minted only when the profile
+ * Official profile reference (e.g. "EX-A00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index e109d40a1..4da2677bd 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -593,7 +593,7 @@ export class ContractTransitionService {
if (!dto.otpPhone || !dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
- await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
+ await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
diff --git a/apps/edr-freight-api/src/modules/gps-tracking/README.md b/apps/edr-freight-api/src/modules/gps-tracking/README.md
new file mode 100644
index 000000000..668b581b8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/gps-tracking/README.md
@@ -0,0 +1,150 @@
+# GPS Tracking (GT06) — Operations & Device Configuration
+
+GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes
+everything about how the service is deployed and how devices are pointed at it.
+
+---
+
+## 1. Why GPS needs its own dedicated TCP port
+
+- **Not HTTP.** GT06 devices send binary frames
+ (`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`).
+ An HTTP server receiving these answers `400 Bad Request` and closes.
+- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two
+ listeners on the same pair collide (`EADDRINUSE`). The REST API already owns
+ its port, so GPS traffic needs a separate one.
+- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so
+ L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by
+ domain. Routing must happen at **Layer 4 (TCP)** by port.
+- **DNS carries no port.** An A record maps a name to an IP only. The tracker
+ config must state the port explicitly (e.g. `gps.example.com:5023`).
+
+### Operational requirements
+
+| Item | Value |
+| --- | --- |
+| Protocol | Raw TCP (not HTTP, not TLS) |
+| Default port | `5023` (configurable via `GT06_TCP_PORT`) |
+| Listener bind | `0.0.0.0` inside the `freight-gps` container |
+| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. |
+
+---
+
+## 2. Port configuration
+
+`5023` is only this project's default — **not** a GT06 protocol requirement. The
+listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured
+with the same number.
+
+Host and container ports are decoupled in `docker-compose.yaml`:
+
+```yaml
+freight-gps:
+ ports:
+ - "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
+ environment:
+ GT06_TCP_PORT: "5023" # pinned inside the container
+```
+
+- The **container** always listens on `5023`.
+- The **host/public** port is configurable (443, 5023, 9000, …) via the root
+ `.env`'s `GT06_TCP_PORT`.
+- This split is required because the image runs as a **non-root** user
+ (`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the
+ host port and forwards to `5023` inside.
+- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the
+ actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`.
+- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that
+ expect a TLS handshake on 443 may drop the connection.
+
+---
+
+## 3. Deployment topology
+
+The GT06 listener runs as its own process (`dist/main.gps.js`, module
+`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight`
+DB with the API; the DB is the seam (ingester writes `gps_devices` /
+`gps_positions`, API reads them).
+
+```
+freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
+freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
+```
+
+`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations.
+
+Horizontal scale: each tracker holds one long-lived TCP connection with
+per-socket session state, so N `freight-gps` replicas can run behind an L4 LB —
+each device sticks to one replica. `ensureDevice` is safe under concurrency
+(unique IMEI).
+
+---
+
+## 4. Device configuration (GT06 side)
+
+Config is done by **SMS to the tracker's SIM**. Commands below are the canonical
+Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware.
+Default command password is usually `123456`.
+
+Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN.
+
+```
+STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
+APN,# # 2. carrier data APN (add ,user,pass if needed)
+SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
+GPRSON,1# # 4. enable data
+GPSON,1# # enable GPS
+TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
+RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
+```
+
+Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#`
+Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#`
+
+### Verify from the server
+
+```bash
+docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
+nc -vz gps.example.com 5023
+curl -H "Authorization: Bearer " https://api.example.com/api/gps/positions/latest
+```
+
+First login packet **auto-registers** the IMEI (no manual step). `online:true`
+only when `lastSeenAt` < 5 min (computed at read time).
+
+### Link a tracker to a vehicle (optional)
+
+Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`):
+
+```
+PATCH /api/gps/devices/:id { "vehicleId": "", "name": "Truck 03-ET" }
+```
+
+### Failure map
+
+| Symptom | Cause |
+| --- | --- |
+| No SMS reply | SIM PIN on / no signal / wrong number |
+| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` |
+| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
+| Registered but `online:false` | packets blocked by firewall — open inbound TCP |
+| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min |
+
+---
+
+## 5. Security
+
+- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
+ reach the port can inject fake positions.
+- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
+ group to the SIM provider's **APN / IP range**.
+- Trackers must use the same host+port as the server:
+ `SERVER,1,gps.example.com,,0#`.
+
+---
+
+## 6. Edge (L4) termination
+
+See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf)
+for an Nginx `stream {}` example, and the AWS NLB notes in the same file.
+Reminder: **L4 only** — an HTTP proxy cannot route GT06.
diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts
index 1d7fd27fc..80fecf1bf 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts
@@ -13,8 +13,10 @@ import {
Patch,
Post,
Query,
+ UseGuards,
} from "@nestjs/common";
-import { ApiOperation, ApiTags } from "@nestjs/swagger";
+import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
+import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import {
AuthUserPayload,
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
+@ApiBearerAuth()
+@UseGuards(JwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}
diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts
index c14dcbaa5..0c4704170 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts
@@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
- * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
- * the handshake is authenticated in `handleConnection` and each socket joins a
- * private `user:` room the service targets.
+ * `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
+ * controller does not cover WebSockets; the handshake is authenticated in
+ * `handleConnection` and each socket joins a private `user:` room the
+ * service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,
diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
index d8404e84c..cddc67b2d 100644
--- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
+++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
+ // axios defaults to no timeout — a hanging gateway would block the caller
+ // (and any transaction it sits in) indefinitely. Always bound the wait.
+ const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000);
+
try {
const response = await axios.post(
url,
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
callbackUrl: "",
},
{
+ timeout,
headers: {
accept: "*/*",
"Content-Type": "application/json",
diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts
index e26ed35c9..b98b41d8d 100644
--- a/apps/edr-freight-api/src/modules/otp/otp.service.ts
+++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts
@@ -50,6 +50,9 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
+ // A freshly issued code gets a fresh guess budget.
+ this.actionAttempts.delete(this.targetKey(target));
+
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
@@ -121,24 +124,44 @@ export class OtpService {
// ---------------------------------------------------------------------------
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
- // contract signature). Unlike verifyOtp above — which marks a phone verified
- // and leaves the code in place — this enforces a short TTL and consumes the
- // code on success so it can never be replayed.
+ // contract signature, resetting a forgotten password). Unlike verifyOtp above
+ // — which marks a target verified and leaves the code in place — this enforces
+ // a TTL and consumes the code on success so it can never be replayed.
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
- async verifyOtpForAction(phone: string, otp: string) {
- const otpData = await this.otpRepository.findByPhone(phone);
+ // Without a cap, a 6-digit code guarding a password reset is brute-forceable
+ // within its own TTL. `otp_verifications` has no attempt column, so the
+ // counter lives here and the code is burned once the budget is spent.
+ // Per-process: it resets on restart and is not shared across replicas — a
+ // persisted counter needs a migration on OtpVerification.
+ private readonly MAX_ACTION_ATTEMPTS = 5;
+ private readonly actionAttempts = new Map();
+
+ private targetKey(target: OtpTarget): string {
+ return target.email ? `email:${target.email}` : `phone:${target.phone}`;
+ }
+
+ async verifyOtpForAction(
+ target: OtpTarget,
+ otp: string,
+ ttlMs: number = this.ACTION_OTP_TTL_MS,
+ ) {
+ const otpData = await this.otpRepository.findByTarget(target);
+ const key = this.targetKey(target);
if (!otpData) {
throw new BadRequestException(
- "No verification code was requested for this phone",
+ target.email
+ ? "No verification code was requested for this email"
+ : "No verification code was requested for this phone",
);
}
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
- if (ageMs > this.ACTION_OTP_TTL_MS) {
+ if (ageMs > ttlMs) {
await this.otpRepository.deleteOtp(otpData);
+ this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
@@ -146,11 +169,24 @@ export class OtpService {
}
if (otpData.otp !== otp) {
+ const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
+
+ if (attempts >= this.MAX_ACTION_ATTEMPTS) {
+ await this.otpRepository.deleteOtp(otpData);
+ this.actionAttempts.delete(key);
+
+ throw new BadRequestException(
+ "Too many incorrect attempts. Request a new code.",
+ );
+ }
+
+ this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
+ this.actionAttempts.delete(key);
return { success: true };
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts
index 681b30284..17b75b424 100644
--- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
-import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
+import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
@IsOptional()
@IsDateString()
gateOutTime?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Container bookings only: the operator chose not to weigh this truck. ' +
+ 'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
+ })
+ @IsOptional()
+ @IsBoolean()
+ weighingSkipped?: boolean;
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
index a54f40973..b2c4ca3c4 100644
--- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
@@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record {
+ try {
+ const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
+ `SELECT DISTINCT ON (booking_id)
+ booking_id AS "bookingId", reference
+ FROM freight.booking_handovers
+ WHERE signed_at IS NULL
+ AND deleted_at IS NULL
+ AND mile_type = 'SELF_HAUL'
+ ORDER BY booking_id, generated_at DESC`,
+ );
+ if (!rows.length) return;
+ this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
+ for (const row of rows) {
+ await this.notifySignNeeded(row.bookingId, row.reference);
+ }
+ } catch (err) {
+ this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
+ }
+ }
+
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise {
await this.dataSource
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 9b2f53a99..2b98856d0 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
+ /** Sent after the transaction commits so the gateway never blocks the receive. */
+ const pendingNotifications: Array<{
+ owner: {
+ phone?: string | null;
+ ownerName?: string | null;
+ bookingReference?: string | null;
+ grnNumber: string;
+ direction?: string | null;
+ warehouseId?: string | null;
+ };
+ booking: {
+ companyId?: string | null;
+ reference?: string | null;
+ hasFirstMile?: boolean;
+ hasLastMile?: boolean;
+ customerTruckAssignedAt?: string | null;
+ };
+ bookingId: string;
+ }> = [];
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
manager,
);
- await this.notifyOwnerInventoryReceived({
- phone: truckEntrance?.customerPhone ?? booking.customerPhone,
- ownerName: truckEntrance?.ownerName ?? booking.customer,
- bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
- grnNumber,
- direction: dto.direction,
- warehouseId: dto.warehouseId,
+ // Queued, not sent here: an SMS/email round-trip inside the transaction
+ // holds capacity/location locks open for the whole gateway latency.
+ pendingNotifications.push({
+ owner: {
+ phone: truckEntrance?.customerPhone ?? booking.customerPhone,
+ ownerName: truckEntrance?.ownerName ?? booking.customer,
+ bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
+ grnNumber,
+ direction: dto.direction,
+ warehouseId: dto.warehouseId,
+ },
+ booking,
+ bookingId,
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
- void this.notifyTruckAssignmentNeeded(booking, bookingId);
}
});
+ // Fan out after commit, un-awaited: the receive response must not wait on the
+ // SMS gateway. Both notifiers swallow their own errors.
+ for (const pending of pendingNotifications) {
+ void this.notifyOwnerInventoryReceived(pending.owner);
+ void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
+ }
+
return result;
}
@@ -2326,6 +2357,27 @@ export class WarehouseInventoryService {
});
}
+ /**
+ * Self-haul = the customer's own truck collects the goods: either a truck
+ * assigned via the portal (customer_truck_assigned_at), or a walk-in truck
+ * registered at the gate on a booking with no EDR last-mile leg. EDR
+ * last-mile bookings are never self-haul.
+ */
+ private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise {
+ const runner = manager ?? this.dataSource;
+ const [row]: Array<{ ok: number }> = await runner.query(
+ `SELECT 1 AS ok
+ FROM freight.bookings b
+ LEFT JOIN freight.service_types st ON st.id = b.service_type_id
+ WHERE b.id = $1 AND b.deleted_at IS NULL
+ AND (b.customer_truck_assigned_at IS NOT NULL
+ OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
+ AND COALESCE(st.includes_last_mile, false) = false))`,
+ [bookingId],
+ );
+ return Boolean(row);
+ }
+
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
async release(id: string, dto: ReleaseOrderDto): Promise {
const item = await this.findById(id);
@@ -2335,19 +2387,17 @@ export class WarehouseInventoryService {
);
}
- const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
+ // Leaving = gate-out captured, with either a weighed gross or an explicit
+ // container weighing skip (bulk always weighs).
+ const isTruckLeaving =
+ Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
if (item.bookingId) {
- const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
- await this.dataSource.query(
- `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
- FROM freight.bookings
- WHERE id = $1 AND deleted_at IS NULL`,
- [item.bookingId],
- );
- const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
+ // Self-haul = customer collects: a truck assigned via the portal, OR a
+ // walk-in truck registered at the gate on a booking with no EDR last mile.
+ const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
@@ -2361,7 +2411,8 @@ export class WarehouseInventoryService {
// Authoritative weight match: the truck's net (gross − tare) must equal the
// total VGM cargo weight of the containers selected as loaded on it.
- if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
+ // Skipped when the operator chose not to weigh (containers only).
+ if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
@@ -2431,13 +2482,10 @@ export class WarehouseInventoryService {
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
- // (idempotent). It must be signed before the truck leaves.
- const [selfHaul]: Array<{ ok: number }> = await manager.query(
- `SELECT 1 AS ok FROM freight.bookings
- WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
- [item.bookingId],
- );
- if (selfHaul) {
+ // (idempotent) and notify the customer to sign it. Covers BOTH portal-
+ // assigned trucks and walk-in trucks registered manually at the gate
+ // (no portal assignment, no EDR last mile). Must be signed before leaving.
+ if (await this.isSelfHaulBooking(item.bookingId, manager)) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
@@ -4422,14 +4470,17 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
- if (dto.tareWeight === undefined) {
+ // Container bookings may skip the weighbridge entirely (weighingSkipped);
+ // bulk always weighs.
+ const weighingSkipped = dto.weighingSkipped === true;
+ if (dto.tareWeight === undefined && !weighingSkipped) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
- const tareWeight = Number(dto.tareWeight);
+ const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
- grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
+ grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
@@ -4441,7 +4492,11 @@ export class WarehouseInventoryService {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
- if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
+ if (
+ !weighingSkipped &&
+ (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
+ grossWeight == null
+ ) {
throw new BadRequestException('Gross weight is required for truck exit');
}
@@ -4457,7 +4512,8 @@ export class WarehouseInventoryService {
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
- `Tare Weight: ${tareWeight} t`,
+ weighingSkipped ? 'Weighing: SKIPPED' : null,
+ tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
@@ -4481,6 +4537,8 @@ export class WarehouseInventoryService {
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
+ // The weigh/skip decision is made at arrival and sticks for the exit.
+ weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
};
}
diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
index 1aef33801..99dae5974 100644
--- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
+++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
@@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
},
{
fileKey: "commercial_license",
- fileLabel: "Commercial License",
- helpText: "Verified against the government trade system during registration.",
+ fileLabel: "Commercial Registration",
+ helpText:
+ "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
- helpText: "Verified against the government trade system during registration.",
+ helpText:
+ "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
const CONTRACT_INTAKE_ENTITY = "contract_intake";
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
- clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
- required: false,
- }),
+ clearanceField(
+ "commercial_framework",
+ "Commercial Framework / Agreement",
+ 1,
+ {
+ required: false,
+ },
+ ),
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
required: false,
}),
@@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
- constructor(private readonly dataSource: DataSource) {}
+ constructor(private readonly dataSource: DataSource) { }
async run() {
await this.dataSource.transaction(async (manager) => {
@@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder {
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
- ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
- ...s,
- description: COMPANY_ONBOARDING_DESCRIPTION,
- })),
- ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
- ...s,
- description: CLEARANCE_DESCRIPTION,
- })),
- ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
- ...s,
- description:
- "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
- })),
- ...SELF_CLEARANCE_SETTINGS.map((s) => ({
- ...s,
- description:
- "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
- })),
- ...CONTRACT_INTAKE_SETTINGS.map((s) => ({
- ...s,
- description:
- "Commercial/framework documents attached at contract submission.",
- })),
- ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
- ...s,
- description:
- "Documents uploaded against a driver profile (license, ID, contracts, etc.).",
- })),
- ];
+ ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
+ ...s,
+ description: COMPANY_ONBOARDING_DESCRIPTION,
+ })),
+ ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
+ ...s,
+ description: CLEARANCE_DESCRIPTION,
+ })),
+ ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
+ ...s,
+ description:
+ "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
+ })),
+ ...SELF_CLEARANCE_SETTINGS.map((s) => ({
+ ...s,
+ description:
+ "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
+ })),
+ ...CONTRACT_INTAKE_SETTINGS.map((s) => ({
+ ...s,
+ description:
+ "Commercial/framework documents attached at contract submission.",
+ })),
+ ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
+ ...s,
+ description:
+ "Documents uploaded against a driver profile (license, ID, contracts, etc.).",
+ })),
+ ];
for (const documentSetting of allSettings) {
await settingRepository.upsert(
@@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder {
});
if (!setting) {
- throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
+ throw new Error(
+ `file_upload_setting_seed_failed:${documentSetting.code}`,
+ );
}
await fieldRepository.delete({ settingId: setting.id });
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 5039e5672..e281e6464 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
+ perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'),
];
// D. Finance — payments + invoices
@@ -395,6 +396,7 @@ export const FREIGHT_PERMS = {
update: 'edr_freight_app:customers:update',
deactivate: 'edr_freight_app:customers:deactivate',
verify: 'edr_freight_app:customers:verify',
+ resetPassword: 'edr_freight_app:customers:reset-password',
},
payments: {
view: 'edr_freight_app:payments:view',
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx
new file mode 100644
index 000000000..bc505a21f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx
@@ -0,0 +1,105 @@
+import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
+import { useMutation } from "@tanstack/react-query";
+import { KeyRound } from "lucide-react";
+import { useState } from "react";
+
+import { useAuth } from "@/auth/useAuth";
+import { useToast } from "@/hooks/use-toast";
+import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
+import { api } from "@/services/api";
+import type { Company, ResetChannel } from "@/types/customer";
+
+export interface ResetPasswordActionProps {
+ company: Pick;
+}
+
+/**
+ * Staff-triggered password reset. Sends a one-time code to the customer's
+ * primary contact; the customer picks their own new password. No credential is
+ * ever shown to or handled by staff.
+ */
+export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [opened, setOpened] = useState(false);
+ const [channel, setChannel] = useState("phone");
+
+ const { mutate, isPending } = useMutation(
+ api.customers.resetPassword.mutationOptions({
+ onSuccess: (result) => {
+ setOpened(false);
+ toast({
+ title: "Reset code sent",
+ description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
+ });
+ },
+ onError: (error) => {
+ toast({
+ title: "Could not send reset code",
+ description: error.message,
+ variant: "destructive",
+ });
+ },
+ }),
+ );
+
+ if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
+
+ return (
+ <>
+ }
+ onClick={() => setOpened(true)}
+ >
+ Reset password
+
+
+ setOpened(false)}
+ title="Send a password-reset code"
+ centered
+ >
+
+
+ We'll send a one-time code to this customer's primary contact.
+ They choose their own new password — you will not see it.
+
+
+ setChannel(v as ResetChannel)}
+ label="Send the code via"
+ >
+
+
+
+
+
+
+
+ The code goes to the primary contact's own email or phone, which
+ may differ from the company contact details shown above.
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 2a87e3b0c..d42673d2b 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -13,5 +13,9 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
+export {
+ default as ResetPasswordAction,
+ type ResetPasswordActionProps,
+} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
index 391a8157d..698b0fc2c 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
@@ -250,7 +250,10 @@ const FreightSidebar = ({
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx
index 78e09a7e0..5eeee7d13 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx
@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
-import { extractErrorMessage } from './options';
+import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
- description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
+ description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
});
}
onClose();
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
index b8bb43086..c4f1f5cec 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
-import { extractErrorMessage } from './options';
+import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Release paper preview failed',
- description: extractErrorMessage(error),
+ description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Handover document failed',
- description: extractErrorMessage(error),
+ description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
index 3b4be03db..a1b91decb 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
-import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
+import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
- toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
+ toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -900,7 +900,7 @@ function EligibleTab({
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
- toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
+ toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
}
setSelected(new Set());
@@ -1697,7 +1697,6 @@ function LoadedExportTab({
WeightRouteStatus
- {dispatchable && Actions}
@@ -1739,19 +1738,6 @@ function LoadedExportTab({
{r.status}
- {dispatchable && (
-
-
-
- )}
))}
@@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
+ // Carries the saved [Exit Inspection] block so Truck Leaving opens with the
+ // arrival details (plate, driver, tare, gate-in) read-only instead of blank.
+ notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
- toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
+ toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}
@@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
- toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
+ toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
index 5cca0d3b2..d986e2aba 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
+import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
+ weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
};
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [containerNumbers, setContainerNumbers] = useState(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState('');
+ // Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
+ const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
const [grossWeight, setGrossWeight] = useState('');
const [netWeight, setNetWeight] = useState('');
const [gateOutTime, setGateOutTime] = useState('');
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
+ setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
- const isExitStep = savedInspection.tareWeight !== '';
+ const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
- const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
+ // Skip is only offered for container bookings; bulk always weighs.
+ const skipWeighing = hasContainerWeights && weighTruck === 'no';
+ const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
+ !skipWeighing &&
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
- if (!gateInTime || tareWeight === '') {
- toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
+ if (!gateInTime || (!skipWeighing && tareWeight === '')) {
+ toast({
+ variant: 'destructive',
+ title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
+ });
return;
}
- if (isExitStep && (!gateOutTime || grossWeight === '')) {
- toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
+ if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
+ toast({
+ variant: 'destructive',
+ title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
+ });
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
- if (isExitStep && systemNetWeight === '') {
+ if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: truckType.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
- tareWeight: Number(tareWeight),
- grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
- netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
+ weighingSkipped: skipWeighing || undefined,
+ tareWeight: skipWeighing ? undefined : Number(tareWeight),
+ grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
+ netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
+ {hasContainerWeights && (
+
+ Weigh truck?
+ setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
+ disabled={isEntranceLocked}
+ />
+ {skipWeighing && (
+ Weighbridge skipped — container passes without tare/gross.
+ )}
+
+ )}
- setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
- setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
+ setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
+ setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
{
const kind = itemKind(item);
const busy = busyId === item.id;
- const nextAction = getNextInventoryAction(item);
+ // Per-booking Load and Dispatch are retired: wagon loading happens in
+ // the train flow and dispatch at the train level (which already
+ // advances inventory). Only the remaining lifecycle actions render.
+ const rawNextAction = getNextInventoryAction(item);
+ const nextAction =
+ rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
)}
{item.status === 'READY_FOR_PICKUP' && (
- <>
-
-
- >
+
)}
{item.status !== 'DISPATCHED' && (
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 77a3bc95e..c6f98c8a2 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
`/payments/by-company/${id}/customer-view`,
+ RESET_PASSWORD: (companyId: string) =>
+ `/backoffice/customers/${companyId}/reset-password`,
},
BILLING: {
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index e0c5c3d08..92fe85ad7 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:customers:update",
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
+ resetPassword: "edr_freight_app:customers:reset-password",
},
payments: {
view: "edr_freight_app:payments:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index c68a4ca25..317f915fb 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -43,6 +43,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
+ ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
}
+ action={}
/>
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index a08bec6c5..f5281c283 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -12,6 +12,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
+ ResetChannel,
+ ResetPasswordResult,
} from "@/types/customer";
import {
CreateDropdownOptionDto,
@@ -2269,6 +2271,16 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
+ resetPassword: endpoint<
+ { companyId: string; channel: ResetChannel },
+ ResetPasswordResult
+ >(
+ "customers",
+ "resetPassword",
+ ({ companyId, channel }) =>
+ customersService.resetPassword(companyId, channel),
+ ),
+
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus; note?: string },
CompanyProfile
diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
index cafe0aece..0476d41bb 100644
--- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
@@ -11,6 +11,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
+ ResetChannel,
+ ResetPasswordResult,
} from "@/types/customer";
const cleanParams = (params: object) =>
@@ -81,6 +83,22 @@ export const customersService = {
.then((r) => r.data);
},
+ /**
+ * Send a password-reset code to the company's primary contact. Staff never
+ * receive a credential — the customer sets their own password from the code.
+ */
+ resetPassword(
+ companyId: string,
+ channel: ResetChannel,
+ ): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
+ { channel },
+ )
+ .then((r) => r.data);
+ },
+
setProfileStatus(
profileId: string,
status: ProfileStatus,
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index 8d76571d4..7328decf0 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
+/** The channel a customer's password-reset code is delivered over. */
+export type ResetChannel = "email" | "phone";
+
+export interface ResetPasswordResult {
+ channel: ResetChannel;
+ /** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
+ maskedTarget: string;
+}
+
/** Mirrors backend `Company` (+ its `companyProfiles`). */
export interface Company {
id: string;
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index e16271880..8a806201e 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
- // once inspection passes. Import STORED is handled via the import queue.
- if (isImport) return null;
+ // once inspection passes. An import item parked back into storage returns
+ // to pickup — otherwise Store would strand it with no action.
+ if (isImport) return inspected ? 'ready-for-pickup' : null;
return inspected ? 'ready-for-loading' : null;
case 'RESERVED':
// Export loading is gated on a passed inspection.
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
grossWeight?: number;
netWeight?: number;
gateOutTime?: string;
+ /** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
+ weighingSkipped?: boolean;
}
/** Import branch: proof of delivery captured on customer pickup. */
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 697c2f89c..e4c5685fd 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
+import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -252,6 +253,7 @@ const App = () => {
}>
} />
} />
+ } />
{/* Signup-flow pages; reached while a session already exists */}
diff --git a/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx
new file mode 100644
index 000000000..b61bb2eab
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx
@@ -0,0 +1,179 @@
+import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
+import {
+ AlertCircle,
+ ArrowLeft,
+ Mail,
+ RotateCw,
+ ShieldCheck,
+ Smartphone,
+} from "lucide-react";
+
+import { maskEmail, maskPhone } from "@/utils/identifier";
+
+export type OtpChannel = "phone" | "email";
+
+export const OTP_LENGTH = 6;
+
+export interface OtpChannelSelectProps {
+ value: OtpChannel;
+ onChange: (channel: OtpChannel) => void;
+ disabled?: boolean;
+ label?: string;
+}
+
+/** Phone/email toggle deciding where the verification code is sent. */
+export function OtpChannelSelect({
+ value,
+ onChange,
+ disabled,
+ label = "Send verification code via",
+}: OtpChannelSelectProps) {
+ return (
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
index ab00965fa..849ba50c5 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
@@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({
});
}, [roles, nationality, startMutation]);
- // Note: no "back to role selection" — once the draft is created the role(s)
- // are fixed; the form's first-step Back is a no-op so progress never resets.
- const handleBackToRoles = useCallback(() => { }, []);
+ // Back from the form's first step returns to nationality/role selection.
+ // Safe to re-enter: startOnboarding is idempotent — it reuses the existing
+ // draft, refreshes the nationality and creates only roles that don't exist yet.
+ const handleBackToRoles = useCallback(() => {
+ setStartError(null);
+ setPhase("nationality-role");
+ }, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
@@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
- console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
- hideFirstStepBack: true,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 89a3d57e7..5dfda7e58 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
+ FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
+ FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
},
USERS: {
diff --git a/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
new file mode 100644
index 000000000..deeca8125
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react";
+
+/** Seconds a user must wait before another OTP can be requested. */
+const DEFAULT_COOLDOWN_SECONDS = 60;
+
+/**
+ * Countdown that gates the "Resend code" button. Ticks with setTimeout rather
+ * than wall-clock arithmetic, so it needs no Date.now().
+ */
+export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
+ const [secondsLeft, setSecondsLeft] = useState(0);
+
+ useEffect(() => {
+ if (secondsLeft <= 0) return;
+ const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
+ return () => clearTimeout(t);
+ }, [secondsLeft]);
+
+ return {
+ secondsLeft,
+ start: () => setSecondsLeft(seconds),
+ reset: () => setSecondsLeft(0),
+ };
+}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
index b3acc1667..007fc7fe9 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
@@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
- const canApproveDelivery = booking.status === "COMPLETED";
+ // Visible from handover generation until the customer signs (flag is attached
+ // by the bookings list endpoint; false again the moment it's signed).
+ const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
index cc9f81a29..abfb61551 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
onBack,
initialStep,
resyncOpen,
- hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
initialStep?: CompanyStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
- /** Hide the Back button on the first step (onboarding can't go back to role pick). */
- hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
@@ -514,10 +511,6 @@ export default function CompanyProfileForm({
else setStep(stepOrder[currentIdx - 1]);
};
- // Back is hidden on the first step during onboarding (can't return to role
- // selection); otherwise always available.
- const showBack = !(hideFirstStepBack && step === "company");
-
return (
<>