diff --git a/.gitignore b/.gitignore
index 316f08dc3..b9dc16c3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ docker-compose.override.yml
# cypress e2e artifacts
e2e/**/cypress/videos/
e2e/**/cypress/screenshots/
+e2e/**/cypress/reports/
e2e/**/cypress/downloads/
# e2e launcher state (ports of the running stack)
diff --git a/CLAUDE.md b/CLAUDE.md
index d67e6c3d5..b90d3b1dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
+| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
diff --git a/E2E_TEST_REPORT.md b/E2E_TEST_REPORT.md
new file mode 100644
index 000000000..186bc3f5d
--- /dev/null
+++ b/E2E_TEST_REPORT.md
@@ -0,0 +1,440 @@
+# EDR Freight — End-to-End Test Report
+
+**Date:** 23 July 2026
+**Branch:** `freight_feature/usermanagement`
+**Command that was run:**
+
+```bash
+E2E_PORTAL_PORT=5374 docker compose -f docker-compose.e2e.yaml --profile cypress \
+ run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
+```
+
+---
+
+## 1. Short summary (read this first)
+
+I ran the end-to-end (E2E) test suite and found **four separate problems**. They are not all the same kind of problem, and this is the most important thing to understand:
+
+| # | Problem | Kind of problem | Status |
+|---|---------|-----------------|--------|
+| 1 | Tests crashed instantly with `exit code 137` | Machine / environment | Explained + how to avoid |
+| 2 | Portal tests pointed at a dead port (`5374`) | Wrong command setting | Explained + corrected |
+| 3 | `contract-lifecycle` failed all 5 of its tests | **Real bug in the test code** | ✅ **Fixed and verified** |
+| 4 | 5 other test files failed | Old app running inside Docker | Diagnosed, needs a rebuild |
+
+**The only real code bug was problem 3, and it is now fixed.** Problems 1, 2 and 4 are about *how the tests were run*, not about the application logic.
+
+---
+
+## 2. Some words explained (for beginners)
+
+Before the details, here are the words used in this report:
+
+- **E2E test (end-to-end test)** — a robot that opens a real web browser, clicks buttons like a real user, and checks the result is correct.
+- **Cypress** — the tool that drives that robot browser.
+- **Spec** — one test file. It ends with `.cy.ts`. Example: `contract-lifecycle.cy.ts`.
+- **Docker container** — a small, isolated box that runs one program (the API, the website, the database).
+- **Docker image** — a *frozen photograph* of your code. A container is started **from** an image. This idea matters a lot in problem 4.
+- **Port** — a numbered door on your computer. A program listens on one port. If you knock on the wrong door, nobody answers.
+
+---
+
+## 3. Result of the full test run
+
+I ran all 38 test files once, cleanly. This took about 44 minutes.
+
+```
+✖ 6 of 38 failed (16%) 44:17 259 tests 223 passing 36 failing
+```
+
+The 6 test files that failed:
+
+| Test file | Tests | Passed | Failed |
+|-----------|-------|--------|--------|
+| `contract-lifecycle.cy.ts` | 5 | 0 | **5** |
+| `export_one_time.cy.ts` | 16 | 9 | 7 |
+| `import_full_train.cy.ts` | 13 | 8 | 5 |
+| `intercity_one_time.cy.ts` | 16 | 11 | 5 |
+| `onboarding.cy.ts` | 3 | 1 | 2 |
+| `segment_weight.cy.ts` | 13 | 1 | 12 |
+
+The other 32 test files passed completely.
+
+---
+
+## 4. Problem 1 — The tests died immediately with `exit code 137`
+
+### What you saw
+
+The command stopped almost at once. There were no test results. The exit code was `137`.
+
+### What it means
+
+`137` means the program was **force-killed** by the operating system (it is `128 + 9`, where `9` is the "kill" signal). It is *not* a test failure. The tests never even started.
+
+### Why it happened
+
+Look at this part of `docker-compose.e2e.yaml`:
+
+```yaml
+cypress:
+ network_mode: host
+ # NOTE: host network shares the abstract X-socket namespace with the host.
+ # Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
+ # time, and don't run it on a host whose X server occupies :99.
+```
+
+Cypress needs a screen to draw the browser on. Because there is no real monitor, it creates a fake screen called **Xvfb** on display number **`:99`**.
+
+Because of `network_mode: host`, that fake screen is shared with the whole computer — **not** kept private inside the container.
+
+So if **two Cypress runs happen at the same time**, both try to take display `:99`. They fight, Chrome dies, and you get `137`.
+
+On this machine there was in fact **another Cypress run already going** (from a second terminal session), which is what killed my run.
+
+### The solution
+
+**Run only one Cypress container at a time.** Before starting, check nothing else is running:
+
+```bash
+docker ps --format '{{.Names}}' | grep cypress
+```
+
+If that command prints something, wait for it to finish. If it prints nothing, you are safe to start.
+
+> This was a problem with the machine being busy — **not** a problem with the tests or the application.
+
+---
+
+## 5. Problem 2 — `E2E_PORTAL_PORT=5374` pointed at a dead port
+
+### What you saw
+
+Tests that use the customer **portal** website failed. They could not open the page at all.
+
+### Why it happened
+
+Your command set the portal port to **5374**:
+
+```bash
+E2E_PORTAL_PORT=5374 docker compose ...
+```
+
+But the portal container was actually published on port **5373**. Here is the proof:
+
+```
+$ cat e2e/freight/.e2e-ports.json
+{ "E2E_API_PORT": 3101, "E2E_PORTAL_PORT": 5373, "E2E_BACKOFFICE_PORT": 5383, ... }
+
+$ docker ps
+edr-freight-e2e-freight-portal-e2e-1 0.0.0.0:5373->80/tcp
+```
+
+Here is the important detail. Setting `E2E_PORTAL_PORT=5374` **only changes where Cypress looks**. It does **not** move the already-running portal container. From `cypress.config.ts`:
+
+```ts
+portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
+```
+
+So Cypress knocked on door **5374**, but the portal was living behind door **5373**. Nobody answered.
+
+This affected the 5 test files that open the portal:
+`onboarding`, `contract-lifecycle`, `cross-app`, `export_one_time`, `intercity_one_time`.
+
+### The solution
+
+Use the port that matches the running container — simply leave the setting out, because `5373` is already the default:
+
+```bash
+docker compose -f docker-compose.e2e.yaml --profile cypress \
+ run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
+```
+
+Or, best of all, use the project's official launcher, which picks matching ports for everything automatically:
+
+```bash
+pnpm e2e:freight:run
+```
+
+> After using the correct port, `cross-app.cy.ts` passed 3/3 — proving the port was the only thing wrong there.
+
+---
+
+## 6. Problem 3 — The real bug: `contract-lifecycle` failed all 5 tests ✅ FIXED
+
+This was the one **genuine code problem**, and it is now fixed and verified.
+
+### What you saw
+
+```
+1) customer creates and submits a GENERAL import container contract:
+ AssertionError: Timed out retrying after 10000ms:
+ Expected to find element: `[role="checkbox"][aria-label="20ft Container"]`,
+ but never found it.
+```
+
+And then 4 more failures after it.
+
+### Why it happened
+
+The test was looking for a **checkbox** to choose the container size (20ft):
+
+```ts
+cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+cy.get('textarea[placeholder*="Electronics"]').type("E2E electronics shipment scope");
+```
+
+But **the application was deliberately changed**. Container contracts now automatically cover **both** 20ft and 40ft sizes, so the checkbox was removed and replaced by a simple information card. The cargo description was also moved to the booking step.
+
+You can see this clearly in the current application code, `step3-cargo-scope.tsx`:
+
+```tsx
+{/* Container scope: the contract always covers BOTH sizes and quotes both
+ rates. Quantities (a size can be 0) and the cargo description are
+ captured at booking time. */}
+{cargoType === "container" && (
+ ...
+ 20ft & 40ft containers covered
+```
+
+And the validation rules confirm nothing else is needed (`schema.ts`):
+
+```ts
+// Container scope needs no validation: both sizes are always in scope and
+// the cargo description moved to booking time.
+```
+
+So: **the application was updated, but the test was not.** The test kept looking for a button that no longer exists.
+
+**Why all 5 tests failed, not just one.** These 5 tests run in order and build on each other. Test 1 creates the contract; tests 2–5 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 2–5 had nothing correct to work on and failed too. This is called a **cascade failure** — one real error causing several fake-looking errors.
+
+### The fix
+
+I removed the two steps that referred to the deleted fields.
+
+**`e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts`**
+
+```diff
+- // Step 1 — Cargo & Route.
++ // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
++ // 40ft (no size picker — just an info card) and the cargo description moved
++ // to booking time, so the scope select plus the route is all this step needs.
+ cy.mantineSelect(/^Cargo Scope/, /Containerized/);
+- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+- cy.get('textarea[placeholder*="Electronics"]').type(
+- "E2E electronics shipment scope",
+- );
+ cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
+```
+
+I found the **same outdated code in two more test files** and fixed them as well, so the problem is solved everywhere and not just in one place:
+
+**`export_one_time.cy.ts`**
+
+```diff
+ cy.mantineSelect(/^Cargo Scope/, /Containerized/);
+- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+- cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
+- cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
+ cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
+```
+
+**`intercity_one_time.cy.ts`**
+
+```diff
+ cy.mantineSelect(/^Cargo Scope/, /Containerized/);
+- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+- cy.get('textarea[placeholder*="Electronics"]').type(
+- "E2E intercity electronics between Ethiopian yards",
+- );
+ cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
+```
+
+### A second, smaller bug found while checking the fix
+
+After the first fix, 4 of 5 tests passed and one still failed:
+
+```
+AssertionError: expected '
' to be 'visible'
+This element is not visible because its content is being clipped by one of its
+parent elements, which has a CSS property of overflow: hidden, clip, scroll or auto
+```
+
+The contract *was* created correctly. The word "Submitted" *was* on the screen. But it sits inside a side-scrolling list, so Cypress treated it as hidden.
+
+Because the line just after it already checks the database properly (which is the stronger, more trustworthy check), I made the screen check clip-proof:
+
+```diff
+- cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
++ // `exist`, not `be.visible`: the status badge sits inside the list's
++ // horizontally-scrolling container, so Cypress reports it as clipped by an
++ // overflow parent. The DB assertion below is the authoritative check.
++ cy.contains("Submitted", { timeout: 15000 }).should("exist");
+```
+
+### Proof that it works
+
+**Before the fix:**
+
+```
+contract-lifecycle.cy.ts 5 tests 0 passing 5 failing
+```
+
+**After the fix:**
+
+```
+✓ customer creates and submits a GENERAL import container contract (7574ms)
+✓ marketer accepts the submission and approves the LINE_STAFF step (3668ms)
+✓ director approves the final step — contract PDF becomes ready (2916ms)
+✓ customer signs the contract with OTP (5143ms)
+✓ staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE (3643ms)
+
+5 passing (29s) EXIT=0
+```
+
+✅ **All 5 tests now pass.**
+
+---
+
+## 7. Problem 4 — The remaining 5 test files: the app inside Docker is old
+
+This is the second most important finding, and it explains **almost all remaining failures**.
+
+### What you saw
+
+Many strange, unrelated-looking errors, for example:
+
+```
+CypressError: cy.request() failed on:
+http://localhost:3101/api/train-scheduling/schedules//dispatch
+The response we received from your web server was:
+ > 400: Bad Request
+```
+
+```
+AssertionError: expected '/dashboard/operations/train-scheduling-v2'
+to match /\/dashboard\/operations\/train-scheduling-v2\/.+/
+```
+
+```
+AssertionError: Expected to find content: 'Clearance Review' within the selector: '[role="tab"]'
+```
+
+### Why it happened
+
+**The test files are new, but the running application is old.**
+
+- The test files live on your disk and are shared into the container live, so they are always the newest version.
+- The API and websites run from **Docker images**, which are frozen photographs of the code. They only change when you **rebuild** them.
+
+Here are the actual times:
+
+```
+freight-api-e2e image built: 2026-07-23 09:22
+freight-portal-e2e image built: 2026-07-23 09:22
+freight-backoffice-e2e image built: 2026-07-23 09:22
+
+latest commit (HEAD): 2026-07-23 20:24 ← 11 hours newer
+```
+
+**5 commits were made after those images were built:**
+
+```
+668b5e1c add permissions and fix issues
+40f16f3c changes
+12767605 changes
+15a6bab5 revert back the clerance payment
+13609f8d changes
+```
+
+### The clearest proof
+
+Commit `668b5e1c` changed the **API and its tests together**, in the same commit:
+
+```
+apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+e2e/freight/cypress/e2e/flows/import-utils.ts
+e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
+```
+
+So the **new test** sends the **new** data shape to `/dispatch`, but the **old API** inside Docker does not understand it and replies `400 Bad Request`.
+
+This is not a bug in the code. It is simply **new tests talking to an old server**.
+
+The `train-scheduling-v2` failures have the same cause: creating a train schedule fails on the old server, so no train exists, and every later step in those files fails as a cascade (this is why `segment_weight` lost 12 of 13 tests from a single root cause).
+
+### The solution
+
+Rebuild the Docker images so they contain the current code, then run the tests again:
+
+```bash
+# stop the old stack and rebuild from current code
+docker compose -f docker-compose.e2e.yaml down
+docker compose -f docker-compose.e2e.yaml build
+pnpm e2e:freight:run
+```
+
+> ⚠️ Note: `down` deletes the test database (it is a throwaway database, which is normal and safe).
+> ⚠️ Note: only do this when nobody else is using the same stack.
+
+---
+
+## 8. Files I changed
+
+Only test files were changed. **No application code was modified.**
+
+| File | Change |
+|------|--------|
+| `e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts` | Removed deleted container-size checkbox + description box; made the "Submitted" check clip-proof; updated the header comment |
+| `e2e/freight/cypress/e2e/flows/export_one_time.cy.ts` | Removed deleted 20ft + 40ft checkboxes and description box |
+| `e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts` | Removed deleted 20ft checkbox and description box |
+
+Evidence the last two fixes helped, even on the old server:
+
+- `export_one_time` — previously failed at contract creation; now reaches 9 passing tests.
+- `intercity_one_time` — previously failed at contract creation; now reaches 11 passing tests.
+
+Their remaining failures are all from problem 4 (old Docker images).
+
+---
+
+## 9. How to run the tests correctly
+
+**Step 1 — make sure no other Cypress is running** (this avoids the `137` crash):
+
+```bash
+docker ps --format '{{.Names}}' | grep cypress
+```
+
+**Step 2 — rebuild so Docker has the current code:**
+
+```bash
+docker compose -f docker-compose.e2e.yaml down
+docker compose -f docker-compose.e2e.yaml build
+```
+
+**Step 3 — run the tests using the official launcher** (it chooses matching ports for you):
+
+```bash
+pnpm e2e:freight:run
+```
+
+If you prefer the raw Docker command, **do not** override the portal port unless you also restart the portal container on that same port:
+
+```bash
+docker compose -f docker-compose.e2e.yaml --profile cypress \
+ run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
+```
+
+---
+
+## 10. Conclusion
+
+- **One real bug was found and fixed:** three test files were still clicking a container-size checkbox and a description box that the application no longer has, because container contracts now cover both 20ft and 40ft automatically.
+- `contract-lifecycle.cy.ts` went from **0 of 5 passing** to **5 of 5 passing**, confirmed by running it twice.
+- The `exit 137` crash was caused by **two Cypress runs at the same time** fighting over the shared virtual screen `:99`.
+- The portal failures were caused by **`E2E_PORTAL_PORT=5374`**, while the portal was really on **5373**.
+- The remaining failures are caused by **Docker images that are 11 hours older than the code**. They need a rebuild, not a code fix.
+
+**Recommended next step:** rebuild the Docker images and run the full suite again. Only then can the remaining 5 test files be judged fairly.
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index d80ce75c6..d6a39bfed 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
+# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
+SUPER_ADMIN_DEFAULT_PASSWORD=
DEFAULT_PASSWORD=password@tria
+# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
+# position types, organization types + default units, org/unit settings, super
+# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
+# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
+# Set to false to opt out.
+SEED_IAM_BASELINE=true
+
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
@@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
+# OAuth redirect_uri for the customer portal (its own origin — must also be
+# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
+FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index cd87dd2d3..b3ed1c802 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -27,6 +27,7 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
+ "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
@@ -35,13 +36,13 @@
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
- "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"migration:run": "node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
+ "@edr/iam-seed": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 9270dd0a1..a3c09df97 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
+ RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -12,6 +13,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
+import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -29,6 +31,8 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
+import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
+import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -99,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
+import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -152,12 +157,26 @@ import { LoggerMiddleware } from "./logger.middleware";
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
+ // Replaces the package's DataSeeder. Shared with edr-passenger-api, which
+ // seeds the same `iam` schema — see packages/iam-seed.
+ IamSeedModule.forRoot({
+ superAdmin: {
+ username: "superadmin",
+ name: { am: "ሱፐር አድሚን", en: "Super Admin" },
+ roleKey: "super_admin",
+ organizationKey: "edr_freight",
+ unitKey: "edr_freight_app",
+ fallbackEmail: "superadmin@tria.com",
+ },
+ }),
BookingsModule,
ContractsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
LocomotivesModule,
+ TruckTypesModule,
+ TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -225,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
+ LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
- // private readonly seeder: DataSeeder,
+ private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -259,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap {
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
- // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
+ // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
+ // position types, organization types +
+ // default units, org/unit settings and the
+ // super-admin account. Replaces the package's
+ // DataSeeder, and is shared with
+ // edr-passenger-api so one writer owns the
+ // `iam` schema. Runs after edrOrgSeeder
+ // because the super admin attaches to the
+ // edr_freight org/unit.
+ // Writes nothing unless SEED_IAM_BASELINE=true.
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
- // await this.seeder.run();
await this.edrOrgSeeder.run();
+ await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// File upload settings — keep enabled.
@@ -304,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
+ consumer.apply(LoginAudienceMiddleware).forRoutes(
+ { path: "auth/login", method: RequestMethod.POST },
+ { path: "auth/mfa-verify", method: RequestMethod.POST },
+ );
}
}
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index a412bf990..854594ffc 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -23,11 +23,34 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
+/**
+ * The document-review countdown in the backoffice header. Its own permission so
+ * it can be granted to exactly the position types that decide operation
+ * requests, instead of every holder of bookings:view.
+ */
+export const BookingDocReviewAlert = () =>
+ BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
+
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
-export const TrainSchedulingManage = () =>
- BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
+// Granular train-scheduling actions replace the retired coarse manage:
+// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
+// cancel a schedule, reschedule (+ maintenance), and manage global rules.
+export const TrainSchedulingCreate = () =>
+ BookingStaff(FREIGHT_PERMS.trainScheduling.create);
+
+export const TrainSchedulingUpdate = () =>
+ BookingStaff(FREIGHT_PERMS.trainScheduling.update);
+
+export const TrainSchedulingCancel = () =>
+ BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
+
+export const TrainSchedulingReschedule = () =>
+ BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
+
+export const TrainSchedulingRulesManage = () =>
+ BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
@@ -58,6 +81,32 @@ export const WagonTransferFulfill = () =>
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
+/**
+ * Open the transfer-requests desk. `wagons:view` is accepted as a one-of
+ * fallback so staff who could already reach the queue keep it without a
+ * re-grant — same pattern the granular fleet keys use.
+ */
+export const WagonTransferView = () =>
+ BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
+
+/** Withdraw a request that has not moved any wagon yet. */
+export const WagonTransferCancel = () =>
+ BookingStaff([
+ FREIGHT_PERMS.wagons.transferCancel,
+ FREIGHT_PERMS.wagons.transferRequest,
+ ]);
+
+/**
+ * End a request short of the requested count. Whoever may move wagons may also
+ * declare the yard has no more to give, so fulfil is accepted alongside the
+ * dedicated key.
+ */
+export const WagonTransferCloseShort = () =>
+ BookingStaff([
+ FREIGHT_PERMS.wagons.transferCloseShort,
+ FREIGHT_PERMS.wagons.transferFulfill,
+ ]);
+
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
new file mode 100644
index 000000000..4658a5434
--- /dev/null
+++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
@@ -0,0 +1,47 @@
+import { ForbiddenException } from '@nestjs/common';
+
+import {
+ assertCanApproveContractStep,
+ canEditContractStep,
+} from './freight-permission.util';
+import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
+
+const userWith = (...keys: string[]) => ({
+ permissions: keys.map((key) => ({ key })),
+});
+
+describe('hazardous contract approval steps', () => {
+ it('rejects an approver who only holds ordinary contract-approve permissions', () => {
+ // The blanket "any contract approve permission" fallback must NOT reach
+ // dangerous goods — that is the whole point of the dedicated desks.
+ const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
+
+ expect(() =>
+ assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
+ ).toThrow(ForbiddenException);
+ expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
+ });
+
+ it('accepts only the matching hazardous permission', () => {
+ const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
+
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
+ ).not.toThrow();
+ // Holding step one does not confer step two.
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
+ ).toThrow(ForbiddenException);
+ });
+
+ it('does not let a hazardous approver stand in for the commercial chain', () => {
+ const hazardOnly = userWith(
+ FREIGHT_PERMS.contracts.hazardousApprovalOne,
+ FREIGHT_PERMS.contracts.hazardousApprovalTwo,
+ );
+
+ expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
+ ForbiddenException,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts
index 429c910d3..56c0e77c2 100644
--- a/apps/edr-freight-api/src/common/freight-permission.util.ts
+++ b/apps/edr-freight-api/src/common/freight-permission.util.ts
@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = {
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
+/**
+ * Approval-chain roles synthesized for hazardous contracts (see
+ * `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
+ * position types — they authorize purely on their own dedicated permission, and
+ * they deliberately opt out of the blanket "holds any contract-approve
+ * permission" fallback so a normal approver cannot sign off dangerous goods.
+ */
+export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = {
+ HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
+ HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
+};
+
+/** The two hazardous steps, in the order they are prepended to the chain. */
+export const HAZARDOUS_APPROVAL_ROLES = [
+ 'HAZARDOUS_APPROVAL_ONE',
+ 'HAZARDOUS_APPROVAL_TWO',
+] as const;
+
const CONTRACT_APPROVE_ROLE_PERMISSION: Record = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
): void {
if (isFreightApprovalAdmin(user)) return;
+ // Hazardous steps are permission-only and strict — no legacy alias, no
+ // blanket approve fallback.
+ const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
+ if (hazardousPermission) {
+ if (hasFreightPermission(user, hazardousPermission)) return;
+ throw new ForbiddenException(
+ `Missing permission: ${hazardousPermission}`,
+ );
+ }
+
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
@@ -219,6 +247,11 @@ export function canEditContractStep(
): boolean {
if (isFreightApprovalAdmin(user)) return true;
+ const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
+ if (hazardousPermission) {
+ return hasFreightPermission(user, hazardousPermission);
+ }
+
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts
new file mode 100644
index 000000000..d95c934ca
--- /dev/null
+++ b/apps/edr-freight-api/src/common/grn.util.spec.ts
@@ -0,0 +1,40 @@
+import { generateGrnNumber, grnOwnerSlug } from './grn.util';
+
+/**
+ * The GRN is mapped to the goods owner for BOTH directions, so a note is
+ * identifiable by who owns the cargo. The reference slice stays the uniqueness
+ * anchor — one owner can have several bookings received the same day.
+ */
+const date = new Date('2026-07-27T09:15:00Z');
+const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
+
+describe('GRN number', () => {
+ it('maps an import GRN to the owner', () => {
+ expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
+ 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
+ );
+ });
+
+ it('maps an export GRN to the owner the same way', () => {
+ expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
+ 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
+ );
+ });
+
+ it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
+ expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
+ expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
+ });
+
+ it('stays unique per booking for one owner on one day', () => {
+ const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
+ const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
+ expect(a).not.toBe(b);
+ });
+
+ it('strips punctuation and caps the owner segment', () => {
+ expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
+ expect(grnOwnerSlug('a/b c')).toBe('ABC');
+ expect(grnOwnerSlug(null)).toBeNull();
+ });
+});
diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts
index 5cae30302..128e0496a 100644
--- a/apps/edr-freight-api/src/common/grn.util.ts
+++ b/apps/edr-freight-api/src/common/grn.util.ts
@@ -1,13 +1,41 @@
/**
- * Goods Received Note number: `GRN---`.
+ * Goods Received Note number: `GRN----`.
+ *
+ * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
+ * both import and export, so a note is identifiable by who owns the cargo
+ * without opening it. The trailing reference slice stays as the uniqueness
+ * anchor — one owner can have several bookings received on the same day.
+ * Owner-less receipts (manual walk-ins with no booking) fall back to the
+ * original `GRN---` form.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
-export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
+export function generateGrnNumber(
+ direction: string,
+ referenceId: string,
+ date: Date,
+ ownerName?: string | null,
+): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
- return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
+ const owner = grnOwnerSlug(ownerName);
+ const base = `GRN-${direction.toUpperCase()}-${stamp}`;
+ return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
+}
+
+/**
+ * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
+ * long company name can't run away with the number. Null when there is nothing
+ * usable, which drops the segment rather than emitting an empty `--`.
+ */
+export function grnOwnerSlug(ownerName?: string | null): string | null {
+ const slug = (ownerName ?? '')
+ .normalize('NFKD')
+ .replace(/[^a-zA-Z0-9]+/g, '')
+ .toUpperCase()
+ .slice(0, 12);
+ return slug || null;
}
diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts
index 2f5288048..f22e86925 100644
--- a/apps/edr-freight-api/src/common/mile-financials.util.ts
+++ b/apps/edr-freight-api/src/common/mile-financials.util.ts
@@ -8,6 +8,8 @@ type MileRecord = {
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
+ /** Attached here: the train schedule the booking rides, for mile alignment. */
+ trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
} | null;
};
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
+ // Train alignment: which schedule each booking rides (mile pickups/deliveries
+ // are planned against the train's departure).
+ const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
+ if (bookingIds.length) {
+ const schedules: Array<{
+ bookingId: string;
+ trainNumber: string | null;
+ departureDate: string | null;
+ }> = await dataSource.query(
+ `SELECT DISTINCT ON (tsb.booking_id)
+ tsb.booking_id AS "bookingId",
+ ts.train_number AS "trainNumber",
+ COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
+ FROM freight.train_schedule_bookings tsb
+ JOIN freight.train_schedules ts
+ ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
+ WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
+ ORDER BY tsb.booking_id, tsb.created_at DESC`,
+ [bookingIds],
+ );
+ const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
+ for (const r of records) {
+ const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
+ if (r.booking && s) {
+ r.booking.trainSchedule = {
+ trainNumber: s.trainNumber,
+ departureDate: s.departureDate,
+ };
+ }
+ }
+ }
+
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);
diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts
index 14c0385ee..ba8096b5b 100644
--- a/apps/edr-freight-api/src/common/rule-engine-guards.ts
+++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts
@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
-export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
+// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
+// endpoint carries the specific action it performs — create on POST-new,
+// update on PATCH / reorder / move-order, delete on DELETE.
+export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
- UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
+ UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
+ );
+
+export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
+ applyDecorators(
+ UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
+ );
+
+export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
+ applyDecorators(
+ UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
);
/**
diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts
index a25289159..30525dd01 100644
--- a/apps/edr-freight-api/src/config/fayda.config.ts
+++ b/apps/edr-freight-api/src/config/fayda.config.ts
@@ -15,7 +15,7 @@ export interface FaydaJwk {
qi?: string;
}
-export type FaydaPlatform = 'WEB' | 'MOBILE';
+export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
export interface FaydaConfig {
enabled: boolean;
@@ -25,8 +25,10 @@ export interface FaydaConfig {
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
- /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
+ /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
webRedirectUri: string;
+ /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
+ portalRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
+ const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
if (!enabled) {
return {
enabled: false,
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
+ portalRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
+ portalRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index 363008660..eb9541d8e 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
+import { hazardClassLabel } from '@edr/types';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import {
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
+ /** Company stamp/seal; rendered next to the signature when present. */
+ stampImageUrl?: string | null;
}
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
+ // Signed before company stamps were required — the customer has to sign
+ // again to attach one, otherwise EDR can never counter-sign the contract.
+ const customerStampMissing = signatures.some(
+ (s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
+ );
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
- canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignCustomer:
+ (contract.status === 'CONTRACT_READY' && !hasCustomer) ||
+ (contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
+ stampImageUrl: row.stampFile?.url ?? null,
};
}
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
- hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
+ // A hazardous contract names the declared class + UN number on the
+ // schedule — the flag alone is not a dangerous-goods declaration.
+ hazardousLabel: contract.isHazardous
+ ? [
+ hazardClassLabel(contract.hazardClass) ?? 'Yes',
+ contract.unNumber ? `UN ${contract.unNumber}` : null,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ : 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
index 8b8b92f09..07f4d25d3 100644
--- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
pricing,
rateSchedule,
signatures,
- canSignCustomer:
- booking.status === 'CONTRACT_READY' && !hasCustomer,
- canSignStaff:
- booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
+ // Government contracts are generated at creation and signable at any
+ // time, in any order — no status gate, no customer-first sequencing.
+ canSignCustomer: booking.isGovernment
+ ? !hasCustomer
+ : booking.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignStaff: booking.isGovernment
+ ? !hasStaff
+ : booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
index 05dd450e4..59cfceb0b 100644
--- a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
@@ -11,6 +11,12 @@
Name: {{signerDisplayName}}
Role: Authorized EDR representative
Date: {{signedAt}}
+ {{#if stampImageUrl}}
+
+ {{/if}}
{{/if}}
{{/each}}
{{else}}
@@ -32,6 +38,12 @@
Name: {{signerDisplayName}}
Role: Authorized client representative
Date: {{signedAt}}
+ {{#if stampImageUrl}}
+
+ {{/if}}
{{/if}}
{{/each}}
{{else}}
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
index 118606065..d9bc9927f 100644
--- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
@@ -372,25 +372,30 @@
font-size: 9pt;
margin: 4px 0;
}
-
- /* ── Witnesses ────────────────────────────────────────────────────────── */
- .witnesses { margin-top: 20px; }
- .witness-table {
- font-size: 9.5pt;
- margin-top: 6px;
+ .sig-stamp {
+ margin-top: 12px;
}
- .witness-table th,
- .witness-table td {
- border-bottom: 1px solid #c9e4d9;
- padding: 9px 8px;
- text-align: left;
- }
- .witness-table th {
+ .sig-stamp-label {
color: #0e5b45;
font-family: Arial, sans-serif;
- font-size: 8.5pt;
+ font-size: 7.5pt;
+ font-weight: 700;
+ letter-spacing: 0.4pt;
text-transform: uppercase;
}
+ .sig-stamp-box {
+ align-items: center;
+ display: flex;
+ height: 30mm;
+ justify-content: center;
+ margin-top: 5px;
+ }
+ .sig-stamp-box img {
+ display: block;
+ max-height: 30mm;
+ max-width: 45mm;
+ mix-blend-mode: multiply;
+ }
@media print {
body { background: #fff; }
diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
index 0e06d9f7b..9c4957b2b 100644
--- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
@@ -147,19 +147,6 @@
authorized to sign and execute this Contract Agreement.
{{> signatures_block}}
-
-
-
Witnesses
-
-
- | Name | Signature | Date |
-
-
- | 1. | | | |
- | 2. | | | |
-
-
-