diff --git a/.portal-flows-assets/00-invoice-detail-pay.png b/.portal-flows-assets/00-invoice-detail-pay.png
new file mode 100644
index 000000000..c57f4b977
Binary files /dev/null and b/.portal-flows-assets/00-invoice-detail-pay.png differ
diff --git a/.portal-flows-assets/01-invoices-due-full.png b/.portal-flows-assets/01-invoices-due-full.png
new file mode 100644
index 000000000..bea5dc558
Binary files /dev/null and b/.portal-flows-assets/01-invoices-due-full.png differ
diff --git a/.portal-flows-assets/02-login-full.png b/.portal-flows-assets/02-login-full.png
new file mode 100644
index 000000000..f0820e0a8
Binary files /dev/null and b/.portal-flows-assets/02-login-full.png differ
diff --git a/.portal-flows-assets/03-hager-invoices-empty-full.png b/.portal-flows-assets/03-hager-invoices-empty-full.png
new file mode 100644
index 000000000..2b56a9fcd
Binary files /dev/null and b/.portal-flows-assets/03-hager-invoices-empty-full.png differ
diff --git a/.portal-flows-assets/04-contracts-list-full.png b/.portal-flows-assets/04-contracts-list-full.png
new file mode 100644
index 000000000..99763e1b2
Binary files /dev/null and b/.portal-flows-assets/04-contracts-list-full.png differ
diff --git a/.portal-flows-assets/05-home-full.png b/.portal-flows-assets/05-home-full.png
new file mode 100644
index 000000000..3eb3d0d6c
Binary files /dev/null and b/.portal-flows-assets/05-home-full.png differ
diff --git a/.portal-flows-assets/06-contract-detail-signed-full.png b/.portal-flows-assets/06-contract-detail-signed-full.png
new file mode 100644
index 000000000..801016e19
Binary files /dev/null and b/.portal-flows-assets/06-contract-detail-signed-full.png differ
diff --git a/.portal-flows-assets/07-contract-step1-full.png b/.portal-flows-assets/07-contract-step1-full.png
new file mode 100644
index 000000000..43b2fe8b0
Binary files /dev/null and b/.portal-flows-assets/07-contract-step1-full.png differ
diff --git a/.portal-flows-assets/08-contract-step1-filled-full.png b/.portal-flows-assets/08-contract-step1-filled-full.png
new file mode 100644
index 000000000..02cbf4d03
Binary files /dev/null and b/.portal-flows-assets/08-contract-step1-filled-full.png differ
diff --git a/.portal-flows-assets/09-contract-step2-full.png b/.portal-flows-assets/09-contract-step2-full.png
new file mode 100644
index 000000000..193fc5252
Binary files /dev/null and b/.portal-flows-assets/09-contract-step2-full.png differ
diff --git a/.portal-flows-assets/10-contract-step3-review-full.png b/.portal-flows-assets/10-contract-step3-review-full.png
new file mode 100644
index 000000000..5bbd72746
Binary files /dev/null and b/.portal-flows-assets/10-contract-step3-review-full.png differ
diff --git a/.portal-flows-assets/11-contract-duplicate-blocked-full.png b/.portal-flows-assets/11-contract-duplicate-blocked-full.png
new file mode 100644
index 000000000..c93a06db1
Binary files /dev/null and b/.portal-flows-assets/11-contract-duplicate-blocked-full.png differ
diff --git a/.portal-flows-assets/11b-quotation-approve-full.png b/.portal-flows-assets/11b-quotation-approve-full.png
new file mode 100644
index 000000000..f381f4988
Binary files /dev/null and b/.portal-flows-assets/11b-quotation-approve-full.png differ
diff --git a/.portal-flows-assets/13-bookings-list-full.png b/.portal-flows-assets/13-bookings-list-full.png
new file mode 100644
index 000000000..8477b2a76
Binary files /dev/null and b/.portal-flows-assets/13-bookings-list-full.png differ
diff --git a/.portal-flows-assets/14-booking-documents-modal-full.png b/.portal-flows-assets/14-booking-documents-modal-full.png
new file mode 100644
index 000000000..b58f9b919
Binary files /dev/null and b/.portal-flows-assets/14-booking-documents-modal-full.png differ
diff --git a/.portal-flows-assets/15-nati-contracts-list-full.png b/.portal-flows-assets/15-nati-contracts-list-full.png
new file mode 100644
index 000000000..191f75f81
Binary files /dev/null and b/.portal-flows-assets/15-nati-contracts-list-full.png differ
diff --git a/.portal-flows-assets/16-nati-contracts-list-actions.png b/.portal-flows-assets/16-nati-contracts-list-actions.png
new file mode 100644
index 000000000..d7fd73df7
Binary files /dev/null and b/.portal-flows-assets/16-nati-contracts-list-actions.png differ
diff --git a/.portal-flows-assets/17-initiate-booking-confirm.png b/.portal-flows-assets/17-initiate-booking-confirm.png
new file mode 100644
index 000000000..16b0b481d
Binary files /dev/null and b/.portal-flows-assets/17-initiate-booking-confirm.png differ
diff --git a/.portal-flows-assets/18-nati-bookings-list-actions.png b/.portal-flows-assets/18-nati-bookings-list-actions.png
new file mode 100644
index 000000000..f4446db24
Binary files /dev/null and b/.portal-flows-assets/18-nati-bookings-list-actions.png differ
diff --git a/.portal-flows-assets/19-complete-booking-cargo-full.png b/.portal-flows-assets/19-complete-booking-cargo-full.png
new file mode 100644
index 000000000..1cb7c216a
Binary files /dev/null and b/.portal-flows-assets/19-complete-booking-cargo-full.png differ
diff --git a/.portal-flows-assets/portal-flows.html b/.portal-flows-assets/portal-flows.html
new file mode 100644
index 000000000..2bbd40681
--- /dev/null
+++ b/.portal-flows-assets/portal-flows.html
@@ -0,0 +1,312 @@
+
+
+
+
+
EDR Freight — Priority Flows
+
Portal Side
Contract Signing · Booking · Payment
+
A step-by-step walkthrough of the same three flows, driven live as the real
+ customer — every screen, button, and validation message as the account actually saw it.
+
+
+
+
+ 1 · Contract creation & signing
+ Portal customer hager@gmail.com, logged into the freight portal (localhost:5273).
+
+
+
+
The portal login form. A customer signs in with their email and password — no OTP step for an already-onboarded account (OTP only applies during self-signup).
+

+
+
+
+
+
Landing page after login: quick stats and shortcuts into Contracts, Bookings, and Invoices.
+

+
+
+
+
+
The customer's contracts: one Active (CTR-2026-00043, Fully Executed) and two In Progress (CTR-2026-00086, CTR-2026-00087 — both Submitted, awaiting EDR review). "New Contract" starts a fresh one.
+

+
+
+
+
4
New Contract — Step 1: Setup
+
Operation Type, Contract Kind (One-Time vs. Framework), New-vs-Renewal, and the Service Type cards — the entry point of the wizard.
+

+
+
+
+
5
Step 1 filled — Operation Type + Service
+
Operation Type set to Import and "Rail Transport with Customs" selected — this reveals the trucking/customs options below (first mile, last mile, and the included customs clearing service).
+

+
+
+
+
+
Cargo scope (containerised, both 20ft/40ft), and the origin/destination yard pair that defines the route.
+

+
+
+
+
+
Final review of the assembled contract terms — operation, service, route, cargo scope, and customs handling — before submitting.
+

+
+
+
+
+
Submitting immediately opens a per-unit rate quotation for the customer to approve — freight rates for 20ft/40ft containers plus the customs clearance service fee per container size. The dialog is explicit that these are per-unit rates, not a total: the payable amount is computed per booking from the quantities actually shipped. Approving submits the contract for EDR staff review.
+

+
+
+
+
9
Real validation: duplicate contract blocked
+
Re-submitting a route/service/cargo-scope combination that already has an active or in-review contract is rejected outright, naming the conflicting contract by number. This is a genuine business rule hit live, not staged.
+

+
+
+
+
Once EDR approves the contract, the customer signs it
+ A submitted contract moves through EDR review as:
Submitted → Pending Approval → Approved →
+ Approved Pending Signature → Contract Ready. The "View & sign contract" button only
+ appears once the contract reaches
Contract Ready — it shows on the Contracts list row and
+ on the contract's own detail page, and opens a dedicated preview-and-sign page
+ (
/contracts/:id/view), not a plain button on the detail page.
+
+ - EDR staff approve the contract and price it.
+ - The customer is notified the moment it's ready to sign — SMS, email, and an in-app
+ notification all fire together (per the customer's description of the real notification
+ flow — not re-verified against the notification-sending code for this document).
+ - The customer opens the notification or the Contracts list, clicks "View & sign
+ contract", scrolls the full contract text, and ticks the consent checkbox.
+ - They draw or reuse a saved signature, upload a company stamp, then verify by OTP
+ (sent to their registered phone/email) to finalize the signature.
+ - Status moves to Signed — Awaiting Staff, then Fully Executed once EDR counter-signs.
+
+ CTR-2026-00086 and CTR-2026-00087 above are still at Submitted — EDR hasn't approved them yet,
+ so no sign button is showing for either.
+
+
+
+
11
Where "View & sign" actually shows (a different customer's contract list)
+
A second real account (nati@gmail.com, 17 contracts) confirms the button live: its Action column carries "View", "Request shipment", "Book shipment", and "Initiate booking" depending on each row's status — the same column shows "View & sign" the moment a row reaches Contract Ready.
+

+
+
+
+ The signable window is real and it closes
+ This account's own CTR-2026-00089 was captured with a live "View & sign" button two days ago
+ (per the customer's own screenshot) — by the time this document was rebuilt, that same contract
+ had already moved to Expired with no button left to click. Contract Ready is a real,
+ time-boxed state, not a permanent one. No currently-open contract exists on either test account
+ at time of writing, so the sign page itself (scroll-to-consent → draw signature → stamp → OTP)
+ could not be re-captured live in this pass; it was documented from the customer's own screenshots
+ and code inspection instead.
+
+
+
+
10
Fully executed contract — signatures
+
CTR-2026-00043's detail page: both STAFF and CUSTOMER signatures are complete and the contract is Fully Executed — the end state of the flow described above.
+

+
+
+
+
+ 2 · Booking
+ A portal customer does not have a bare "New booking" button on the Bookings page
+ itself — booking creation is triggered from a signed contract's row, once EDR has cleared it for
+ shipment (contract status Active). What that entry point actually is depends on the contract's
+ state: "Initiate booking" opens a brand-new booking; "Book shipment" / "Book" continues one EDR
+ has already pre-cleared.
+
+
+
1
Initiate booking — confirmation
+
Clicking "Initiate booking" on an Active contract (CTR-2026-00071) doesn't create the booking immediately — it confirms first: "This creates a new shipment booking under contract CTR-2026-00071. You'll upload the import documents next, and the shipment quantity is drawn down from your contract's reserved capacity." Cancelled here rather than completed, since creating a live booking is a real mutation.
+

+
+
+
+
2
Complete Your Booking — cargo details
+
Following an already-cleared booking's "Book" action (BK-2026-000209, Bulk cargo under CTR-2026-00093) lands on this form: the contract's fixed route, a cargo-details block (Total weight for Bulk cargo — a Container-scoped contract shows an Excel-import table instead), and a Schedule block picking the binding shipment day. The billing currency here is fixed to ETB, paid through the payment gateway. The calendar is real and reactive: it read "Enter your cargo details first — available shipment days depend on the wagons your cargo needs," and no day was actually open on this route at the time of capture (0 available days) — the same live batch-window behaviour documented in Section 1.
+

+
+
+
+
3
Bookings list — one contract's view
+
This account's one existing booking, BK-2026-000068 on CTR-2026-00043 — status "In review," amount still ETB 0.00 because pricing is finalised after document review, not at booking creation. Every row carries a "Review documents" action.
+

+
+
+
+
+
Clicking "Review documents" opens this in place, no page navigation. All four required import documents — Bill of Lading/Waybill, Packing List, Import declaration, and Ethiopia T1 & Djibouti T1 — show status "Under review," each with View/Download and a "Replace file" drop zone. The banner is explicit: "Only re-upload the documents flagged with a query below — approved documents stay as they are." "Submit documents" stays disabled while nothing is flagged.
+

+
+
+
+
5
Bookings list — the fuller picture (nati@gmail.com, 38 bookings)
+
Across a larger real history the Action column carries every state at once: "Book" (cleared, awaiting cargo details), "View" (nothing to do), "Rebook wagons" (a cancelled reservation), "Pay [amount]" (booked, payment outstanding), "Review documents" / "Documents", and "Track" once a train is assigned. BK-2026-000188 shows "Pay 16,323.25 ETB" while its own Status column reads Cancelled — a live inconsistency (the booking lapsed after its payment window closed, but the stale Pay action hadn't been cleared from the row).
+

+
+
+
+
+ 3 · Payment
+ The customer-facing payment surface is the Invoices page.
+
+
+
1
Invoices — hager@gmail.com (nothing to pay)
+
Outstanding, Overdue, and Total Invoices all read 0. This lines up with the Booking flow above: BK-2026-000068 is still in document review and hasn't been priced, so nothing has been invoiced to this customer yet. The customer supplied a second real login (nati@gmail.com) to reach the actual payment screen.
+

+
+
+
+
2
Invoices — nati@gmail.com, filtered to Due
+
This account has a long invoice history (35 invoices) and 5 currently Outstanding. The status filter's real options are Due / Overdue / Paid / Draft / Cancelled / Refunded; filtering to "Due" narrows the list to what's actually payable right now, each row with a "Pay" action.
+

+
+
+
+
3
Invoice detail — the Pay screen
+
Opening INV-20260820-00017 (Br 200.00, a port-charges line item off booking BK-2026-000112) shows the full detail a customer sees before paying: billed-to company, source, issue/due dates, the line-item breakdown, a "Download invoice" action, and a "Pay Br 200.00" button sized to the exact amount due.
+

+
+
+
+ What "Pay" opens (from the customer's own screenshot)
+ Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning —
+ "Pay only from a bank account registered under your company name — DE BE KE. A payment sent
+ from an account under any other name will not be recognized as paid" — a payment-method
+ picker (the option shown was CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"),
+ a "Secured — you'll be redirected to your provider to pay" note, then "Continue to payment".
+ This was captured by the customer directly, not by this document — every attempt here to click a
+ live "Pay" button (on this invoice and on two different bookings) was blocked by the harness's own
+ financial-mutation guard before the dialog could be reached.
+
+
+
+
Paying by bank transfer — step by step (customer-provided process)
+
+ - Open your internet banking app or account.
+ - Copy the PNR code EDR sent you by SMS and email.
+ - In your bank's payment menu, choose Travel, then Land Transport, then
+ EDR Freight as the biller.
+ - Paste the PNR code into the reference/biller-code field.
+ - Confirm the amount shown matches the invoice total, then pay.
+
+ This is the settlement path behind the "Pay" button above — the bank confirms the PNR against
+ EDR's own records, so the code must match exactly what was sent. These steps were supplied
+ directly by the customer describing the real banking flow; they are not something this
+ document observed inside the portal UI itself, which only exposes the "Pay" button shown above.
+
+
+
+ Stopped short of submitting the payment — three separate attempts, all blocked
+ Clicking "Pay Br 200.00" on the invoice above, and separately clicking "Pay" on two different
+ bookings (BK-2026-000195 and BK-2026-000188) while rebuilding this document, were all blocked by
+ the harness's own auto-mode permission classifier as a real financial mutation against the shared
+ dev database — the same guard that blocked "Confirm paid" on the backoffice side. No workaround
+ was attempted, per the classifier's own instruction. The invoice screen above is the actual entry
+ point a customer uses; the backoffice-side companion document (EDR-Freight-Priority-Flows.pdf)
+ covers staff-side settlement (Transactions → Manual Payments).
+
+
+
+
+
diff --git a/.portal-flows-assets/render.mjs b/.portal-flows-assets/render.mjs
new file mode 100644
index 000000000..4b0780043
--- /dev/null
+++ b/.portal-flows-assets/render.mjs
@@ -0,0 +1,25 @@
+import { chromium } from '/home/tria/projects/hagernesh/edr-platform/node_modules/.pnpm/playwright-core@1.61.1/node_modules/playwright-core/index.mjs';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+const dir = path.dirname(new URL(import.meta.url).pathname);
+let html = readFileSync(path.join(dir, 'portal-flows.html'), 'utf8');
+
+html = html.replace(/\{\{([\w.-]+\.png)\}\}/g, (_, file) => {
+ const buf = readFileSync(path.join(dir, file));
+ return `data:image/png;base64,${buf.toString('base64')}`;
+});
+
+const browser = await chromium.launch({
+ executablePath: '/home/tria/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome',
+});
+const page = await browser.newPage();
+await page.setContent(html, { waitUntil: 'load' });
+await page.pdf({
+ path: '/home/tria/projects/hagernesh/edr-platform/EDR-Freight-Priority-Flows-Portal.pdf',
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '14mm', bottom: '16mm', left: '14mm', right: '14mm' },
+});
+await browser.close();
+console.log('done');
diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf
index fe37cc9e8..8d8115757 100644
Binary files a/EDR-Freight-Priority-Flows-Portal.pdf and b/EDR-Freight-Priority-Flows-Portal.pdf differ
diff --git a/INV-20260812-00005-mor.pdf b/INV-20260812-00005-mor.pdf
new file mode 100644
index 000000000..4ce89c534
Binary files /dev/null and b/INV-20260812-00005-mor.pdf differ
diff --git a/INV-20260812-00005-thermal.pdf b/INV-20260812-00005-thermal.pdf
new file mode 100644
index 000000000..21df74ca7
Binary files /dev/null and b/INV-20260812-00005-thermal.pdf differ
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 0c6b1c727..f36b36382 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -18,6 +18,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
+ "seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
@@ -31,6 +32,7 @@
"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",
+ "seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index b397e5d11..84e3e4b7a 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -117,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
+import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module";
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
@@ -259,6 +260,7 @@ if (!process.env.APPLICATION_NAME) {
FirstMileModule,
LastMileModule,
LastMileRequestsModule,
+ EmptyReturnRequestsModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
new file mode 100644
index 000000000..817e967f8
--- /dev/null
+++ b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
@@ -0,0 +1,148 @@
+import {
+ collectAllPositions,
+ resolveActiveEmployee,
+ type SnapshotEmployee,
+} from './freight-jwt.guard';
+
+// Shapes and ids taken from the real dev session for `test_dj_gl_director`
+// (iam.sessions 800ad793-…), an employee holding two posts on one row.
+const CHIEF = {
+ id: '990189f1-e872-4b8c-9f6a-36259a0df480',
+ employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6',
+ name: { en: 'Djibouti GL Chief' },
+ isDelegate: false,
+};
+const DIRECTOR = {
+ id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e',
+ employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df',
+ name: { en: 'Djibouti GL Director' },
+ isDelegate: false,
+};
+
+const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b';
+const oneRow: SnapshotEmployee[] = [
+ { id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] },
+];
+
+describe('resolveActiveEmployee', () => {
+ it('leaves the parent guard alone when no position header is sent', () => {
+ const { owner, active } = resolveActiveEmployee(
+ oneRow,
+ undefined,
+ EMPLOYEE_ID,
+ );
+
+ expect(owner).toBe(oneRow[0]);
+ expect(active).toBeUndefined();
+ });
+
+ it("resolves freight's header value (employeePositionId)", () => {
+ const { active } = resolveActiveEmployee(
+ oneRow,
+ DIRECTOR.employeePositionId,
+ EMPLOYEE_ID,
+ );
+
+ expect(active).toBe(DIRECTOR);
+ });
+
+ // The regression this guard exists for: the stock IAM guard matches the
+ // header against employeePositionId only, so Smart Office's position.id
+ // matched nothing and every request silently ran as positions[0].
+ it("resolves Smart Office's header value (position.id)", () => {
+ const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID);
+
+ expect(active).toBe(DIRECTOR);
+ expect(active).not.toBe(CHIEF);
+ });
+
+ it('falls back to the parent row when the header names nothing', () => {
+ const { owner, active } = resolveActiveEmployee(
+ oneRow,
+ 'not-a-position-id',
+ EMPLOYEE_ID,
+ );
+
+ expect(owner).toBe(oneRow[0]);
+ expect(active).toBeUndefined();
+ });
+
+ describe('when the two posts sit on different employee rows', () => {
+ const smartOfficeRow: SnapshotEmployee = {
+ id: 'emp-smart-office',
+ positions: [CHIEF],
+ };
+ const freightRow: SnapshotEmployee = {
+ id: 'emp-freight',
+ positions: [DIRECTOR],
+ };
+ const twoRows = [smartOfficeRow, freightRow];
+
+ it('selects the row that owns the requested position', () => {
+ const { owner, active } = resolveActiveEmployee(
+ twoRows,
+ DIRECTOR.employeePositionId,
+ // The parent guard matches the header against position.id only, so it
+ // matched neither row and fell through to the first.
+ smartOfficeRow.id,
+ );
+
+ expect(owner).toBe(freightRow);
+ expect(active).toBe(DIRECTOR);
+ });
+
+ it('keeps the parent row when no header is sent', () => {
+ const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id);
+
+ expect(owner).toBe(freightRow);
+ });
+
+ it('falls back to the first row when the parent row is unknown', () => {
+ const { owner } = resolveActiveEmployee(twoRows, undefined, undefined);
+
+ expect(owner).toBe(smartOfficeRow);
+ });
+ });
+});
+
+describe('collectAllPositions', () => {
+ it('unions posts held across separate employee rows', () => {
+ // The real shape: IAM keeps one employee row per organization, and "EDR"
+ // and "EDR Freight" are separate orgs, so a user holding a Smart Office
+ // post and a freight post owns one row each.
+ const smartOfficeRow: SnapshotEmployee = {
+ id: 'emp-edr',
+ organizationId: 'org-edr',
+ positions: [CHIEF],
+ };
+ const freightRow: SnapshotEmployee = {
+ id: 'emp-edr-freight',
+ organizationId: 'org-edr-freight',
+ positions: [DIRECTOR],
+ };
+
+ expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([
+ CHIEF,
+ DIRECTOR,
+ ]);
+ });
+
+ it('keeps every post when they share one row', () => {
+ expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]);
+ });
+
+ it('de-duplicates a post repeated across rows', () => {
+ const rows: SnapshotEmployee[] = [
+ { id: 'a', positions: [CHIEF] },
+ { id: 'b', positions: [CHIEF, DIRECTOR] },
+ ];
+
+ expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]);
+ });
+
+ it('tolerates rows carrying no positions', () => {
+ const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }];
+
+ expect(collectAllPositions(rows)).toEqual([]);
+ });
+});
diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.ts
index 68b842217..9dbc36b73 100644
--- a/apps/edr-freight-api/src/common/freight-jwt.guard.ts
+++ b/apps/edr-freight-api/src/common/freight-jwt.guard.ts
@@ -3,29 +3,124 @@ import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+import { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant';
import { DataSource } from 'typeorm';
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
-type SnapshotPosition = { id?: string; [key: string]: unknown };
+export type SnapshotPosition = {
+ id?: string;
+ employeePositionId?: string;
+ isDelegate?: boolean;
+ [key: string]: unknown;
+};
-type SessionUserInfo = {
- employee?: { id?: string; positions?: SnapshotPosition[] }[];
+/** One employee row as the snapshot stores it. A user may hold several. */
+export type SnapshotEmployee = {
+ id?: string;
+ positions?: SnapshotPosition[];
+ [key: string]: unknown;
+};
+
+type SessionUserInfo = { employee?: SnapshotEmployee[] };
+
+/**
+ * `x-current-position-id` is sent with two different meanings by two different
+ * frontends, and the IAM guard reads it both ways in the same function: it
+ * picks the EMPLOYEE row by `position.id` but the POSITION by
+ * `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends
+ * `position.id` — so whichever value arrives, one of the two lookups silently
+ * matches nothing and falls back to the first entry.
+ *
+ * Matching both fields is what makes the header mean one thing again.
+ */
+const identifies = (position: SnapshotPosition, id: string): boolean =>
+ position?.id === id || position?.employeePositionId === id;
+
+/**
+ * Every post the user holds, across every employee row, first occurrence kept.
+ *
+ * IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are
+ * separate organizations — so a user given a freight post and a Smart Office
+ * post owns two rows, one post on each. Only one row can be the active one, and
+ * a permission check that reads only that row cannot see the other post at all.
+ */
+export const collectAllPositions = (
+ employees: SnapshotEmployee[],
+): SnapshotPosition[] => {
+ const seen = new Set