diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c648fec09..bdb4c5ff5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -173,7 +173,7 @@ jobs: run: | set -euo pipefail IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" # Tag with git SHA for rollback capability CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true diff --git a/.gitignore b/.gitignore index ca2a5b7af..316f08dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,22 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ +RUNNING_LOCALLY.md diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 6fdaab48b..d80ce75c6 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY= TELEBIRR_PUBLIC_KEY= TELEBIRR_INSECURE_TLS=false +# Public origin of the freight customer portal. Password-reset links sent to +# customers are built against this — it must be browser-reachable. +FREIGHT_PORTAL_URL=http://localhost:5173 + # Portal pages the payment provider redirects the browser to after payment. # Point these at the freight portal's public payment result routes. PAYMENT_RETURN_URL=http://localhost:5173/payment/success diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index e2c4137cc..a1d86723c 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -1,14 +1,12 @@ # syntax=docker/dockerfile:1 # Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . +# +# The base image (Node + Alpine Chromium/Puppeteer + pnpm) is built and pushed +# separately — see Dockerfile.base. Override the pinned tag at build time with +# --build-arg BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base: +ARG BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:node24-alpine -FROM node:24.15.0-alpine AS base -RUN apk add --no-cache libc6-compat -# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit -# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -WORKDIR /app +FROM ${BASE_IMAGE} AS base FROM base AS pruner COPY . . @@ -35,8 +33,9 @@ FROM deployer AS migration WORKDIR /deploy CMD ["node", "dist/scripts/migrate.js"] -FROM node:24.15.0-alpine AS runner +FROM base AS runner RUN apk add --no-cache libc6-compat + ENV NODE_ENV=production WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ diff --git a/apps/edr-freight-api/Dockerfile.base b/apps/edr-freight-api/Dockerfile.base new file mode 100644 index 000000000..9459958a2 --- /dev/null +++ b/apps/edr-freight-api/Dockerfile.base @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# Base image for edr-freight-api — Node + Alpine Chromium/Puppeteer + pnpm. +# Built and pushed separately so app builds pull it from Harbor instead of +# reinstalling the ~system Chromium toolchain on every build. +# +# Build + push (from monorepo root): +# docker build -f apps/edr-freight-api/Dockerfile.base \ +# -t registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine . +# docker push registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine +# +# Bump the tag whenever Node, Chromium, or the apk set below changes, then +# update BASE_IMAGE in Dockerfile to match. + +FROM node:24.15.0-alpine + +# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB +# download at install time. This stage installs Alpine's system Chromium. +ENV PUPPETEER_SKIP_DOWNLOAD=true +# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs). +# Without these, Puppeteer fails to launch and the code degrades to an +# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built); +# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine. +RUN apk add --no-cache \ + libc6-compat \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + font-noto-cjk +ENV NODE_ENV=production +# Point Puppeteer at the system Chromium and skip its bundled download. +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit +# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /app diff --git a/apps/edr-freight-api/FREIGHT_QA_REPORT.md b/apps/edr-freight-api/FREIGHT_QA_REPORT.md new file mode 100644 index 000000000..166b18b25 --- /dev/null +++ b/apps/edr-freight-api/FREIGHT_QA_REPORT.md @@ -0,0 +1,457 @@ +# EDR Freight — Full QA / Logic Audit Report + +**Date:** 2026-07-15 +**Scope:** `@edr/freight-api` + freight backoffice/portal web apps +**Excluded per request:** warehouses, first-mile, last-mile, onboarding (Fayda/verifayda) +**Environment:** live stack via `pnpm run dev:freight`, API on `http://localhost:3030`, DB `edr_freight` @ `10.18.7.207` +**Method:** booted the real app, logged in as `superadmin@tria.com`, drove the API with `curl`, reproduced state bugs against live data, read every service/controller/entity in the in-scope modules, and cross-checked the backoffice screens against the API. + +--- + +## 1. How to read this report + +Every finding has: **Problem** (what is wrong + concrete failure), **Impact**, **Fix** (code-level, with `file:line`), and — where I ran it live — a **Repro** block with the actual request/response. + +Severity: + +- **CRITICAL** — money moves wrongly, or anyone can act on anyone's data / settle invoices. +- **HIGH** — data corruption, cross-tenant read/write, physical-asset state diverges from reality. +- **MEDIUM** — wrong-but-recoverable state, missing guards, math/notification errors. +- **LOW** — hardening, stale displays, latent (unused endpoint) bugs. + +**Counts:** 6 Critical · 18 High · 27 Medium · 20 Low (≈71 distinct issues). + +The single most valuable structural fix is at the top of §4 — it eliminates a whole family of bugs. + +--- + +## 2. Live tests I actually ran (evidence) + +| # | Test | Result | Verdict | +|---|------|--------|---------| +| 1 | Login `superadmin@tria.com` | `success:true` + JWT | OK | +| 2 | Build train `TR-00002` in KALITY, 2 locomotives | created | OK | +| 3 | **Move coupled LOCO-004 to MOJO while train stays in KALITY** | `200`, train KALITY / loco MOJO | **BUG — your example, confirmed** | +| 4 | Re-use same locomotive on a 2nd train | `409 already coupled` | Guard OK | +| 5 | `PATCH /train-builder/:id/yard` → MOJO | coupled locos + wagons follow | OK (builder path correct) | +| 6 | Decommission a coupled locomotive | `200 OUT_OF_SERVICE` | **BUG — no coupling guard** | +| 7 | Attach KALITY wagon to MOJO train | `400 not in yard` | Guard OK | +| 8 | `PATCH /wagons/:id` yard → different yard while ASSIGNED | `200` accepted | **BUG** | +| 9 | `DELETE /wagons/:id` on a wagon coupled to a train | `200` + **row physically gone** | **BUG — hard delete, no guard** | +| 10 | `GET /payments/checkout` **no auth** | `200` | **BUG — public** | +| 11 | `POST /internal/payments/mark-paid` **no auth** | `400` (reached handler, not `401`) | **BUG — public** | +| 12 | `GET /payments/receipt/:id` **no auth** | `400` (reached handler) | **BUG — public** | +| 13 | `GET /files/:id` **no auth** | `404` (reached handler) | **BUG — public** | +| 14 | Control: `GET /locomotives`, `GET /incidents` no auth | `401` | Auth global guard works | + +Tests 10–14 prove the "public" endpoints are genuinely reachable without a token (protected routes return `401`; these return `400`/`404`/`200` because they hit the handler). + +> **Boot-time noise (not app bugs):** SMS + Email services fail on RabbitMQ `ACCESS_REFUSED` (broker creds), and Swagger warns about 3 duplicate DTO names (`UpdateProfileDto`, `RequestChangesDto`, `SignContractDto`) and a legacy `/api/*` route. See §9. + +--- + +## 3. CRITICAL — money & authorization + +### C1. Paying an invoice settles it instantly without any money moving +**File:** `edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts:973-986` +A shipped "DEMO" shortcut fakes a `payment.succeeded` callback the moment a payment is initiated: +```ts +// DEMO: manually fire the gateway 'payment.succeeded' callback here … +if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", … }) } +``` +Real gateways return `REQUIRES_ACTION` (redirect), so `immediateSuccess` is false for essentially every payment → the invoice is marked **PAID**, the booking advances to `PAID`/batch allocation, and the clearance-fee gate opens. **A customer clicks "Pay", closes the page, pays nothing, and the freight ships.** If they *do* pay, the later real webhook is a no-op and money is collected against an already-settled invoice with no reconciliation. +**Fix:** delete the `if (!result.immediateSuccess)` block. Settle only from `settleByPaymentId` on a verified provider signal, and check `intent.amountMinor` against the invoice balance at settle time. + +### C2. Unauthenticated endpoints can settle any invoice by ID *(live-confirmed)* +**Files:** `billing/payment.controller.ts:55-104` (`GET /payments/checkout`, `@Public()`), `payment/internal-payment.controller.ts:22-39` (`POST /internal/payments/mark-paid`, `@Public()` — its own comment: *"anyone who can reach the API can mark payments as paid"*), `billing/payment.controller.ts:39-53` (`POST /payments/initiate`, no guard, no ownership check). +**Repro (live):** +``` +GET /api/payments/checkout?invoiceId=…&method=TELEBIRR → 200 (no token) +POST /api/internal/payments/mark-paid → 400 (no token, reached handler) +GET /api/locomotives → 401 (control) +``` +Combined with **C1**, anyone with an invoice UUID marks it paid unauthenticated and ships the freight. Anyone can POST a forged `payment.succeeded` (only a booking id is needed) to `mark-paid`. +**Fix:** shared-secret / `x-service-token` guard on `mark-paid` (the payment API already sends one — `http-payment-event-publisher.ts:44-45`); require auth + ownership on `initiate`; make `checkout` a signed, expiring URL. + +### C3. Currency-unit chaos — providers disagree by 100× on the same charge +**Files:** `billing.service.ts:958` sends `amountMinor: Math.round(Number(invoice.balanceAmount))` (invoice amounts are **major** units, `numeric(14,2)`). Providers then split: `cbe-birr.provider.ts:57`, `ebirr.provider.ts:64`, `card.provider.ts:60`, `cac-bank.provider.ts:307` all do `amountMinor / 100`; but `telebirr.provider.ts:201`, `dmoney.provider.ts:200`, `waafi.provider.ts:282` treat it as major (no `/100`). +**Impact:** a 50,000 ETB invoice paid via CBE_BIRR/EBIRR/CARD/CAC charges **500.00** — while the freight side still marks it fully PAID (see C4). `Math.round` also drops cents. +**Fix:** pick one convention (`Math.round(balance*100)` true-minor everywhere, fix the 3 major-unit providers), then enforce `confirmedAmountMinor === intent.amountMinor` at settlement. + +### C4. Settlement never verifies the amount charged +**Files:** `billing.service.ts:527-552` (`markInvoiceAsPaid` sets `paidAmount = totalAmount, balanceAmount = 0` purely from the invoice); `edr-payment-api/.../intents/intents.service.ts:408-415` (an amount mismatch is only `logger.error`'d — the intent still finalizes SUCCEEDED, and no freight handler even populates `confirmedAmountMinor`). +**Impact:** any wrong-amount success (C3's 1/100, a partial card capture, a reused stale intent) still marks the full invoice PAID. +**Fix:** treat a `confirmedAmountMinor` mismatch as a failure/hold; in `settleByPaymentId` refuse (or record a partial) when the confirmed amount doesn't cover `balanceAmount`. + +### C5. Telebirr & D-Money webhook signatures are disabled +**Files:** `edr-payment-api/.../webhooks/handlers/telebirr-webhook.service.ts:16-18` (`const signatureValid = true;` + `// TODO: re-enable`), `dmoney-webhook.service.ts:16-17` (same). The pipeline only rejects when `signatureValid` is false, so these two are trusted unconditionally on the public `/webhooks/*` surface. +**Impact:** an attacker who guesses a `merch_order_id` POSTs a fake success payload → intent SUCCEEDED → freight marks the invoice PAID and ships. +**Fix:** implement `verifyWebhookSignature` for both; until keys exist, re-query the provider (`queryStatus`) before honoring SUCCEEDED. + +### C6. Customers can create/act-on bookings billed to any company +**Files:** `bookings/bookings.service.ts:594-629` (`create` only resolves+verifies the caller's own company when `dto.companyId` is *absent*; a supplied `companyId` is used verbatim), plus a batch of booking mutations with **no ownership check** in `bookings.controller.ts`: `PATCH /:id` (205), `DELETE /:id` (585), `POST /:id/generate-price` (611), `/submit` (622), `/confirm-submit` (633), `/reject` (643), `/clearance/documents` (682), `/clearance/proceed` (699), `GET /:id/clearance` (672). Reads (`GET /:id`) *do* call `assertCustomerCanAccessBooking`; writes don't. +**Impact:** Company A's user sets `companyId` to Company B and books/invoices under B; or calls `DELETE /bookings/{B's id}` / submits / uploads clearance docs on B's booking. UUIDs appear in list payloads, so they're discoverable within a session. +**Fix:** require a staff permission to pass `companyId`; otherwise force it from the resolved user company. Add `assertCustomerCanAccessBooking` to every mutating booking route. + +--- + +## 4. HIGH — Train / Locomotive / Wagon consist integrity + +> **★ Structural root cause (fix this first).** The invariant *"a consist (train + its locomotives + its wagons) moves and locks as one unit"* is enforced **only inside** `trains/train-builder.service.ts`. Every *legacy / master-data* endpoint around it — `PATCH /locomotives/:id`, `PATCH /wagons/:id`, `POST /wagons/:id/assign-train`, `wagons/bulk-status`, `wagons/bulk-transfer`, `DELETE /trains/:id`, `DELETE /wagons/:id` — mutates the same rows with **no coupling guard**. Findings H1, H2, H5, H6, H7 are all the same missing check. **The one fix that kills the family:** make `TrainLocomotive`/`wagon.trainId` membership a guard that every locomotive/wagon mutation consults (reject or redirect to the builder endpoints). + +### H1. ★ Locomotive yard/status freely editable while coupled to a built train — *your example, confirmed live* +**File:** `locomotives/locomotives.service.ts:87-119` (`update`). +`update()` applies `status` and `currentYardId` with **no check of the `train_locomotives` link table**: +```ts +currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null), +``` +The builder keeps train + locomotives in the same yard (`train-builder.service.ts:389-429 setYard`, `:656-660 validateAndLockLocomotives`), but `PATCH /locomotives/:id` bypasses it. **The frontend triggers it by accident:** the locomotive edit form always includes the `status` and `currentYardId` selects (`.../pages/fleet/config/resources.ts:168-169`) and PATCHes the *whole form object* on every save (`FleetResourcePage.tsx:301`). So editing a coupled locomotive's *name* re-sends its yard → the train and its locomotive end up in different yards. +**Repro (live):** +``` +Build TR-00002 in KALITY with LOCO-004 (+LOCO-023) +PATCH /api/locomotives/LOCO-004 {"currentYardId": MOJO} → 200 +GET train → yard: KALITY ; LOCO-004 → yard: MOJO ← diverged, no error +``` +**Fix (exactly what you described — reject with a clear error, don't silently move):** +```ts +const link = await this.ds.getRepository(TrainLocomotive) + .findOne({ where: { locomotiveId: id }, relations: { train: true } }); +if (link) { + if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) + throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; move the train instead`); + if (dto.status !== undefined && dto.status !== loco.status) + throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; detach it before changing status`); +} +``` +Frontend: send only dirty fields, and disable the yard/status selects (show the modal error) when the locomotive is in a built train. + +### H2. Wagon PATCH is a free-for-all — yard, `trainId`, `sequenceNumber`, `status` all unguarded *(live-confirmed)* +**File:** `wagons/wagons.service.ts:90-101` — `Object.assign(wagon, dto)` with zero invariant checks; `UpdateWagonDto` (PartialType of Create) exposes `trainId`, `sequenceNumber`, `status`, `currentYardId`. +**Repro (live):** `PATCH /api/wagons/:id {"currentYardId": MOJO}` on a wagon `ASSIGNED` to a KALITY train returned `200` and moved it. Setting `trainId` directly attaches to any train bypassing every builder rule; flipping `ASSIGNED→AVAILABLE` while `trainId` is set makes the wagon grabbable by transfer requests and the legacy assign flow. +**Fix:** reject `trainId`/`sequenceNumber` in update; when `wagon.trainId != null`, reject `currentYardId`/`status` changes (409 → point at train-builder endpoints). + +### H3. `DELETE /wagons/:id` hard-deletes with no guard — destroyed real data during this audit *(live-confirmed)* +**File:** `wagons/wagons.service.ts:136-139` — `this.wagonRepo.remove(wagon)` is a **hard** delete (repo standard is soft-delete via `BaseEntity.deletedAt`). No check for `trainId`, live-schedule pinning, or containers. +**Repro (live):** I called `DELETE /api/wagons/{BW1-0009}` while it was `ASSIGNED` to my test train → `200`. Raw SQL then showed **the row physically gone** (`SELECT … WHERE id=… → 0 rows`). On delete, `train_set_wagons.physical_wagon_id` is `SET NULL` (a dispatched schedule silently loses its physical wagon) and `wagon_movements` is `CASCADE` (the audit ledger is destroyed). +> **I recreated BW1-0009 via the API** (`POST /api/wagons`, new UUID `d9b578ed-…`, AVAILABLE @ KALITY) so the dev data count is whole again. The original UUID `905cad62-…` is unrecoverable (hard delete). See §10. +**Fix:** block deletion when `trainId IS NOT NULL` or the wagon is pinned to a DRAFT/SCHEDULED/DISPATCHED schedule (reuse `train-builder`'s `isWagonPinnedToLiveSchedule`); switch to `softRemove` (add a partial unique index on `wagon_number WHERE deleted_at IS NULL`). + +### H4. `DELETE /trains/:id` hard-deletes a built train and permanently strands its wagons +**File:** `trains/trains.service.ts:57-60` — `trainRepo.remove(train)`, no active-schedule check (contrast `train-builder.service.ts:536-563 disband`, which blocks on live schedules and releases resources). Wagons' FK is `SET NULL`, so they keep `status = ASSIGNED` with `trainId = null` → **unusable forever** (attach requires `status === Available`). The fleet Trains CRUD page (`resources.ts:204`) points at this endpoint. +**Fix:** delegate `TrainsService.remove` to `TrainBuilderService.disband` (block live schedules, reset wagons to Available, delete locomotive links), and use `softRemove`. + +### H5. Legacy wagon assign: maintenance wagons assignable, silent theft, duplicate sequences +**File:** `wagons/wagons.service.ts:141-164` (`POST /wagons/:id/assign-train`). Only guard is `status === Assigned`. Holes vs the builder's `attachWagons`: a `MAINTENANCE`/`DETAINED` wagon is accepted and flipped to ASSIGNED (erasing the flag); a wagon with `trainId` set but status≠ASSIGNED has its `trainId` overwritten (**steals it from another train's consist**, never resequenced); no wagon-yard vs train-yard check; no `train.status !== IN_SERVICE` check; caller `sequenceNumber` isn't collision-checked (no unique index on `(train_id, sequence_number)`). +**Frontend echo:** `AssignWagonDialog.tsx:22-24` filters `w.status === Available || !w.trainId` — the `||` should be `&&`; today it offers maintenance/detained wagons. +**Fix:** re-implement on builder rules (`Available && trainId === null && sameYard && train not InService`, sequence = max+1), or delete the endpoint and use `POST /train-builder/:id/wagons`. + +### H6. `bulk-status` / yard-workspace "Free up" corrupts wagons physically in a train +**File:** `wagons/wagons.service.ts:241-269` (`bulkSetStatus`, no `trainId` guard); UI `WagonYardWorkspaceModal.tsx:187,270-287,545-580` picks an arbitrary slice of ASSIGNED wagons and flips them AVAILABLE — including consist wagons (whose ASSIGNED means "coupled"). After the flip they still have `trainId` set but read AVAILABLE → transfer requests hand them out, `bulk-transfer` moves them, legacy assign re-homes them, while the builder consist still lists them. +**Fix:** refuse to change status of wagons with `trainId IS NOT NULL`; exclude them from the modal's flip pools. + +### H7. `bulk-transfer` relocates train-coupled wagons; legacy unassign bypasses the schedule pin +**File:** `wagons/wagons.service.ts:180-234` (`bulkTransfer`, existence-only check → moves consist wagons to another yard) and `166-172` (`unassignFromTrain` frees a wagon with no `isWagonPinnedToLiveSchedule` check and leaves a sequence gap). +**Fix:** reject `trainId IS NOT NULL` in `bulkTransfer`; run the builder's pinned-schedule query in `unassignFromTrain` and resequence, or delete in favor of `DELETE /train-builder/:id/wagons/:wagonId`. + +### H8. Export capacity check-then-act race → train overbooking +**File:** `bookings/booking-transition.service.ts:1093-1153` → `train-scheduling/booking-batch.service.ts:722-793` — `pickExportSchedule` reads `budget.fits(...)` then `reserve` (idempotency only, never re-checks capacity). No transaction/row-lock spans the check and the write (the code comment even calls the pre-check "rough"). +**Impact:** two staff accept two export bookings for the same near-full train concurrently → both pass `fits()`, both reserve → train exceeds locomotive pull weight / wagon slots. +**Fix:** wrap check + reserve in a serializable transaction with `SELECT … FOR UPDATE` on the schedule (or a per-schedule advisory lock); re-verify `fits()` inside the lock. + +### H9. Consolidation pairing race → one partner paired with two bookings +**File:** `bookings.service.ts:494-543` + `bookings.repository.ts:213-261,287-309` — `findComplementaryConsolidationPartner` filters `consolidationPartnerId IS NULL`, `pairConsolidation` writes both sides, no lock between find and pair. +**Impact:** two bookings both pick the same waiting partner → asymmetric pairing, shared-wagon capacity double-counted. +**Fix:** `FOR UPDATE` on the candidate inside a transaction, re-assert `consolidationPartnerId IS NULL` on both before writing. + +### H10. `import-operations` controller has no authorization +**File:** `import-operations/import-operations.controller.ts` (whole file — only `@ApiTags`, no permission guard). Any authenticated portal user can `POST /import-operations/customs/{anyBookingId}/release-permitted`, assign customs risk, or mark duties/taxes paid on any booking. +**Fix:** add `@BookingStaff(FREIGHT_PERMS…)` guards. + +### H11. `interchange-documents` controller has no authorization +**File:** `interchange-documents/interchange-documents.controller.ts` — `POST /generate-from-schedule`, `PATCH /:id/acknowledge|dispute|cancel` reachable by any authenticated user. These are outward-facing customs/port handover records. +**Fix:** staff permission guards. + +### H12. Anyone can sign anyone's contract as CUSTOMER +**File:** `contracts/contracts.controller.ts:502-523` (asserts permission only for non-CUSTOMER roles) + `contract-transition.service.ts:795-816` (CUSTOMER branch checks only status + a **caller-supplied** `dto.otpPhone`/`dto.otp`). Any portal user signs another company's CONTRACT_READY contract with an OTP on their own phone → SIGNED_CUSTOMER, firing counter-sign/clearance-fee. `renew` (525) and `clearance/documents` (543) have the same gap. +**Fix:** call `assertCustomerCanAccessContract`; verify OTP against the contract company's registered phone, not `dto.otpPhone`. + +### H13. Any file downloadable by anyone with the UUID *(live-confirmed public)* +**File:** `files/files.controller.ts:20-55` — the single global file-stream route is `@Public()`, no auth, no ownership, no expiry. All uploads funnel through it: driver documents, contract PDFs, company license / Fayda national-ID files. +**Repro (live):** `GET /api/files/{uuid}` → `404` with no token (reached handler; a valid id streams the file). Control routes return `401`. +**Fix:** require `JwtGuard` and authorize by the file's `resource`/`resourceId`, or serve via short-lived signed URLs (the code already has `filesService.signUrl`). + +### H14. Duplicate `payment.succeeded` regresses advanced/cancelled bookings back to PAID +**File:** `bookings/booking-invoice.service.ts:133-166` — the idempotency guard `// if (booking.paymentStatus === "PAID") return;` is **commented out**, then it unconditionally rewrites `status: "PAID"` and re-runs allocation. The relay is documented at-least-once and freight never dedupes `eventId` (the `PaymentWebhookEventEntity` is registered but unused). +**Impact:** a replayed success on a `SCHEDULED`/`DISPATCHED` — or `CANCELLED` — booking force-rewrites it to PAID and re-fires side effects. +**Fix:** restore the guard as a state-machine check (only advance from awaiting-payment statuses); persist processed `eventId`s. + +### H15. Booking prices ignore the contract's frozen rate snapshots +**File:** `bookings/booking-pricing.service.ts:123-229` prices exclusively from `ratesService.findLiveRates()`; nothing reads `contract_rate_snapshots` (only the clearance fee honors the freeze). `contract-booking.service.ts:289-295` comments "compute from contract unit rates" but calls the live-rate path. +**Impact:** customer signs a contract at 1,916 USD/40ft, rates team raises the live rate to 2,300, the drawdown booking bills 2,300 — contradicting the signed contract PDF. +**Fix:** in `computePriceForBooking`, when `booking.contractId` is set, resolve unit prices from that contract's snapshots (fall back to live only for un-frozen codes). + +### H16. Cancelling a booking leaves its invoice open & payable; refund path is dead code +**File:** `booking-transition.service.ts:519-543` (`cancel()` writes a note + `status: CANCELLED`, never cancels the open PREPAID invoice) and `billing.service.ts:654-672` (`markInvoiceAsRefunded` has **zero callers**). +**Impact:** the cancelled booking's invoice stays payable in the portal; paying it fires `booking.invoice.paid` → flips the CANCELLED booking back to PAID (see H14). Money already collected on a later-cancelled flow has no refund mechanism. +**Fix:** `cancel()`/`reject()` must cancel/expire the open invoices in the same transaction; wire `markInvoiceAsRefunded` to a real staff refund endpoint; make `advanceBookingOnPayment` refuse terminal-status bookings. + +### H17. `cancelTrainSchedule` has no status guard — a DISPATCHED/ARRIVED train can be cancelled +**File:** `train-scheduling/train-scheduling.service.ts:3226-3311` (unguarded at `train-scheduling.controller.ts:817`). Every sibling transition checks `status`; this one goes straight to the cancel transaction. +**Impact:** cancelling a DISPATCHED train sets every pinned wagon's `currentYardId = originStationId` ("never left") while they're rolling; releases the locomotives out on this run to AVAILABLE; detaches IN_TRANSIT bookings while their cargo stays IN_TRANSIT. The frontend only shows Cancel for `DRAFT`/`SCHEDULED` (`TrainScheduleV2ListPage.tsx:439-469`) — the API enforces nothing. +**Fix:** reject unless `['DRAFT','SCHEDULED'].includes(schedule.status)`. + +### H18. `executeReschedule` is not transactional — a mid-flight failure strands the schedule half-rescheduled +**File:** `scheduling-reschedule/scheduling-reschedule.service.ts:131-210` — four separately-committed steps (persist new date → unassign each displaced booking → `assignBookingsToSchedule` → audit event). Step 3 routinely throws (re-validates against the *new* date persisted in step 1). Result: date already moved, displaced bookings already gone, no audit event, `400` to staff. +**Fix:** run the whole execute in one `dataSource.transaction`, threading the manager through unassign/assign. + +--- + +## 5. MEDIUM — scheduling, bookings, cargo, containers + +### M1. Cargo delivery never releases the container (counts itself) +**File:** `cargoes/cargoes.service.ts:159-175` — sets `cargo.status = 'DELIVERED'` in memory, then counts `LOADED` cargo on the container *before saving*, so the cargo being delivered counts itself → `remaining >= 1` always → the container is **never** flipped back to AVAILABLE. +**Fix:** save the cargo first, or exclude the current id: `count({ where: { containerId, status: 'LOADED', id: Not(cargo.id) } })`. + +### M2. Cargo unload leaves the container marked LOADED +**File:** `cargoes.service.ts:139-147` — `unloadCargo` sets cargo `UNLOADED` but never touches `container.status` (which `loadCargo` set to LOADED). Emptied containers read as in-use forever. +**Fix:** on unload, if no remaining LOADED cargo references the container, reset it to AVAILABLE. + +### M3. Container↔wagon assignment: no capacity/duplicate guard; status wrongly AVAILABLE +**File:** `container-management/containers.service.ts:113-136` — `MAX(position)+1` check-then-act with no `(wagon_id, position)` unique constraint; no check that the container is already on another wagon (silently overwrites `wagonId`); sets `status = 'AVAILABLE'` for a container physically on a wagon (so it reads free for another assignment). *(There's also a dead `containers.service copy.ts` duplicate.)* +**Fix:** reject when the container already has a `wagonId`; enforce wagon capacity; unique `(wagon_id, position)` + allocate in a transaction; use a distinct on-wagon status. + +### M4. Interchange document state machine holes +**File:** `interchange-documents/interchange-documents.service.ts:182-206` — `dispute` has **no status guard** (a CANCELLED or ACKNOWLEDGED doc can be flipped to DISPUTED); `acknowledge` guards only CANCELLED, so an already-DISPUTED doc can be quietly ACKNOWLEDGED (losing the dispute). +**Fix:** restrict `dispute` to GENERATED/ACKNOWLEDGED; restrict `acknowledge` to GENERATED. + +### M5. Interchange item weight mixes tons and kg in one column +**File:** `interchange-documents.service.ts:238-361` — the `weight` column is `COALESCE`d from tons sources (`total_vgm_tons`, `cargo_total_weight_vgm`) *and* kg sources (`containers.max_gross_weight`, `cargoes.weight // kg`). Different line items in the same customs handover carry weights ~1000× apart. +**Fix:** normalize every source to one unit before writing. + +### M6. Cargo load/create allow weight over container capacity +**File:** `cargoes.service.ts:114-137` — `loadCargo` sets `cargo.weight = dto.weight` (`@Min(0)` only), never compared to the container's `maxGrossWeight`, no aggregate across cargoes. +**Fix:** verify `tare + sum(loaded) ≤ maxGrossWeight` (mind M5's units) and reject overflow. + +### M7. Reschedule changes the departure date without any of `updateScheduleDate`'s validation +**File:** `scheduling-reschedule.service.ts:151-157` vs `train-scheduling.service.ts:781-865` — the reschedule/maintenance path writes `scheduledDepartureDate` directly: no `PRE_WINDOW` check, no lead-window rejection, no re-derivation of `windowOpensAt/ClosesAt/Phase`, no route+day group re-anchor, and **past dates are accepted** (never compared to `now`; only the dialog checks client-side). Windows keep the timing computed for the OLD date. +**Fix:** delegate to `updateScheduleDate` (or replicate its checks) and validate `newDepartureDate > now`. + +### M8. `compareSchedulingPriority` sorts null-date bookings FIRST (comment says last) +**File:** `scheduling/compare-scheduling-priority.util.ts:4-6,20-22` — null `scheduledDate` → `getTime()` falls back to `0` (epoch), ascending sort puts it first. In `previewReschedule` this decides who is retained when capacity is tight → a general-contract booking with no date outranks customers who booked a concrete slot. +**Fix:** fall back to `Number.MAX_SAFE_INTEGER`, not `0`. + +### M9. Assigning bookings never checks the booking's day matches the schedule's departure day +**File:** `train-scheduling.service.ts:1226-1259` + `3321-3618` — the parity guard's comment claims day-fit is enforced "downstream", but `validateBookingsForScheduling` never reads `dto.scheduleDate` or compares `booking.scheduledDate`. A booking a customer picked for Jul 25 can be assigned to a train departing Jul 17, silently. +**Fix:** add a violation (or `forceAssign` warning) when `eatDay(booking.scheduledDate) !== eatDay(schedule departure)`. + +### M10. Partial `unassignBooking` frees the booking but not its wagon slots/totals +**File:** `train-scheduling.service.ts:1492-1541` — `TrainSetWagon` slots and `TrainSet` aggregates are only reset when the train becomes fully empty. Remove 1 of 3 bookings → tonnage/length/wagonCount stay stale, empty slots stay RESERVED with wagons pinned, free-capacity under-reports (can hide the day from customers), dispatch sends the empty pinned wagons. +**Fix:** after a partial unassign, release the emptied slots and recompute totals from surviving allocations. + +### M11. Displaced-booking fallback leaves the booking still linked to the schedule +**File:** `scheduling-reschedule.service.ts:159-168` — on unassign failure the `catch` only flips scheduling fields; it doesn't delete the `TrainScheduleBooking` link, clear `trainScheduleId`, or free allocations. The booking becomes ELIGIBLE for batch fills **and** still linked → double-booking; its stale `trainScheduleId` also blocks manual assignment elsewhere. +**Fix:** in the fallback, delete the link + allocations and set `trainScheduleId: null` (or re-throw and abort). + +### M12. Reschedule notifies "rescheduled to a new date" even when the date didn't change +**File:** `scheduling-reschedule.service.ts:204-235` — `effectiveDeparture` falls back to the (never-null) existing date, so the `if (newDeparture)` branch always runs → a GOVERNMENT_PREEMPT rebalance with no date change SMS/email-blasts every retained customer "rescheduled to ``". +**Fix:** `const effectiveDeparture = dto.newDepartureDate ? new Date(dto.newDepartureDate) : null;` + +### M13. Maintenance reschedule endpoint bypasses DTO validation & drops caller bookings +**File:** `scheduling-reschedule.controller.ts:56-65` (body typed as an intersection `PreviewRescheduleDto & { … }` → Nest emits `Object` metadata → **ValidationPipe is skipped**, so `newDepartureDate: "garbage"` and missing arrays reach the service) + `scheduling-reschedule.service.ts:257-283` (preview uses `currentIds.length ? currentIds : dto.incomingBookingIds`, so caller-supplied incoming ids are dropped on a non-empty train, then execute re-runs with `dto.incomingBookingIds` → the displaced-set equality check can 400). +**Fix:** real `MaintenanceRescheduleDto extends PreviewRescheduleDto` with `@IsDateString() newDepartureDate`; merge `currentIds ∪ dto.incomingBookingIds` for both preview and execute. + +### M14. Routes editable (milestones deleted, endpoints swapped) while live schedules reference them +**File:** `routes/routes.service.ts:108-140` — `update()` deletes+rewrites milestones and origin/destination with no check for DRAFT/SCHEDULED/DISPATCHED schedules on the route. Schedules read the corridor live afterward (sub-leg validation, checkpoints, customer day pool), so a reroute silently invalidates boarding bookings and renumbers stations mid-run. +**Fix:** reject milestone/endpoint changes when any non-terminal schedule references the route (allow status-only edits). + +### M15. Workspace capacity meter sums locomotive limits; API caps at the weakest locomotive +**File:** `.../components/trainScheduling/ScheduleWorkspacePanel.tsx:98-109` (`reduce(sum + maxPullWeightTons)`) vs `train-capacity.util.ts:242-260` + service `1349-1389` (`minLocomotiveLimits` — the weakest locomotive caps the train; gate is cargo **+ consist tare**). Two 3500T locos → the meter shows 7000T and "43% full" while the API already rejects adds at ~3500T gross; the overfill warning fires on the wrong threshold. +**Fix:** capacity = `min(maxPullWeightTons) + min(overageToleranceTons)`, and include consist tare in `used`. + +### M16. Vehicles under maintenance stay assignable; maintenance never changes availability +**File:** `maintenance/*` never writes `vehicle.status`/availability; `maintenance.service.ts:36-46` COMPLETED doesn't restore anything; assignment paths check only availability, never `VehicleStatus.MAINTENANCE`. +**Impact:** a vehicle in the shop can be dispatched; marking a vehicle MAINTENANCE doesn't block assignment. +**Fix:** on maintenance start set the vehicle unavailable, on completion restore, and reject MAINTENANCE/OUT_OF_SERVICE/RETIRED at assignment. + +### M17. Fuel purchases accept negative/zero quantities, no duplicate guard +**File:** `fuel/dto/create-fuel-purchase.dto.ts:11-15` (`liters`, `costPerLiter` are `@IsNumber()` only) → `fuel.service.ts:18` `totalCost = liters * costPerLiter`. Negative liters → negative monthly totals & averages, poisoning Financial Reports/Fleet Dashboard; no `(vehicleId, receiptNumber)` uniqueness → double-counting. +**Fix:** `@IsPositive()` on both; reject duplicate `(vehicleId, receiptNumber)`. + +### M18. Public OTP verify: no expiry, no rate limit, no attempt cap, replayable +**File:** `otp/otp.service.ts:94-120` (`verifyOtp`, exposed `@Public()` at `otp.controller.ts:22-63`) — a 6-digit code (1e6 space) with unlimited attempts, no age check, and only flagged `verified=true` on success (the same code keeps working). The hardened `verifyOtpForAction` (TTL + 5-attempt cap + delete-on-success) exists but this route doesn't use it. +**Fix:** give `verifyOtp` the same TTL/attempt-cap/consume semantics; rate-limit the public OTP routes. + +### M19. OTP generated with `Math.random()` (not a CSPRNG) +**File:** `otp/otp.service.ts:27-29` — this gates password reset (`forgot-password.service.ts:134`) and contract-signature sudo. Predictable codes weaken account-takeover resistance. +**Fix:** `crypto.randomInt(100000, 1000000)`. + +### M20. OTP send is public & unthrottled — SMS/email bombing + counter reset +**File:** `otp/otp.controller.ts:33-42` (`@Public() POST /otp/send`) + `otp.service.ts:35-88` (each send does `actionAttempts.delete(...)`, resetting the in-memory brute-force counter — attacker-controllable, and per-process anyway). +**Fix:** rate-limit per target + per IP; move the attempt counter to persistent storage. + +### M21. Email notifications silently discarded while reporting success +**File:** `notifications/strategies/notification.email.strategy.ts:8-11` — `send()` logs and `return false;` (stub). `notifications.service.ts:25-32 directSend` awaits it, logs `is sent - false`, and neither throws nor surfaces the failure. Every email notification (booking lifecycle, contracts, companies, booking-window) silently never sends. +**Fix:** implement the strategy (or route through the working `EmailClientService`); make `directSend` treat `false`/throw as an observable failure. + +### M22. Rate "CEO approval" is self-approvable (no segregation of duties) +**File:** `rule-engine/services/rates.service.ts:186-208` + `rates.controller.ts:61-76` — `submit` and `approve` share the identical `@RuleEngineManage('rates')` permission, and `approve` never checks `approverUserId !== proposedByStaffId`. One staffer can draft→submit→approve LIVE. (Same in `priority-rule-change-requests.service.ts:78-112`.) +**Fix:** distinct approver permission + reject self-approval. + +### M23. Uploads accept arbitrary type & unbounded size +**File:** `drivers/drivers.controller.ts:74-82` (`AnyFilesInterceptor()`, no `limits`/`fileFilter`) + `files/files.service.ts:36-55` (stores whatever mime/size). `file.buffer` held in memory → DoS; executables/active-HTML then served inline via the public files route (H13). The `file-upload-settings` config exists but isn't enforced. +**Fix:** Multer `limits.fileSize` + mime allowlist (driven by file-upload-settings), validated in `FilesService.upload`. + +### M24. Incidents controller: full CRUD + IDOR for any authenticated user +**File:** `incidents/incidents.controller.ts:17-69` — no permission guard on any route (sibling fleet modules all use `@BookingStaff`). A portal customer can read any driver's incident history by `driverId` and create/alter/delete incident records. +**Fix:** class-level `@BookingStaff(FREIGHT_PERMS.incidents.view)` + per-write permissions. + +### M25. Tracking timeline IDOR +**File:** `tracking/tracking.controller.ts:11-17` — `GET /:consignmentId` has no guard/ownership check; any logged-in user reads any consignment's full movement history by iterating UUIDs. +**Fix:** permission guard + scope to the caller's company. + +### M26. Clearance-fee gate silently waived when the fee snapshot is missing +**File:** `contracts/clearance-fee.service.ts:80-87` — `gateApplies` returns `false` (skipping the prepay gate, warn-log only) whenever no `CUSTOMS_CLEARANCE` snapshot line exists, and `contract-pricing.service.ts:262-284` falls back to a stale stored breakdown. A customs contract whose price predates the fee feature ships clearance for free. +**Fix:** for `customsClearingEnabled` contracts, hard-fail counter-sign/shipment-request when no fee line resolves. + +### M27. Manual settlement / `updateStatus` gaps in the invoice state machine +**File:** `billing.service.ts:577-609` (`recordPayment` guards Cancelled/Refunded/Paid but not Draft/Expired → an expired invoice can be settled at the counter, resurrecting a released flow) and `889-907` (`updateStatus` accepts **any** target including Paid without touching `paidAmount`/`balanceAmount` → a PAID invoice with a full outstanding balance). +**Fix:** add Draft/Expired to the reject list; restrict `updateStatus` to the Draft→Issued transition it's actually used for. + +--- + +## 6. LOW — hardening & latent bugs + +- **L1. Decommission ignores coupling** — `locomotives.service.ts:121-133` sets OUT_OF_SERVICE with no `TrainLocomotive` check *(live-confirmed: decommissioned a coupled loco, 200)*. Train keeps hauling with a dead loco on paper; `capacityTons` not recomputed. → link-table guard. +- **L2. Builder accepts ASSIGNED/UNAVAILABLE locomotives** — `train-builder.service.ts:653` blocks only OUT_OF_SERVICE/MAINTENANCE; a loco out on a dispatched run (ASSIGNED) can be coupled to a new train. → allowlist `AVAILABLE/IMPORT_READY/EXPORT_READY`. +- **L3. Legacy `PATCH /trains/:id`** — `trains.service.ts:50-55` `Object.assign` lets `status`/`capacityTons`/`code`/`trainNumber` be rewritten (unfreeze a dispatched train, break the weakest-loco capacity, code/number clashes → 500 or silent collision). → strip those fields; run the builder's clash query. +- **L4. `PATCH /train-builder/:id/details` skips even/odd run-number rule** — `update-train-details.dto.ts:17-29` lacks the `@Matches(/…[13579]$/ | …[02468]$/)` the build DTO enforces → can set IMPORT number odd. → copy the decorators. +- **L5. Legacy reorder ignores `trainId`** — `wagons.service.ts:271-287` (`_trainId` unused) renumbers any wagon list to `1..n` with no set-equality check → duplicate sequences on other trains. → validate set equality vs `find({ where: { trainId } })`. +- **L6. Wagon type soft-deletable while in use** — `wagon-types.service.ts:114-117` no reference check; soft-deleted type → `wagon.wagonType` loads null → builder length/tare math silently zeroes. → block when wagons reference it. +- **L7. Facilities create/update are dead** — `facilities/dto/*.dto.ts` are plain interfaces (no decorators) under a `forbidNonWhitelisted` pipe → every non-empty body 400s, empty body 500s. → decorate the DTOs + code-uniqueness check. +- **L8. Yard soft-delete has no reference guard** — `rule-engine/services/yards.service.ts:62-66` strands trains/locos/wagons parked there (their `current_yard_id` scalar survives but joins miss). → count references, 409. +- **L9. Wagon create incoherent state** — `wagons.service.ts:27-37` accepts `trainId` while status defaults AVAILABLE (violates attached⇒ASSIGNED); duplicate `wagonNumber` → 500 not 409. → drop `trainId`/`sequenceNumber` from create DTO, uniqueness pre-check. +- **L10. Locomotive code generation is a read-scan race** — `locomotives.service.ts:40-48` scans all rows for max `LOCO-NNN`; concurrent creates collide → 500. → `MAX(SUBSTRING…)` + probe loop or a sequence. +- **L11. `Train.capacityTons` stale after a coupled loco's pull limit is edited** — `locomotives.service.ts:87-119` has no recompute hook; list vs detail disagree. → recompute for trains found via `TrainLocomotive`. +- **L12. `GET /facilities/:id` returns `200 null`, `DELETE` succeeds for unknown ids** — `facilities.service.ts:20-30` (every sibling 404s). → throw NotFound. +- **L13. Import customs flow has no ordering/terminal guards** — `import-operations.service.ts:102-210`: duties-paid before notify, risk changed after completion, no terminal lock. → state + `completedAt` guards. +- **L14. Consignment create doesn't validate the referenced booking** — `consignments.service.ts:14-17` persists the DTO directly (`bookingId` only `@IsUUID`). → existence check. +- **L15. Incident & maintenance status transitions unvalidated** — `incidents.service.ts:64-71`, `maintenance.service.ts:36-46` accept any status (REPORTED→CLOSED, SCHEDULED→COMPLETED). → allowed-transition checks. +- **L16. Maintenance cost fields accept negatives** — `maintenance/dto/create-maintenance.dto.ts` `estimatedCost/actualCost/costAmount` no `@Min(0)`. → add it. +- **L17. Procurement depreciation accepts inverted money** — `procurement/dto/procurement.dto.ts:89-99` (no `@Min(0)`) + `procurement.service.ts:120-131`: `salvageValue > cost` → negative monthly depreciation → `bookValue` grows unbounded. → `@Min(0)` + `salvage <= cost`. +- **L18. `notification-inbox` fan-out is sequential N+1** — `notification-inbox.service.ts:62-64` per-recipient create+countUnread+findOne. → batch/parallelize (it's fire-and-forget, so slow not broken). +- **L19. GPS history `limit` unguarded vs NaN/negative** — `gps-tracking.controller.ts:38` passes `parseInt` through; `?limit=abc`→NaN, `?limit=-5`→negative take. → clamp to a positive int. +- **L20. Hardcoded default JWT secret** — `@edr/api-common` `shared-auth.module.js` falls back to a public constant if `JWT_SECRET` is unset (mitigated by the session-row check). → ensure `JWT_SECRET` always set in the environment. + +--- + +## 7. Backoffice / portal frontend mismatches (already folded into findings above) + +- **Locomotive/Wagon fleet edit forms PATCH the whole form object** (`FleetResourcePage.tsx:301`, `resources.ts:168-169,273-279`) → the accidental trigger for H1/H2. +- **`AssignWagonDialog.tsx:22-24`** — `status === Available || !w.trainId` should be `&&` (H5). +- **`WagonYardWorkspaceModal.tsx`** — "Assigned→Available" flip picks an arbitrary slice including coupled wagons (H6). +- **`ScheduleWorkspacePanel.tsx:98-109`** — capacity meter sums loco limits instead of the weakest (M15). +- **`InvoiceDetailPage.tsx:129-131` / `PayClearanceFeeButton.tsx:121-125`** — pay button shows `totalAmount`, backend charges `balanceAmount`; the correct `amountDue` line is commented out → a 60%-paid invoice tells the customer they'll pay the full total. Also a leftover `console.log(paymentMethod)` at line 71. +- **Reflected XSS on the public checkout page** — `billing/payment.controller.ts:100-103,156-176` interpolates provider error `message`/`status`/`intentId` unescaped into `@Public()` HTML. → HTML-escape or return a generic message. +- **Portal tracking/consignments pages are still mock-backed** (`portal/src/pages/tracking/shipments.mock.ts`, `consignments.mock.ts`) — they show mock data, not live consignments. + +--- + +## 8. What is working correctly (verified, not bugs) + +So the report is balanced — these were checked and are sound: + +- **Train-builder same-yard enforcement** at build and attach (rejected my cross-yard attempts with clear 400s). +- **Double-coupling a locomotive** → `409 already coupled to train …` (guard works). +- **`train-builder` yard change** correctly relocates the whole consist (locos + wagons follow). +- **Dispatch / arrive / finalize** status guards, dispatch train-number pooling with pessimistic locks, route direction derivation and segment ordering, EAT-timezone math in `batch-window.util.ts` (no string date comparisons). +- **Booking cancellation** can't leak reserved capacity (only pre-reservation statuses cancellable); approval-step sequencing; `allocateContainers` and interchange generation are properly transactional. +- **GT06 GPS codec** coordinate/CRC/UTC decoding correct. +- **notification-inbox WebSocket auth**, **signatures**, **backoffice role management** (blocks reserved roles), **forgot-password/customer-reset** (use the hardened OTP + single-use IAM tickets) — all scope correctly. +- **Clearance-fee migration 2260** itself is sound (nullable/defaulted, `IF NOT EXISTS`); the gate enforcement on document upload is correct on both contract and booking sides (the bugs are around it — M26, and C1–C3 letting the fee be "paid" for free). + +--- + +## 9. Boot-log noise (environment, not code bugs) + +- **SMS + Email over RabbitMQ fail:** `Handshake terminated by server: 403 (ACCESS-REFUSED) … Login was refused using authentication mechanism PLAIN` — broker credentials for the dev environment. (Compounds M21: email is doubly dead — stub strategy *and* no broker.) +- **Swagger duplicate-DTO warnings:** `UpdateProfileDto`, `RequestChangesDto`, `SignContractDto` each defined twice with different schemas ("will throw in the next major version"). Rename the duplicates. +- **`LegacyRouteConverter` warning** on `/api/*` — path-to-regexp v6 wants `/api/*path`. + +--- + +## 10. Test-data side effects from this audit (please review) + +I drove real writes against `edr_freight`. Net state: + +1. **Created train `TR-00002`** (`exportTrainNumber 9901`, `importTrainNumber 9902`, name "QA-TEST-TRAIN") — then **deleted it** (cleaned up). +2. **Destroyed wagon `BW1-0009`** (original UUID `905cad62-1b12-46a0-9268-bacbf115e787`) via the hard-delete bug (H3). **I recreated it via the API** — new UUID `d9b578ed-e65a-48f6-9ae2-fb8239451460`, `AVAILABLE`, in KALITY, same wagon type. The wagon count is whole again; only the UUID changed (its old `wagon_movements` ledger was cascade-deleted and cannot be recovered). +3. **Locomotives briefly moved** during the yard-divergence repro (LOCO-004, LOCO-023, LOCO-025) — **all restored** to KALITY / their original yards. LOCO-004 was briefly set OUT_OF_SERVICE by the decommission test and **restored to AVAILABLE**. + +No other records were mutated. (A raw-SQL restore of the original wagon UUID was intentionally **not** performed — the DB-write guard blocked it, and re-inserting via the API is the correct, app-logic-respecting cleanup.) + +--- + +## 11. Prioritized fix roadmap + +**Do first (security / money — a customer or attacker can exploit these today):** +1. C1 — remove the DEMO auto-settle in `billing.service.ts:973`. +2. C2 / H13 — auth-guard `mark-paid`, `payments/initiate`, `payments/checkout`, `payments/receipt`, and `files/:id`. +3. C3 + C4 — unify currency units and enforce amount verification at settlement. +4. C5 — enable Telebirr/D-Money webhook signature checks. +5. C6 / H10 / H11 / H12 / M24 / M25 — add ownership/permission guards to booking writes, import-operations, interchange-documents, contract-sign, incidents, tracking. + +**Do next (data integrity — the consist family, one structural fix):** +6. **H1–H7 + L1–L2**: the `TrainLocomotive`/`wagon.trainId` coupling guard on every locomotive/wagon mutation (this is your reported bug and its whole family). Switch `DELETE` to soft-delete. +7. H8/H9 capacity & consolidation locks; H17 cancel-status guard; H18 transactional reschedule. + +**Then (correctness):** M1–M27 (container release, weight units, scheduling day/priority/notification bugs, OTP hardening, self-approval). + +**Finally (hardening):** the L-series + the frontend dirty-field / display fixes in §7. + +--- + +*Report generated from a live run of `pnpm run dev:freight` plus a full read of the in-scope modules. Every "live-confirmed" item has a reproduced request/response above; every code finding cites `file:line`.* + +--- + +# ADDENDUM — Fixes Applied (2026-07-16) + +All **18 High + 27 Medium** findings were fixed. Critical (C1–C6) and Low (L-series) were **left untouched** per the request (except L1, which shares the H1 guard). Applied across ~35 backend files + 3 frontend files via 6 partitioned edit passes. + +**Verification status** +- `turbo run type-check --filter=@edr/freight-api` → **clean (0 errors)**. +- Web apps: freight-portal clean; freight-backoffice fails **only** on the pre-existing `user-management/web-Management/**` errors (documented, none in edited files). +- `scheduling-reschedule.service.spec` → **5/5 pass** (updated for the H18 transaction + a future-dated fixture for the new M7 past-date guard). +- App **boots clean** on 3030; migration `2280000000000-WagonNumberPartialUnique` applied and recorded (partial unique index `UQ_wagons_wagon_number_active` verified live). + +**Live-verified fixes (real requests against the running app)** +- **H1** — `PATCH /locomotives/:id` on a coupled loco → `409 "coupled to train TR-00002; move the train instead"` (yard) and `409 "detach it before changing its status"` (status). *This is your reported bug — now behaves exactly as requested.* +- **L1** — decommission coupled loco → `409`. +- **H2** — `PATCH /wagons/:id` yard on coupled wagon → `409`; sending `trainId` → `400 "property trainId should not exist"`. +- **H3** — `DELETE /wagons/:id` on coupled wagon → `409`, wagon **survives** (data-loss bug closed); delete now uses `softRemove`. +- **H4** — `DELETE /trains/:id` on a built train → frees wagon (AVAILABLE, unlinked) + both locos, **soft-deletes** the train (tombstone row, not stranded). + +**What each pass changed (high level)** +- **Fleet (H1–H7, L1):** coupling guard (`train_locomotives` / `wagon.trainId`) on every legacy loco/wagon mutation; `assignToTrain` tightened to builder rules; `bulk-status`/`bulk-transfer`/`unassign` refuse coupled/pinned wagons; hard-delete → soft-delete (+ migration); frontend `AssignWagonDialog` `||`→`&&`, workspace modal excludes coupled wagons. +- **Scheduling (H17, M8–M10, M14, M15):** cancel status-guard; null-date sort fixed; booking-day match check; partial-unassign now releases slots + recomputes TrainSet totals; route edit blocked while live schedules reference it; capacity meter uses weakest loco. +- **Reschedule (H18, M7, M11–M13):** date+event wrapped in one transaction; `newDepartureDate > now` guard; real `MaintenanceRescheduleDto` (validation no longer skipped); merged incoming-booking set; retained-customer notice only when the date actually moves; displaced-fallback clears `trainScheduleId`. +- **Bookings/Billing/Contracts (H8, H9, H12, H14–H16, M26, M27):** export-capacity + consolidation now lock the row (`FOR UPDATE`) and re-check before reserving; payment idempotency guard restored (won't resurrect CANCELLED/advanced); cancel/reject expire open invoices; contract-sign requires ownership + verifies OTP against the company's registered phone; pricing honors frozen contract-rate snapshots; clearance-fee gate hard-fails instead of waiving; invoice state-machine gaps closed. +- **Cargo/Containers/Customs (M1–M6, H10, H11):** container release counts fixed (exclude-self); unload frees container; load rejects over-capacity; container→wagon assign rejects double-assign + wraps position in a txn; interchange state-machine guarded; interchange item weights normalized to tons; import-operations + interchange-documents controllers now permission-guarded. +- **Ops/Auth/Files (H13, M16–M25):** file download requires auth; incidents + tracking guarded; maintenance flips vehicle status/availability; fuel rejects non-positive + duplicate receipts; OTP uses CSPRNG + TTL/attempt-cap/consume-on-success, send no longer resets the brute-force counter; email strategy implemented + failures surfaced; rate/priority self-approval blocked; driver uploads size/type-limited. + +**Residual / partial (flagged in code with TODOs — intentional, safe)** +- **H18** cross-service (un)assign calls still run their own transactions (can't thread the manager without editing the scheduling core); date+event are atomic. +- **M7** kept the raw date write + `>now` guard; booking-window fields are not re-derived (delegating to `updateScheduleDate` would wrongly require `PRE_WINDOW`). +- **M11** best-effort detach (clears `trainScheduleId`); the link-row/allocation delete lives in the scheduling core. +- **M13** format is enforced; `newDepartureDate` is validated-when-present but not strictly required (parent DTO marks it optional). +- **M15** meter capped at weakest loco; consist tare isn't available client-side. +- **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope). +- **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet). +- **M22** self-approval blocked for normal staff; **super admins are exempt** (full backoffice authority — verified live: propose→submit→self-approve → LIVE). Both rates and priority-rule change requests. A distinct CEO/approver permission is still recommended (TODO). +- **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one). +- **M25** view-guarded; company-scoping the query is a TODO. +- **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`). +- **H15** frozen rates cover base rail + surcharges + first/last-mile; a rare container-fallback line keeps the live rate. +- **M3** position race narrowed by a txn; a `(wagon_id, position)` unique index is the full fix (TODO). + +**Not committed.** All changes are in the working tree only. diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 8ecd815ed..603a6b6fc 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -14,6 +14,7 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", + "seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts", "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", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2bee84923..9270dd0a1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -39,10 +39,12 @@ import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; +import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; +import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; @@ -53,29 +55,30 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; -import { DemoUsersSeeder } from "./seed/demo-users.seeder"; -import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +// Disabled seeds — imports commented out with their provider/injection/run below. +// import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; -import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; -import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; -import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; -import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; -import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; -import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; -import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; -import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; +import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; +// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; +// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; +// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; +// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; +// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; -import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; -import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; -import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder"; +// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { VerifaydaModule } from './modules/verifayda/verifayda.module'; -import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -101,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware"; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], + load: [ + appConfig, + databaseConfig, + telebirrConfig, + rabbitmqConfig, + faydaConfig, + ], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -159,10 +168,12 @@ import { LoggerMiddleware } from "./logger.middleware"; BillingModule, NotificationsModule, NotificationInboxModule, + SupportChatModule, FileUploadSettingsModule, DropdownSettingsModule, ContractTemplatesModule, OtpModule, + HealthModule, RuleEngineModule, BackofficeModule, DemoPermissionsModule, @@ -195,78 +206,100 @@ import { LoggerMiddleware } from "./logger.middleware"; providers: [ EdrOrgSeeder, FreightPositionsSeeder, - DemoUsersSeeder, - FreightStaffUsersSeeder, - PricingDataSeeder, FileUploadSettingsSeeder, + YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, - DemoFreightDataSeeder, - GovCompaniesSeeder, - EdrTruckFleetSeeder, - IndodeFacilitySeeder, - Batch14TestDataSeeder, - Batch5TestDataSeeder, - Batch7TestDataSeeder, - Batch8TestDataSeeder, - WarehouseDemoSeeder, - ExportDjiboutiInterchangeDemoSeeder, - MarshallingDemoTrainsSeeder, + // Disabled seeds — providers commented out (imports/injection/run too): + // DemoUsersSeeder, + // FreightStaffUsersSeeder, + // PricingDataSeeder, + // DemoFreightDataSeeder, + // GovCompaniesSeeder, + // IndodeFacilitySeeder, + // Batch14TestDataSeeder, + // Batch5TestDataSeeder, + // Batch7TestDataSeeder, + // Batch8TestDataSeeder, + // WarehouseDemoSeeder, + // ExportDjiboutiInterchangeDemoSeeder, + // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + // private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, - private readonly demoUsersSeeder: DemoUsersSeeder, - private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, - private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, - private readonly indodeFacilitySeeder: IndodeFacilitySeeder, - private readonly batch14TestDataSeeder: Batch14TestDataSeeder, - private readonly batch5TestDataSeeder: Batch5TestDataSeeder, - private readonly batch7TestDataSeeder: Batch7TestDataSeeder, - private readonly batch8TestDataSeeder: Batch8TestDataSeeder, - private readonly warehouseDemoSeeder: WarehouseDemoSeeder, - private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, - private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, - private readonly demoFreightDataSeeder: DemoFreightDataSeeder, - private readonly govCompaniesSeeder: GovCompaniesSeeder, - private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder, + // Disabled seeds — injections commented out (imports/provider/run too): + // private readonly demoUsersSeeder: DemoUsersSeeder, + // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + // private readonly pricingDataSeeder: PricingDataSeeder, + // private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + // private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + // private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + // private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + // private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + // private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + // private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + // private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + // private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + // private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { + // ── Enabled: permissions + file-upload settings (+ dropdown settings) only ── + // Everything else below is intentionally disabled. Seeders stay registered + // as providers and injected; only their .run() calls are commented out, so + // re-enabling any of them is a one-line uncomment. + + // 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 + // freightPositionsSeeder → seeds Position + PositionPermission rows + // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); + // await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); - await this.demoUsersSeeder.run(); - await this.freightStaffUsersSeeder.run(); - await this.pricingDataSeeder.run(); + + // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); - await this.indodeFacilitySeeder.run(); - await this.batch14TestDataSeeder.run(); - await this.batch5TestDataSeeder.run(); - await this.batch7TestDataSeeder.run(); - await this.batch8TestDataSeeder.run(); - await this.warehouseDemoSeeder.run(); - await this.exportDjiboutiInterchangeDemoSeeder.run(); - await this.marshallingDemoTrainsSeeder.run(); - // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. - // Each block self-guards on an empty-table check, so this is safe every boot. - // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, - // FileUploadSettingsSeeder) are intentionally disabled — they stay - // registered as providers but are not run. Re-inject + call .run() to enable. - // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval - // rules are disabled inside the seeder). Kept running for the staff users. - await this.demoFreightDataSeeder.run(); - // Government entities (with importer/exporter profiles) that government - // bookings bill to. Idempotent — keyed by fixed IDs. - await this.govCompaniesSeeder.run(); - await this.edrTruckFleetSeeder.run(); + + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, + // Dire Dawa). Idempotent; creates no yards. + await this.yardFacilitiesSeeder.run(); + + // Dropdown settings are not seeded on boot; run them with + // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). + + // ── Disabled: demo / test / reference data seeds ── + // Uncomment a line to re-enable that seed. + // await this.demoUsersSeeder.run(); + // await this.freightStaffUsersSeeder.run(); + // await this.pricingDataSeeder.run(); + // IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so + // it will not recognise a hand-created Indode warehouse and will seed a second + // one alongside it. Only enable it against an Indode that has no warehouse. + // await this.indodeFacilitySeeder.run(); + // await this.batch14TestDataSeeder.run(); + // await this.batch5TestDataSeeder.run(); + // await this.batch7TestDataSeeder.run(); + // await this.batch8TestDataSeeder.run(); + // await this.warehouseDemoSeeder.run(); + // await this.exportDjiboutiInterchangeDemoSeeder.run(); + // await this.marshallingDemoTrainsSeeder.run(); + // demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval + // rules are already disabled inside the seeder). + // await this.demoFreightDataSeeder.run(); + // Government entities (importer/exporter profiles) that government bookings + // bill to. Idempotent — keyed by fixed IDs. + // await this.govCompaniesSeeder.run(); } configure(consumer: MiddlewareConsumer) { diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d3344aa5a..a412bf990 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) => ), ); +/** + * Read-only reference data (yard dropdowns, search filters): any signed-in + * staff. Menu/page visibility stays permission-gated in the frontend — this + * only lets forms populate their lookups. + */ +export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); + export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const TrainSchedulingView = () => @@ -22,9 +29,22 @@ export const TrainSchedulingView = () => export const TrainSchedulingManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.manage); -export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); +/** + * Fleet guards take an optional granular per-resource key (locomotives:create, + * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain + * valid as a one-of fallback so existing role grants keep working. + */ +export const FleetView = (granular?: string) => + BookingStaff( + granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + ); -export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +export const FleetManage = (granular?: string) => + BookingStaff( + granular + ? [granular, FREIGHT_PERMS.fleet.manage] + : FREIGHT_PERMS.fleet.manage, + ); /** Requester creates a wagon-transfer request (count-only, no wagon picks). */ export const WagonTransferRequest = () => @@ -34,6 +54,10 @@ export const WagonTransferRequest = () => export const WagonTransferFulfill = () => BookingStaff(FREIGHT_PERMS.wagons.transferFulfill); +/** Admin: read every staffer's wagon-transfer history (not just one's own). */ +export const WagonTransferHistoryAll = () => + BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); + /** 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/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts new file mode 100644 index 000000000..d0d01535a --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -0,0 +1,51 @@ +import { + assertCanApproveContractStep, + canEditContractStep, +} from './freight-permission.util'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +// The document-edit gate (canEditContractStep) must be STRICT: only the approver +// whose turn it is may edit. This is the fix for a previous approver keeping the +// "Edit contract articles" button after acting, because the approve gate lets +// through anyone holding any contract-approve permission. +describe('canEditContractStep (strict per-step edit gate)', () => { + const director = { + employee: { position: { positionType: { key: '-marketing-director-' } } }, + }; + // A line staff who already approved their own step but still holds a + // contract-approve permission — the exact actor that leaked edit rights. + const officerWithApprovePerm = { + employee: { + position: { + positionType: { key: '-marketing-officer-' }, + permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }], + }, + }, + }; + const superAdmin = { roles: [{ key: 'super_admin' }] }; + + it('lets the step’s own approver edit', () => { + expect(canEditContractStep(director, '-marketing-director-')).toBe(true); + }); + + it('lets an approval admin edit any step', () => { + expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true); + }); + + it('does NOT let a different approver edit just because they hold an approve permission', () => { + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); + + it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => { + // The approve gate passes the officer via the any-permission blanket… + expect(() => + assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'), + ).not.toThrow(); + // …but the edit gate does not — that divergence IS the fix. + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); +}); 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 69596c21d..429c910d3 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin'; const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; type PermissionLike = { key?: string }; +type PositionTypeLike = { key?: string }; type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: | { - position?: { permissions?: PermissionLike[] }; + position?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }; delegatedPositions?: { permissions?: PermissionLike[] }[]; } | { - positions?: { permissions?: PermissionLike[] }[]; + positions?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }[]; }[] | null; }; @@ -90,12 +97,138 @@ export function assertFreightPermission( throw new ForbiddenException(`Missing permission: ${permissionKey}`); } +/** + * The caller's IAM position-type keys (`iam.position_types.key`). A position + * type is the platform's notion of a role — it is what carries permissions via + * `iam.position_type_permissions` — and it is the vocabulary contract approval + * chains are configured in. + * + * Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is + * an object on some tokens and an array on others. + * + * Note delegated positions carry no `positionType` in the token, so a delegate + * is not reachable here — they authorize through the permission arm of + * `assertCanApproveContractStep` instead. + */ +export function collectPositionTypeKeys( + user: MeLikeUser | null | undefined, +): string[] { + const employee = user?.employee; + if (!employee) return []; + + const keys = new Set(); + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + if (pos.positionType?.key) keys.add(pos.positionType.key); + } + } + return [...keys]; + } + + if (employee.position?.positionType?.key) { + keys.add(employee.position.positionType.key); + } + return [...keys]; +} + +/** + * Legacy chain roles predate position types. Historical `approval_rules` and + * in-flight `contract_approval_steps` rows still carry them, so map each to the + * position types that stand in for it. Without this, an approver holding a + * modern position type could not action an older step. + */ +const LEGACY_ROLE_POSITION_TYPES: Record = { + LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'], + DIRECTOR: ['director', 'operation-director'], + CEO: ['chief', 'deputy'], +}; + const APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, CEO: FREIGHT_PERMS.bookings.approveCeo, }; +const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, +}; + +const ANY_CONTRACT_APPROVE_PERMISSION = [ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, +]; + +/** + * May this caller action a contract approval step requiring `requiredRole`? + * + * `requiredRole` is an `iam.position_types.key` for chains configured by an + * admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows. + * A caller passes when any of these hold: + * + * - they are a super/organization admin (blanket bypass); + * - their position type matches the step, directly or via a legacy alias; + * - they hold the approve permission the legacy role maps to; + * - they hold any contract approve permission — this covers delegates (whose + * position type is absent from the token) and staff whose IAM position has + * no position type assigned yet. + */ +export function assertCanApproveContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + if (legacyPermission && hasFreightPermission(user, legacyPermission)) return; + + if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) { + return; + } + + throw new ForbiddenException( + `You are not the required approver (${requiredRole}) for this step.`, + ); +} + +/** + * Strict "is it exactly this caller's turn?" test — mirrors the backoffice + * `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep} + * EXCEPT the blanket "holds any contract-approve permission" fallback is + * dropped: a line-staff holding `approveLineStaff` must NOT read as the director + * for a director step. Used to gate contract-document editing so approval hands + * edit rights to the NEXT approver only — a previous approver who already acted + * (but still holds an approve permission) loses the edit button, as required. + * + * (Kept separate from the approve/reject gate, which keeps the blanket fallback + * so delegates whose token omits a position type can still action their step.) + */ +export function canEditContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): boolean { + if (isFreightApprovalAdmin(user)) return true; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return true; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return true; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission)); +} + export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts new file mode 100644 index 000000000..5cae30302 --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -0,0 +1,13 @@ +/** + * Goods Received Note number: `GRN---`. + * + * 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 { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; +} diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts new file mode 100644 index 000000000..8c3310321 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts @@ -0,0 +1,52 @@ +import { usesEdrMileService } from './mile-haulage.util'; + +/** + * The road legs are chosen on the contract and copied onto the booking. EDR + * haulage and a customer's own truck are alternatives, so this one answer gates + * both sides — the customer-truck guard and the mile-queue guard. + */ +describe('usesEdrMileService', () => { + const booking = (over: Partial[0]> = {}) => ({ + tradeDirection: 'IMPORT', + firstMile: null, + lastMile: null, + ...over, + }); + + it('an import that chose delivery uses EDR haulage', () => { + expect(usesEdrMileService(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(true); + }); + + it('an import that chose nothing does not', () => { + expect(usesEdrMileService(booking())).toBe(false); + }); + + it('ignores the pickup address on an import — collection is the export leg', () => { + expect(usesEdrMileService(booking({ firstMile: 'Modjo' }))).toBe(false); + }); + + it('an export that chose collection uses EDR haulage', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })), + ).toBe(true); + }); + + it('ignores the delivery address on an export — delivery is the import leg', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })), + ).toBe(false); + }); + + it('a domestic booking counts either leg', () => { + expect( + usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })), + ).toBe(true); + expect( + usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), + ).toBe(true); + }); + + it('treats a whitespace-only address as no choice', () => { + expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.ts b/apps/edr-freight-api/src/common/mile-haulage.util.ts new file mode 100644 index 000000000..1ca83d06e --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-haulage.util.ts @@ -0,0 +1,49 @@ +/** The booking fields that decide who hauls the road legs. */ +export interface MileHaulageRow { + tradeDirection: string | null; + /** `first_mile_pickup_address` — set when the customer asked EDR to collect. */ + firstMile: string | null; + /** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */ + lastMile: string | null; +} + +/** + * Whether the customer bought the EDR road leg that matters for their direction: + * delivery at the end of an import, collection at the start of an export. A + * DOMESTIC booking can use either, so either one counts. + * + * The address is the signal because it is the only per-booking record of the + * choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be + * used — every service type ships with both set to true, so reading them would + * mean every booking uses EDR haulage and none could ever self-haul. + */ +export function usesEdrMileService(booking: MileHaulageRow): boolean { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + switch (booking.tradeDirection) { + case 'IMPORT': + return hasLastMile; + case 'EXPORT': + return hasFirstMile; + default: + return hasFirstMile || hasLastMile; + } +} + +/** + * EDR haulage and a customer's own truck are alternatives, never both. Whichever + * side is being set up, it has to reject the other — a guard on only one side + * lets the two paths open on the same booking, each unaware of the other. + */ +export const SELF_HAUL_CONFLICT_MESSAGE = + 'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.'; + +export const EDR_HAULAGE_CONFLICT_MESSAGE = + 'Customer truck assignment is only allowed when first/last mile delivery is not selected'; + +/** + * The road legs are chosen on the contract. A booking whose contract bought + * neither has no business in the first/last-mile queues at all. + */ +export const NO_MILE_SERVICE_MESSAGE = + 'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.'; 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 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts new file mode 100644 index 000000000..fbb3a436a --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -0,0 +1,159 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + remainingBulkTons, +} from './truck-load.util'; + +/** + * One physical rule, shared by customer self-haul and EDR last-mile. It used to + * be written out three times (addTruck, updateTruck, departTruck) plus a fourth + * in LastMileService. + */ +describe('assertTruckLoad', () => { + const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111']; + + it('accepts two 20ft containers on one truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '20ft'], + }), + ).not.toThrow(); + }); + + it('accepts a single 40ft container', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['40ft'], + }), + ).not.toThrow(); + }); + + it('rejects a 40ft sharing the truck — it fills the bed', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['40ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects more than two containers', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'], + bookingContainers: booking, + sizes: ['20ft', '20ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container that is not on the booking', () => { + expect(() => + assertTruckLoad({ + containers: ['ZZZZ9999999'], + bookingContainers: booking, + sizes: ['20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container already riding another truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['20ft'], + assignedElsewhere: ['ABCD1234567'], + }), + ).toThrow(ConflictException); + }); + + it('skips membership checks when the booking has no containers (bulk)', () => { + expect(() => + assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }), + ).not.toThrow(); + }); + + it('still caps the count when the booking has no containers', () => { + expect(() => + assertTruckLoad({ + containers: ['A', 'B', 'C'], + bookingContainers: [], + sizes: [], + }), + ).toThrow(BadRequestException); + }); +}); + +describe('assertBulkTonnageRemains', () => { + it('allows another truck while tonnage is left', () => { + expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow(); + }); + + it('rejects a truck once the booking is fully hauled', () => { + expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException); + }); + + it('does not cap a booking with no declared weight', () => { + // Nothing to draw down against — capping here would block every truck. + expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow(); + }); +}); + +describe('remainingBulkTons', () => { + const dataSourceReturning = (totalTons: string, hauledTons: string) => + ({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never; + + it('counts trucks from both haulage paths against the declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1'); + + expect(result).toEqual({ + totalTons: 100, + hauledTons: 60, + remainingTons: 40, + complete: false, + }); + }); + + it('is complete once everything is hauled', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('never reports negative tonnage when trucks overshoot', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('is not complete for a booking with no declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1'); + + expect(result.complete).toBe(false); + }); +}); + +describe('assertTruckCountWithinContainers', () => { + it('allows one truck per container', () => { + expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow(); + }); + + it('rejects more trucks than containers', () => { + expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException); + }); + + it('does not cap a bulk booking, which has no container count', () => { + expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts new file mode 100644 index 000000000..b65bc12fb --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -0,0 +1,148 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +/** Two 20ft containers fit a truck bed; one 40ft fills it. */ +export const MAX_CONTAINERS_PER_TRUCK = 2; + +/** + * What one truck is being asked to carry, and the booking context to judge it + * against. `sizes` are the container_size labels of `containers`, in any order — + * only whether a 40ft is present matters. + */ +export interface TruckLoadCheck { + containers: string[]; + /** Every container number on the booking. Empty means nothing to validate against. */ + bookingContainers: string[]; + sizes: string[]; + /** Containers already riding another truck on this booking. */ + assignedElsewhere?: string[]; +} + +/** + * The physical rule for loading one truck, shared by both haulage paths. + * + * A customer's own truck and an EDR last-mile truck obey the same physics, but + * the rule was implemented twice — once in CustomerTruckService, once in + * LastMileService — along with a byte-identical container-size query. Two copies + * of one rule drift, and that is exactly how the self-haul guard ended up + * enforced on one side only. + */ +export function assertTruckLoad({ + containers, + bookingContainers, + sizes, + assignedElsewhere = [], +}: TruckLoadCheck): void { + if (containers.length > MAX_CONTAINERS_PER_TRUCK) { + throw new BadRequestException( + `A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`, + ); + } + + // With no container list on the booking there is nothing to check membership + // against — bulk bookings take this path. + if (!bookingContainers.length) return; + + for (const number of containers) { + if (!bookingContainers.includes(number)) { + throw new BadRequestException( + `Container ${number} is not one of this booking's containers`, + ); + } + if (assignedElsewhere.includes(number)) { + throw new ConflictException(`Container ${number} is already loaded onto another truck`); + } + } + + // A 40ft fills the bed, so it travels alone. + if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } +} + +/** Never put more trucks on a booking than it has containers to fill them. */ +export function assertTruckCountWithinContainers( + truckCount: number, + bookingContainerCount: number, +): void { + if (bookingContainerCount > 0 && truckCount > bookingContainerCount) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`, + ); + } +} + +/** + * How much of a bulk booking is still to be hauled. Counts trucks from BOTH + * haulage paths — a booking uses one or the other, and the rule ("trucks until + * no tonnage is left") is the same either way, so a single sum keeps them from + * disagreeing. + * + * Only departed trucks count: tonnage is known once the truck is weighed out. + */ +export async function remainingBulkTons( + dataSource: DataSource, + bookingId: string, +): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> { + const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> = + await dataSource.query( + `SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons", + COALESCE(( + SELECT SUM(va.net_weight_tons) + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm + ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + WHERE lm.booking_id = b.id + AND va.deleted_at IS NULL + AND va.departed_at IS NOT NULL + ), 0) + + COALESCE(( + SELECT SUM(a.net_weight_tons) + FROM freight.customer_truck_assignments a + WHERE a.booking_id = b.id + AND a.deleted_at IS NULL + AND a.departed_at IS NOT NULL + ), 0) AS "hauledTons" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + const totalTons = Number(row?.totalTons ?? 0); + const hauledTons = Number(row?.hauledTons ?? 0); + const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000); + return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 }; +} + +/** A fully-hauled bulk booking has nothing left for another truck to carry. */ +export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void { + if (totalTons > 0 && remainingTons <= 0) { + throw new BadRequestException( + 'This bulk booking is fully hauled — no tonnage left to assign trucks for', + ); + } +} + +/** + * container_size labels for the given container numbers on a booking. Shared so + * the two haulage paths read sizes the same way. + */ +export async function bookingContainerSizes( + dataSource: DataSource, + bookingId: string, + numbers: string[], +): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((row) => (row.size ?? '').trim()); +} diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts new file mode 100644 index 000000000..dd22f3c37 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts @@ -0,0 +1,72 @@ +import { validate } from 'class-validator'; +import { IsISO8601, IsOptional } from 'class-validator'; + +import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator'; + +class Subject { + @IsOptional() + @IsISO8601() + @IsNotBackdated() + occurredAt?: string; +} + +const subjectWith = (occurredAt?: string) => { + const subject = new Subject(); + subject.occurredAt = occurredAt; + return subject; +}; + +const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt)); + +const backdatedErrors = (errors: Awaited>) => + errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated')); + +describe('IsNotBackdated', () => { + it('rejects a timestamp from the past', async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const errors = await errorsFor(yesterday); + + expect(backdatedErrors(errors)).toHaveLength(1); + expect(errors[0].constraints?.IsNotBackdated).toBe( + 'occurredAt cannot be backdated — it must be now or later', + ); + }); + + it('accepts now', async () => { + const errors = await errorsFor(new Date().toISOString()); + + expect(errors).toHaveLength(0); + }); + + it('accepts a value stale only by transit and clock skew', async () => { + // What an honest caller sends: "now" as of when the request was built. + const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString(); + + const errors = await errorsFor(almostNow); + + expect(errors).toHaveLength(0); + }); + + it('rejects a value staler than the skew allowance', async () => { + const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString(); + + const errors = await errorsFor(tooStale); + + expect(backdatedErrors(errors)).toHaveLength(1); + }); + + it('ignores an absent value so @IsOptional decides', async () => { + const errors = await errorsFor(undefined); + + expect(errors).toHaveLength(0); + }); + + it('leaves an unparseable value to the format validator', async () => { + const errors = await errorsFor('not-a-date'); + + // Reported as a format problem, not as a backdate. + expect(backdatedErrors(errors)).toHaveLength(0); + expect(errors[0].constraints).toHaveProperty('isIso8601'); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts new file mode 100644 index 000000000..7a767e7f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts @@ -0,0 +1,56 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * A caller may not stamp an event as having happened before now. + * + * A request cannot reach the server at the instant it was built, and a caller's + * clock is not the server's, so a timestamp that honestly means "now" always + * arrives a little stale. Comparing straight against `Date.now()` would reject + * it. The skew allowance below is what makes an honest "now" pass — it is not a + * window for backdating, and it is deliberately far too small to reach any + * earlier event worth backdating to. + */ +export const CLOCK_SKEW_TOLERANCE_MS = 60_000; + +@ValidatorConstraint({ name: 'IsNotBackdated', async: false }) +export class IsNotBackdatedConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + // Absence is not this validator's business; pair with @IsOptional. + if (value === undefined || value === null || value === '') return true; + const parsed = new Date(value as string | Date); + // An unparseable value is a format error — let @IsISO8601/@IsDateString own + // that message rather than reporting it as a backdate. + if (Number.isNaN(parsed.getTime())) return true; + const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS; + return parsed.getTime() >= Date.now() - toleranceMs; + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} cannot be backdated — it must be now or later`; + } +} + +/** + * Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}. + * Pass a different tolerance only with a reason. + */ +export function IsNotBackdated( + toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS, + validationOptions?: ValidationOptions, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [toleranceMs], + validator: IsNotBackdatedConstraint, + }); + }; +} diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts new file mode 100644 index 000000000..cf6ca7a6e --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts @@ -0,0 +1,54 @@ +import { + validate, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { IsTin, normalizeTin } from './is-tin.validator'; + +class Required { + @IsString() + @IsNotEmpty() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin!: string; +} + +class Optional { + @IsOptional() + @IsString() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin?: string; +} + +async function errs(cls: any, tin: any) { + const o = new cls(); + o.tin = tin; + return (await validate(o)).length; +} + +describe('IsTin', () => { + it('accepts a real 10-digit TIN', async () => { + expect(await errs(Required, '0012345678')).toBe(0); + }); + + it.each([ + ['letters', 'ABCDEFGHIJ'], + ['symbols', '!!!!!!!!!!'], + ['too short', '123'], + ['too long', '12345678901'], + ['draft TIN', 'D123456789'], + ['spaced', '012 345678'], + ])('rejects %s', async (_label, value) => { + expect(await errs(Required, value)).toBeGreaterThan(0); + }); + + it('rejects empty on the required DTO but allows omission on the optional one', async () => { + expect(await errs(Required, '')).toBeGreaterThan(0); + expect(await errs(Optional, undefined)).toBe(0); + }); + + it('normalizes messy input', () => { + expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678'); + expect(normalizeTin('')).toBe(''); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts new file mode 100644 index 000000000..9396a884d --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts @@ -0,0 +1,54 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** An Ethiopian TIN is exactly 10 digits. */ +export const TIN_REGEX = /^\d{10}$/; + +/** + * Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by + * CompaniesService.generateDraftTin(), because the column is NOT NULL + unique. + * Those never travel through a DTO, so this constraint deliberately rejects them + * — a "D…" value arriving on a request body is client-supplied and invalid. + */ +@ValidatorConstraint({ name: 'IsTin', async: false }) +export class IsTinConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. + if (value === undefined || value === null || value === '') return true; + if (typeof value !== 'string') return false; + return TIN_REGEX.test(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be exactly 10 digits`; + } +} + +/** Class-validator decorator enforcing the 10-digit TIN format. */ +export function IsTin(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsTinConstraint, + }); + }; +} + +/** + * Strip everything that isn't a digit and cap at 10 characters. Tolerant — + * never throws; returns the value unchanged when empty/nullish. + */ +export function normalizeTin( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined || value === null || value === '') return value; + return value.replace(/\D/g, '').slice(0, 10); +} diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index e493cc393..050b07145 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -9,6 +9,14 @@ export default registerAs("app", () => ({ env: process.env.NODE_ENV ?? "development", port: parseInt(process.env.PORT ?? "3001", 10), apiPrefix: "api", + /** + * Public origin of the freight customer portal. Password-reset links mailed + * or SMS'd to customers are built against this, so it must be the address the + * customer's browser can actually reach — not an internal service name. + */ + portalBaseUrl: ( + process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173" + ).replace(/\/+$/, ""), trainScheduling: { maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 6aacab16c..92bef9dd3 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -13,6 +13,9 @@ export interface RenderedClause { /** A dynamic article ready for the Handlebars template. */ export interface RenderedArticle { number: number; + /** Stable article id from the template (e.g. "pricing") — lets the layout + * inject the live rate schedule table under the pricing article. */ + id: string; title: string; /** Set (instead of clauses) when the body is a single plain paragraph. */ paragraph?: string; 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 559415fd8..363008660 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,7 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; -import { Contract } from '../modules/contracts/entities/contract.entity'; +import { + Contract, + ContractDocumentSnapshot, +} from '../modules/contracts/entities/contract.entity'; import { ContractRoute } from '../modules/contracts/entities/contract-route.entity'; import { ContractSignature, @@ -11,7 +14,11 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing. import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service'; import { ContractTemplateResolver } from './contract-template.resolver'; import { getTemplateMeta } from './contract-template.registry'; -import { ContractViewModel } from './contract-view-model.builder'; +import { + ContractDynamicTemplateView, + ContractViewModel, +} from './contract-view-model.builder'; +import { RateSchedule } from './contract-rate-schedule.builder'; /** * Signature row for the contract PDF. Mirrors the booking builder's @@ -90,22 +97,36 @@ export class ContractDocumentViewModelBuilder { contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); let template = getTemplateMeta(templateKey); - // Prefer the admin-editable DB template matching the contract's - // direction/freight pair; fall back to the code-defined generic layout - // when none is active. - const dynamicSource = await this.contractTemplates.findActiveForContract( - contract.tradeDirection, - contract.freightType, - ); - const dynamicTemplate = dynamicSource - ? { - code: dynamicSource.code, - name: dynamicSource.name, - documentTitle: dynamicSource.documentTitle, - whereasClauses: dynamicSource.whereasClauses ?? [], - articles: dynamicSource.articles ?? [], - } - : undefined; + // The document articles come, in order of preference, from: + // 1. this contract's frozen snapshot (staff accepted / edited it) — the + // shared six templates are never consulted for these contracts; + // 2. the admin-editable DB template matching the direction/freight pair; + // 3. the code-defined generic layout (handled below when none of the above). + const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null; + let dynamicTemplate: ContractDynamicTemplateView | undefined; + if (snapshot && (snapshot.articles?.length ?? 0) > 0) { + dynamicTemplate = { + code: snapshot.code ?? 'CONTRACT', + name: snapshot.name ?? template.title, + documentTitle: snapshot.documentTitle ?? '', + whereasClauses: snapshot.whereasClauses ?? [], + articles: snapshot.articles, + }; + } else { + const dynamicSource = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + dynamicTemplate = dynamicSource + ? { + code: dynamicSource.code, + name: dynamicSource.name, + documentTitle: dynamicSource.documentTitle, + whereasClauses: dynamicSource.whereasClauses ?? [], + articles: dynamicSource.articles ?? [], + } + : undefined; + } if (dynamicTemplate) { template = { ...template, @@ -115,6 +136,7 @@ export class ContractDocumentViewModelBuilder { } const pricing = this.buildPricing(contract); + const rateSchedule = this.buildRateSchedule(pricing); const signatures = await this.loadSignatures(contractId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -157,6 +179,7 @@ export class ContractDocumentViewModelBuilder { }, schedule: this.buildSchedule(contract), pricing: pricing as unknown as ContractViewModel['pricing'], + rateSchedule, // 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'], @@ -210,6 +233,40 @@ export class ContractDocumentViewModelBuilder { }; } + /** + * A rate schedule for the contract PDF, sourced from the contract's own frozen + * unit rates (its agreed lane prices) rather than the global rate config — a + * signed contract must show the prices it was signed on. Rendered as freight + * lanes labelled with the contract's primary origin → destination route. + */ + private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule { + const route = `${pricing.originLabel} → ${pricing.destinationLabel}`; + const freightLanes = pricing.unitRates.map((line) => ({ + route, + cargo: line.label, + currency: line.currency, + amount: this.formatAmount(line.unitPrice), + unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`, + })); + + return { + freightLanes, + additionalServices: [], + surcharges: [], + isEmpty: freightLanes.length === 0, + currencyLabel: pricing.currency, + }; + } + + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { const firstRoute = this.firstRoute(contract); const cargoScope = (contract.cargoScope ?? [])[0]; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 49fb3416a..765c41142 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { originLabel: 'Nagad', destinationLabel: 'Galaan Multipurpose Port', } as unknown as ContractViewModel['pricing'], + rateSchedule: { + freightLanes: [ + { route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' }, + ], + additionalServices: [ + { route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' }, + ], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', order: 1, }, + { + id: 'pricing', + title: 'Contract Price and Payment Terms', + body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.', + order: 2, + }, { id: 'duration', title: 'Duration', body: 'Valid until August 31, {{contractYear}}.', - order: 2, + order: 3, }, ], }, @@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { expect(html).toContain('#1b9e7a'); }); + it('renders the live rate schedule lane under the pricing article', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Rate Schedule'); + // Base freight lane pulled from the rate config + expect(html).toContain('Nagad → Galaan Multipurpose Port'); + expect(html).toContain('USD 100 per wagon'); + // Additional-service group + expect(html).toContain('First-mile pickup by truck'); + }); + it('keeps the generic layout when no dynamic template is attached', () => { const view = dynamicView(); delete view.dynamicTemplate; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts new file mode 100644 index 000000000..48eadb6c7 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@nestjs/common'; + +import { RatesService } from '../modules/rule-engine/services/rates.service'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { + ContractDirection, + ContractFreight, +} from './contract-template.types'; + +/** One priced line in the contract's rate schedule. */ +export interface RateScheduleRow { + /** "Negad → Mojo Dry Port" for base freight, service name otherwise. */ + route: string; + /** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */ + cargo: string; + currency: string; + /** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */ + amount: string; + /** Human unit, e.g. "per container", "per wagon", "per ton". */ + unit: string; +} + +/** + * The origin → destination rate schedule shown in a generated contract's + * pricing article. Grouped so the reader sees rail freight lanes first, then + * pickup/delivery legs, then trigger-based surcharges and demurrage. + */ +export interface RateSchedule { + /** Base rail freight lanes matching this contract's direction + freight. */ + freightLanes: RateScheduleRow[]; + /** First-mile / last-mile truck legs (route-agnostic). */ + additionalServices: RateScheduleRow[]; + /** Hazard, reefer, overweight, demurrage, customs, etc. */ + surcharges: RateScheduleRow[]; + /** True when every group is empty — the template falls back to prose. */ + isEmpty: boolean; + /** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */ + currencyLabel: string; +} + +const UNIT_LABELS: Record = { + PER_WAGON: 'per wagon', + PER_TON: 'per ton', + PER_CONTAINER: 'per container', + PER_KM: 'per km', + PER_INVOICE: 'per invoice', + FLAT: 'flat', +}; + +const SERVICE_ROUTE_LABELS: Partial> = { + FIRST_MILE: 'First-mile pickup by truck', + LAST_MILE: 'Last-mile delivery by truck', +}; + +/** Friendly wording for the trigger-based charges shown in the surcharge group. */ +const TRIGGER_ROUTE_LABELS: Partial> = { + HAZARDOUS: 'Hazardous cargo surcharge', + OVERWEIGHT: 'Overweight surcharge', + REEFER: 'Reefer (refrigerated) surcharge', + WITH_RETURN: 'Empty-container return service', + SHIPPING_LINE: 'Shipping line handling', + CONSOLIDATION: 'Penalty (container consolidation)', + LASHING: 'Cargo lashing and securing', + CANCELLATION: 'Booking cancellation fee', + DEMURRAGE: 'Demurrage / wagon detention', + PIL_EXTRA_FEE: 'PIL shipping line extra fee', + CUSTOMS_CLEARANCE: 'Customs clearance service', +}; + +@Injectable() +export class ContractRateScheduleBuilder { + constructor(private readonly ratesService: RatesService) {} + + /** + * Build the rate schedule for a contract of the given direction + freight. + * Base-freight lanes are filtered to the matching trade direction / freight + * kind so an import container contract shows import container lanes only; + * additional services and surcharges are route-agnostic and always shown. + */ + async build( + direction: ContractDirection, + freight: ContractFreight, + ): Promise { + const rates = await this.ratesService.findLiveRatesDetailed(); + + const freightLanes: RateScheduleRow[] = []; + const additionalServices: RateScheduleRow[] = []; + const surcharges: RateScheduleRow[] = []; + + for (const rate of rates) { + if (this.isBaseFreight(rate)) { + if (this.baseFreightMatches(rate, direction, freight)) { + freightLanes.push(this.laneRow(rate)); + } + continue; + } + + if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') { + additionalServices.push(this.serviceRow(rate)); + continue; + } + + // Everything left is a trigger-based charge (surcharge / demurrage / customs). + surcharges.push(this.surchargeRow(rate)); + } + + const currencyLabel = this.currencyLabel([ + ...freightLanes, + ...additionalServices, + ...surcharges, + ]); + + return { + freightLanes, + additionalServices, + surcharges, + isEmpty: + freightLanes.length === 0 && + additionalServices.length === 0 && + surcharges.length === 0, + currencyLabel, + }; + } + + private isBaseFreight(rate: Rate): boolean { + return ( + rate.trigger === 'ALWAYS' && + (rate.appliesTo === 'BULK' || + rate.appliesTo === 'CONTAINER' || + rate.appliesTo === 'INTERCITY') + ); + } + + private baseFreightMatches( + rate: Rate, + direction: ContractDirection, + freight: ContractFreight, + ): boolean { + // Domestic contracts price off intercity rates; the freight kind is carried + // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). + if (direction === 'DOM') { + if (rate.appliesTo !== 'INTERCITY') return false; + return freight === 'BULK' + ? rate.rateType === 'INTERCITY_BULK' + : rate.rateType === 'INTERCITY_CONTAINER'; + } + + // Import / export price off BULK or CONTAINER rates matching the direction. + const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER'; + if (rate.appliesTo !== wantAppliesTo) return false; + const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT'; + return rate.tradeDirection === wantDirection; + } + + private laneRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + return { + route: `${origin} → ${destination}`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private serviceRow(rate: Rate): RateScheduleRow { + return { + route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private surchargeRow(rate: Rate): RateScheduleRow { + return { + route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + /** The type a rate is scoped to (container/cargo), or a dash when unscoped. */ + private cargoLabel(rate: Rate): string { + return ( + rate.containerType?.label ?? + rate.containerType?.code ?? + rate.cargoType?.cargoTypeName ?? + '—' + ); + } + + private unitLabel(unit: Rate['rateUnit']): string { + return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' '); + } + + /** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */ + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + + private currencyLabel(rows: RateScheduleRow[]): string { + const seen: string[] = []; + for (const row of rows) { + if (!seen.includes(row.currency)) seen.push(row.currency); + } + return seen.join(', ') || 'USD'; + } + + private titleCase(value: string): string { + return value + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index a66a516c0..b0fc4c8ef 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -58,6 +58,15 @@ describe('ContractRendererService', () => { destinationLabel: 'Modjo', containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], }, + rateSchedule: { + freightLanes: [ + { route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' }, + ], + additionalServices: [], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: true, canSignStaff: false, diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index 7b3301c87..b1b02c62f 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((article, index) => ({ number: index + 1, + id: article.id, title: interpolateTemplateText(article.title, view), ...parseArticleBody(interpolateTemplateText(article.body, view)), })); 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 d90b8e709..8b8b92f09 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 @@ -7,6 +7,7 @@ import { ContractSignerRole, } from '../modules/bookings/entities/booking-contract-signature.entity'; import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; @@ -74,6 +75,12 @@ export interface ContractViewModel { lastMileDeliveryAddress: string; }; pricing: PricingSchedule; + /** + * The live origin → destination rate schedule (base freight lanes + services + * + surcharges) matching this contract's direction and freight kind. Drives + * the pricing article's rate table so the contract mirrors the rate config. + */ + rateSchedule: RateSchedule; signatures: ContractSignatureView[]; canSignCustomer: boolean; canSignStaff: boolean; @@ -89,6 +96,7 @@ export class ContractViewModelBuilder { private readonly bookingsRepository: BookingsRepository, private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, + private readonly rateScheduleBuilder: ContractRateScheduleBuilder, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -101,6 +109,10 @@ export class ContractViewModelBuilder { booking.contractTemplateKey ?? this.templateResolver.resolve(booking); const template = getTemplateMeta(templateKey); const pricing = await this.pricingBuilder.build(booking); + const rateSchedule = await this.rateScheduleBuilder.build( + template.direction, + template.freight, + ); const signatures = await this.loadSignatures(bookingId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -143,6 +155,7 @@ export class ContractViewModelBuilder { }, schedule: this.buildSchedule(booking), pricing, + rateSchedule, signatures, canSignCustomer: booking.status === 'CONTRACT_READY' && !hasCustomer, diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index 64319612a..eec54fa79 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,25 +25,9 @@

Equipment return: {{pricing.equipmentReturn}}

{{/if}} - {{#if pricing.unitRates}} -

Unit Rate Schedule

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting - totals are determined per shipment at booking time; no total contract value is fixed at this stage. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{#unless rateSchedule.isEmpty}} +

Rate Schedule

+ {{> rate_schedule}} {{else}}

Charges

@@ -76,7 +60,7 @@
- {{/if}} + {{/unless}}

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index bec620655..4bdc9a24a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -20,5 +20,9 @@ {{/each}} {{/if}} + {{#if (eq id "pricing")}} +

Rate Schedule

+ {{> rate_schedule}} + {{/if}} {{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs new file mode 100644 index 000000000..f0cb0fa7d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs @@ -0,0 +1,55 @@ +{{#if rateSchedule.isEmpty}} +

+ No published rate schedule is currently on file for this corridor. Applicable charges will be quoted + by the Service Provider per shipment in accordance with the prevailing EDR tariff. +

+{{else}} +

+ The charges below are the current published railway tariff for this contract's trade direction and + freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting + totals are determined per shipment at booking time. +

+ + + + + + + + + + {{#if rateSchedule.freightLanes.length}} + + {{#each rateSchedule.freightLanes}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.additionalServices.length}} + + {{#each rateSchedule.additionalServices}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.surcharges.length}} + + {{#each rateSchedule.surcharges}} + + + + + + {{/each}} + {{/if}} + +
Route / ServiceCargo / EquipmentUnit price
Railway Freight — Origin → Destination
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Additional Services
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Surcharges, Demurrage & Fees
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
+{{/if}} 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 4ba35ea3b..0e06d9f7b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -134,26 +134,8 @@ - {{#if pricing.unitRates.length}} -

Agreed Unit Rates

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and resulting - totals are determined per shipment at booking time. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
- {{/if}} +

Published Rate Schedule

+ {{> rate_schedule}} {{!-- ────────────────────────── Signatures ───────────────────────────── --}} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..7fecfccbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consist adjustments from a schedule: staff can trim free wagons off a built + * train when their tare pushes gross weight over the locomotives' pull limit + * (incl. overage tolerance), or couple extra yard wagons on while weight and + * length headroom remain. Each add/remove is logged here so the schedule keeps + * an auditable history; the built train itself is updated in place. + * + * Plain columns (no FKs) so the history survives wagon/train deletion. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface { + name = 'ScheduleWagonAdjustmentLogs2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_schedule_id uuid NOT NULL, + train_id uuid NOT NULL, + action varchar(10) NOT NULL, + wagon_id uuid NOT NULL, + wagon_number varchar(50) NOT NULL, + adjusted_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id) + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id" + ON freight.schedule_wagon_adjustment_logs (train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_id" + ON freight.schedule_wagon_adjustment_logs (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts new file mode 100644 index 000000000..3f10fa874 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Link each physical wagon move back to the transfer request that drove it, so + * the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103". + * Nullable — legacy moves and non-request manual corrections carry no request. + * Also indexes `moved_by_user_id` for the per-user history queries. + */ +export class LinkWagonMovementToTransferRequest2180000000000 + implements MigrationInterface +{ + name = 'LinkWagonMovementToTransferRequest2180000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request' + ) THEN + ALTER TABLE freight.wagon_movements + ADD CONSTRAINT fk_wm_transfer_request + FOREIGN KEY (transfer_request_id) + REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_transfer_request + ON freight.wagon_movements (transfer_request_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_moved_by + ON freight.wagon_movements (moved_by_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP CONSTRAINT IF EXISTS fk_wm_transfer_request + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP COLUMN IF EXISTS transfer_request_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts new file mode 100644 index 000000000..5a667e2e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Drop the unused reopen-delay knob from the global rules. + * + * The window engine never honoured `reopen_delay_minutes`: a not-yet-full train + * reopens as soon as its payment phase settles, so the real gap between a cycle + * closing and reopening is doc review + payment — nothing else. The per-schedule + * `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at + * creation so the batch board keeps projecting the cycles the customer was shown. + */ +export class DropReopenDelayMinutes2190000000000 implements MigrationInterface { + name = "DropReopenDelayMinutes2190000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts new file mode 100644 index 000000000..65a3d3cda --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every built train owns a fixed pair of run numbers, typed at build time: + * an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002). + * Scheduling copies the route-direction-matched number onto the schedule at + * creation; legacy trains with a null pair keep dispatch-time pool assignment. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class TrainNumberPair2200000000000 implements MigrationInterface { + name = 'TrainNumberPair2200000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS import_train_number varchar(20), + ADD COLUMN IF NOT EXISTS export_train_number varchar(20); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number" + ON freight.trains (import_train_number) + WHERE import_train_number IS NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number" + ON freight.trains (export_train_number) + WHERE export_train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts new file mode 100644 index 000000000..42e8f1dc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Schedule-scoped wagon pins. + * + * Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots + * (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the + * Wagon entity, so the same physical wagon can serve many schedules (the July 17 + * and July 20 runs of one train both use its 50 wagons). The Wagon columns + * `current_train_schedule_id` / `train_set_wagon_id` keep only their physical + * meaning — "out on this DISPATCHED train right now" (stamped at dispatch, + * cleared at arrive/unload/cancel). + * + * This migration erases the legacy pin-time stamps left by the old flow: any + * wagon pointing at a schedule that is not currently DISPATCHED (or that no + * longer exists) gets its pointers cleared, and — when the old flow had parked + * it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only + * while coupled to a built train, otherwise AVAILABLE). + */ +export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface { + name = "ScheduleScopedWagonPins2210000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons w + SET current_train_schedule_id = NULL, + train_set_wagon_id = NULL, + status = CASE + WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE' + ELSE w.status + END + WHERE w.deleted_at IS NULL + AND w.current_train_schedule_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + WHERE ts.id = w.current_train_schedule_id + AND ts.deleted_at IS NULL + AND ts.status = 'DISPATCHED' + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Pin-time stamps cannot be reconstructed (the data was the bug); the + // slots on train_set_wagons still hold every live pin, so down is a no-op. + } +} diff --git a/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts new file mode 100644 index 000000000..5a8cdd035 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds freight.contracts.document_snapshot — a per-contract frozen copy of the + * contract-document template (articles + WHEREAS recitals) captured at staff + * accept. Staff can edit these articles for a single contract before generating + * its PDF; the edit never touches the shared six freight.contract_templates + * rows. Null on existing contracts → the PDF keeps rendering from the live + * template, so this is backward compatible. + */ +export class AddContractDocumentSnapshot2220000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS document_snapshot JSONB; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP COLUMN IF EXISTS document_snapshot; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts new file mode 100644 index 000000000..2fe7c726d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation). + * The column is a plain varchar, so this is a data-only rename. Vehicles keep + * their own RETIRED status — only freight.wagons rows are touched. + */ +export class RenameWagonStatusRetiredToDetained2230000000000 + implements MigrationInterface +{ + name = 'RenameWagonStatusRetiredToDetained2230000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts new file mode 100644 index 000000000..76a59b0f7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every new wagon-transfer request must state WHY the wagons are needed; the + * reason is shown on the OCC request queue. Nullable in the DB — legacy rows + * predate the requirement; the DTO enforces it for new requests. + */ +export class AddTransferRequestReason2240000000000 implements MigrationInterface { + name = 'AddTransferRequestReason2240000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS reason text NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS reason + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts new file mode 100644 index 000000000..f93fc7c95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for priority-rule changes: every create/update/delete of a + * priority config is filed here as a PENDING change request; an approver + * applies or rejects it. `payload` carries the proposed field values (null for + * DELETE), `priority_config_id` the target row (null for CREATE). + */ +export class CreatePriorityRuleChangeRequests2250000000000 + implements MigrationInterface +{ + name = 'CreatePriorityRuleChangeRequests2250000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + action varchar(10) NOT NULL, + priority_config_id uuid NULL REFERENCES freight.priority_configs (id), + payload jsonb NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_prcr_status + ON freight.priority_rule_change_requests (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.priority_rule_change_requests`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts new file mode 100644 index 000000000..c0dc2c818 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Prepaid customs clearance service fee (Path B): + * - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE + * fee line so it is billed via its own clearance invoice and excluded from + * shipment booking totals; + * - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee + * settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS); + * - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's + * fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS). + * All nullable/defaulted — existing rows are untouched and keep today's flow. + */ +export class AddClearanceFeePayment2260000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + DROP COLUMN IF EXISTS is_clearance; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts new file mode 100644 index 000000000..052d46340 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * EDR last-mile is multi-truck: a booking can be served by as many trucks as it + * has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery + * were stamped once per `last_mile` record, so every truck shared one timestamp. + * These per-vehicle columns give each EDR truck its own arrival, leaving and + * weighed load — the same granularity self-haul trucks already have. + * + * Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit + * weighing UI). Named `*_tons` deliberately: the older + * customer_truck_assignments.gross_weight_kg is named kg but stores tonnes. + * All nullable — legacy rows predate per-truck tracking. + */ +export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface { + name = 'AddLastMileTruckArrivalDeparture2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL + `); + + // A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one + // container the legacy scalar `container_number` can hold. Mirrors the + // self-haul customer_truck_containers child table. The scalar stays in place + // (synced to the first container) for backward compatibility. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE, + last_mile_id uuid NOT NULL, + container_number varchar(32) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment" + ON freight.last_mile_vehicle_containers (assignment_id) + `); + // A container rides exactly one truck per delivery. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container" + ON freight.last_mile_vehicle_containers (last_mile_id, container_number) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`); + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS arrived_at, + DROP COLUMN IF EXISTS departed_at, + DROP COLUMN IF EXISTS gross_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts new file mode 100644 index 000000000..a651b8f60 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Re-seed the EDR wagon fleet onto the official ER numbering. + * + * Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons + * on a `-NNNN` scheme and wrote the status as 'Available' — mixed case + * that never matches WagonStatus.Available ('AVAILABLE'), so status filters + * silently returned nothing. This seed uses the enum value. + * + * Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon + * specs (capacity/length/tare) stay owned by wagon_types and are not touched — + * the types already exist and only the wagon↔type link is (re)established here. + */ +type FleetRow = { + code: string; + start: number; + end: number; + count: number; +}; + +/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */ +const FLEET: FleetRow[] = [ + { code: 'PW2', start: 1, end: 220, count: 220 }, + { code: 'CW4', start: 221, end: 330, count: 110 }, + { code: 'CW3', start: 331, end: 350, count: 20 }, + { code: 'KW2', start: 351, end: 370, count: 20 }, + { code: 'KW3', start: 371, end: 390, count: 20 }, + { code: 'NW5', start: 391, end: 940, count: 550 }, + { code: 'BW1', start: 941, end: 950, count: 10 }, + { code: 'GW2', start: 951, end: 1060, count: 110 }, + { code: 'NW6', start: 1061, end: 1080, count: 20 }, + { code: 'NW7', start: 1081, end: 1100, count: 20 }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface { + name = 'SeedEdrWagonFleetErNumbering2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Full replacement: the ER range is the fleet of record, so any wagon + // outside it is stale seed data. Safe to hard-delete — containers and + // train_set_wagons null their link, wagon_movements cascade. + await queryRunner.query(`DELETE FROM freight.wagons;`); + + // Deliberately does NOT create a unique index on wagon_number. It once did, + // to satisfy an ON CONFLICT clause that no longer exists (the DELETE above + // makes collisions impossible). Recreating the plain index here would undo + // WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL + // unique index so soft-deleted wagons stop reserving their number — this + // seeder is run directly by scripts/seed-edr-wagons.ts, which would + // otherwise resurrect the plain index on an already-migrated database. + + for (const row of FLEET) { + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_missing:${row.code}`); + } + + // generate_series builds the range server-side — one round trip per type + // instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon + // was deleted above, so a plain INSERT cannot collide, and the clause would + // otherwise hard-require a unique index this table lacks on some envs. + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id + ) + SELECT + 'ER' || LPAD(seq::text, 4, '0'), + $1::uuid, + 'AVAILABLE', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM generate_series($2::int, $3::int) AS seq; + `, + [typeRecord.id, row.start, row.end], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`, + [wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)], + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts new file mode 100644 index 000000000..4a37b5e98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds freight.booking_container.return_quantity — how many units of a + * container line ship with the empty-container-return service (≤ quantity). + * Mirrors hazardous_quantity / reefer_quantity: captured per line at booking + * creation when the contract enables WITH_RETURN (container freight only) and + * drives the booking-level equipment_return flag that fires the WITH_RETURN + * pricing surcharge. + */ +export class AddContainerReturnQuantity2270000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container + ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP COLUMN IF EXISTS return_quantity; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts b/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts new file mode 100644 index 000000000..f28c81317 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain + * UNIQUE on wagon_number would keep a retired wagon's number reserved forever + * and block ever re-registering that number. Swap it for a PARTIAL unique index + * that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no + * longer occupy their number. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it + * idempotent whether the original uniqueness is the auto-named column + * constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index. + */ +export class WagonNumberPartialUnique2280000000000 implements MigrationInterface { + name = 'WagonNumberPartialUnique2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is + // named (dropping the constraint also drops its backing index). + await queryRunner.query(` + DO $$ + DECLARE con_name text; + BEGIN + FOR con_name IN + SELECT conname + FROM pg_constraint + WHERE conrelid = 'freight.wagons'::regclass + AND contype = 'u' + AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%' + LOOP + EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name); + END LOOP; + END $$; + `); + + // Drop any standalone (non-partial) unique index on wagon_number too. + await queryRunner.query(` + DO $$ + DECLARE idx_name text; + BEGIN + FOR idx_name IN + SELECT c.relname + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE i.indrelid = 'freight.wagons'::regclass + AND i.indisunique + AND i.indpred IS NULL + AND c.relname <> 'UQ_wagons_wagon_number_active' + AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name); + END LOOP; + END $$; + `); + + // Live wagon numbers stay unique; soft-deleted rows are exempt. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active" + ON freight.wagons (wagon_number) + WHERE deleted_at IS NULL; + `); + } + + public async down(): Promise { + // No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted + // wagons share a number, and the partial index is strictly safer. Left in + // place intentionally. + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts new file mode 100644 index 000000000..7e12efdbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Stand the whole wagon fleet in Doraleh. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with a NULL yard — so this must stay later in timestamp order. + * + * A wagon with no yard cannot be coupled to a train (the train builder only + * offers AVAILABLE wagons standing in the train's own yard), which left the + * seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs + * originate from. + * + * The yard is created when absent: environments disagree about which yards + * exist, so this cannot assume one is there. + */ +const YARD_CODE = 'DORALEH'; + +export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface { + name = 'SeedWagonYardDoraleh2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Ensure the yard exists and is usable. Deliberately does NOT overwrite an + // existing label/country — a deployment that already calls this yard + // something else keeps its own naming. + await queryRunner.query( + ` + INSERT INTO freight.yards (code, label, country, is_active, display_order) + VALUES ($1, 'Doraleh', 'Djibouti', true, 12) + ON CONFLICT (code) DO UPDATE SET + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [YARD_CODE], + ); + + const [yard] = await queryRunner.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [YARD_CODE], + ); + + if (!yard?.id) { + throw new Error(`yard_missing:${YARD_CODE}`); + } + + // Whole fleet — a wagon already coupled to a built train follows the train, + // so leave those where they stand. + await queryRunner.query( + ` + UPDATE freight.wagons + SET current_yard_id = $1::uuid, + updated_at = now() + WHERE train_id IS NULL; + `, + [yard.id], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Back to the state SeedEdrWagonFleetErNumbering leaves them in. + await queryRunner.query(` + UPDATE freight.wagons + SET current_yard_id = NULL + WHERE train_id IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts new file mode 100644 index 000000000..620eebc14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its + * destination yard, but only some yards have the equipment to do it. EDR's + * load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad — + * and the set grows, so it must be data, not a constant. + * + * `yards.has_facility` marks a yard as a load/unload point; `yard_facilities` + * holds what that facility can do. Only a facility with `has_warehouse` (Indode + * today) stores cargo, and therefore accrues storage/demurrage — the rest just + * move it on and off the train. + * + * `facility_handling_events` records each load/unload and carries its GRN. + * warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so + * a facility with no warehouse could never have a row. `inventory_id` links to the + * storage record when the facility does have a warehouse. + */ +export class YardFacilities2290000000000 implements MigrationInterface { + name = 'YardFacilities2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yards + ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_facilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + has_warehouse boolean NOT NULL DEFAULT false, + equipment_notes text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + // One facility record per yard. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard" + ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.facility_handling_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid NULL REFERENCES freight.train_schedules(id), + event_type varchar(10) NOT NULL, + grn_number varchar(60) NULL, + quantity numeric(14, 3) NULL, + weight_tons numeric(14, 3) NULL, + inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id), + performed_by varchar(120) NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking" + ON freight.facility_handling_events (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard" + ON freight.facility_handling_events (yard_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn" + ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`); + await queryRunner.query(` + ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair for environments missing the GPS tracking tables. + * + * AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but + * some databases have it RECORDED in public.migrations without the tables ever + * landing. TypeORM never re-runs a recorded migration, so those environments + * stay broken through any number of restarts — the GT06 listener accepts tracker + * packets on its TCP port regardless of schema state and fails per packet with + * `relation "freight.gps_devices" does not exist`, dropping position fixes. + * + * This re-issues the same DDL under a new name so it is applied afresh. Every + * statement is IF NOT EXISTS, so it is a no-op where the tables already exist + * and safe on every environment. + * + * Kept byte-identical to the original DDL on purpose: this must converge on the + * schema the entities expect, not a variant of it. + */ +export class RepairGpsTrackingTables2300000000000 implements MigrationInterface { + name = "RepairGpsTrackingTables2300000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(): Promise { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} diff --git a/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts new file mode 100644 index 000000000..17db8a4e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Customer-support chat. A `support_conversations` row is the single ongoing + * thread with a company; `support_messages` are its text messages. There is no + * lifecycle column — a thread is opened by whichever side speaks first and + * stays open. Enum-like columns are varchar (no PG enum churn). + * + * The unique index on `company_id` is load-bearing, not just an optimization: + * the get-or-create path depends on it to settle concurrent first-messages. + * It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block + * a fresh one. + */ +export class CreateSupportChat2310000000000 implements MigrationInterface { + name = "CreateSupportChat2310000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL, + company_name varchar(200), + created_by_user_id uuid, + last_message_at timestamptz, + last_message_preview varchar(280), + last_message_author_role varchar(12), + customer_last_read_at timestamptz, + agent_last_read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY" + ON freight.support_conversations (company_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG" + ON freight.support_conversations (last_message_at) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL, + author_user_id uuid NOT NULL, + author_role varchar(12) NOT NULL, + author_name varchar(200), + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED" + ON freight.support_messages (conversation_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_conversations`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts new file mode 100644 index 000000000..8e693cf2a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts @@ -0,0 +1,140 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope base rail freight to a route (origin yard → destination yard). + * + * Until now a base-freight rate was keyed by direction + container/bulk scope + * only, so "container import" cost the same whether the box was railed to Dire + * Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which + * is what the business actually sells: `container import, Djibouti → Dire Dawa, + * 500 USD`. + * + * Existing base-freight rates predate the yard pair and cannot be backfilled — + * there is no way to know which route each was meant for. They are retired + * (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot + * and rate_change_requests hold FKs to them (RESTRICT) and those rows are price + * history. Retiring drops them out of pricing and the admin UI just the same; + * the yard-scoped replacements must be re-entered. + * + * Surcharges, first-mile and last-mile rates are untouched: they are not + * route-scoped and keep NULL yards. + */ +export class AddRateYardScope2320000000000 implements MigrationInterface { + name = 'AddRateYardScope2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Yard columns + FKs ────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL, + ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL; + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_origin_yard_id" + FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_destination_yard_id" + FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id); + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`, + ); + + // ── 2. Retire route-less base freight ────────────────────────────────── + // Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE + // RESTRICT and those snapshots are what past bookings were charged. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'ALWAYS' + AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'); + `); + + // ── 3. Route is part of a rate's identity ────────────────────────────── + // Two rates may now share rateType + scope + unit as long as they price + // different legs, so the yard pair joins the uniqueness tuple. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + // ── 4. Base freight must carry a route; nothing else may ─────────────── + // Retired rows are exempt — they are the route-less rates step 2 just + // superseded, and they must stay readable for snapshot history. + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The retired rates are not un-superseded: which route each belonged to was + // never recorded, so reviving them would restore rates that price the wrong + // legs. Down only reverses the schema. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP COLUMN IF EXISTS destination_yard_id, + DROP COLUMN IF EXISTS origin_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts b/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts new file mode 100644 index 000000000..1f383f1da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-SupportChatAttachments.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Let a support message carry files instead of text. + * + * No new table: chat attachments reuse the polymorphic `freight.files` record + * with `resource = 'support_message'` and `resource_id = `, the same + * way bookings/contracts/companies already store theirs. + * + * The only schema change is dropping NOT NULL from `support_messages.body`, so + * an attachment-only message can say "there is no text" rather than smuggling + * that fact through an empty string. DROP NOT NULL is a catalog-only change in + * Postgres — no table rewrite, no long lock — so this is safe on a live table. + * + * The partial index on (resource, resource_id) is what makes hydrating a page of + * messages one indexed lookup instead of a scan of every file row in the system. + */ +export class SupportChatAttachments2320000000000 implements MigrationInterface { + name = "SupportChatAttachments2320000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.support_messages + ALTER COLUMN body DROP NOT NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP" + ON freight.files (resource, resource_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP" + `); + + // Re-imposing NOT NULL would fail on any attachment-only message written + // while this migration was applied. Backfill those to '' first so the + // rollback is deterministic rather than dependent on production data. + await queryRunner.query(` + UPDATE freight.support_messages SET body = '' WHERE body IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.support_messages + ALTER COLUMN body SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts new file mode 100644 index 000000000..d6e7ae273 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A facility handles what its equipment can handle. Containers need a reach + * stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs + * far less, so all five facilities load and unload it. + * + * Both default true — a facility handles everything unless someone says + * otherwise, which keeps existing rows working and makes the seeder the place + * where the real capability is stated. + */ +export class YardFacilityFreightTypes2320000000000 implements MigrationInterface { + name = 'YardFacilityFreightTypes2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS handles_container, + DROP COLUMN IF EXISTS handles_bulk + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts new file mode 100644 index 000000000..b435e55ea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a global "booking close offset" — how long BEFORE departure a schedule's + * booking window shuts — configurable separately for import and export. + * + * When an offset is set, the window's close instant is `departure − offset` + * (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure + * Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole + * booking lifecycle: the first window close, every reopen cycle, and the export + * FCFS close all land at/at-or-before this cutoff instead of at departure. + * + * NULL / 0 preserves the previous behaviour exactly (import closes at + * open+duration clamped to departure; export closes at departure), so existing + * installs are unaffected until an offset is entered. + * + * `*_close_offset_minutes` on the global-rules singleton is the live config; the + * matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown + * even after a later global-rules edit. Both are nullable with no backfill — + * absent means "no offset", the safe default. + */ +export class AddBookingCloseOffset2330000000000 implements MigrationInterface { + name = "AddBookingCloseOffset2330000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_import_close_offset_minutes, + DROP COLUMN IF EXISTS rule_export_close_offset_minutes; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_close_offset_minutes, + DROP COLUMN IF EXISTS export_close_offset_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts new file mode 100644 index 000000000..6585cc842 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add `has_lashing` to cargo types. + * + * When true, every booking of that cargo type incurs the flat LASHING + * surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing + * cargo ships without the fee until the flag is turned on. + */ +export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface { + name = "AddCargoTypeHasLashing2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS has_lashing; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts new file mode 100644 index 000000000..ad389e929 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add an opt-in "reverse wagon order" flag to a train schedule. + * + * When true, the built wagon plan is flipped at build time so the physically-last + * wagon sits at position 1. Only the order (sequence_no) changes — composition and + * booking allocations travel with their slot. The flag is frozen on the schedule + * at creation and re-applied every time the wagon plan is rebuilt, so the stored + * train order and the schedule order always match. + * + * Defaults to false; existing schedules keep their as-built order. + */ +export class AddReverseWagonOrder2340000000000 implements MigrationInterface { + name = "AddReverseWagonOrder2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS reverse_wagon_order; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..95e007c8a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the "pricing" article of each seeded contract template so it points + * at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon, + * USD 919/40ft, …). The original CreateContractTemplates migration seeded the + * old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB + * rows and would otherwise contradict the rate-config-driven schedule table now + * rendered under the pricing article. + * + * Only the article whose id = 'pricing' is touched, and only when its body + * still matches the originally-seeded prose — so any admin edit to the pricing + * article is left untouched. Idempotent: re-running is a no-op once refreshed. + */ +export class RefreshContractPricingArticles2350000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((article) => article.id === 'pricing'); + if (!pricing) continue; + + // jsonb_set the title + body of the element whose id = 'pricing', matched + // by array index. Guarded so admin-edited bodies are never overwritten. + await queryRunner.query( + ` + UPDATE freight.contract_templates ct + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text) + ELSE elem + END + ) + FROM jsonb_array_elements(ct.articles) elem + ) + WHERE ct.code = $1 + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(ct.articles) e + WHERE e->>'id' = 'pricing' + AND e->>'body' LIKE ANY (ARRAY[ + '%USD 59.4 per metric ton%', + '%USD 696 (six hundred ninety-six) per wagon%', + '%USD 400 (four hundred) per wagon%', + '%From SGTD to Dire Dawa dry port, the rate is USD 919%', + '%Railway transportation charges from GMP to SGTD: USD 819%', + '%prevailing EDR domestic container tariff, as set out in the commercial schedule%' + ]) + ); + `, + [seed.code, pricing.title, pricing.body], + ); + } + } + + public async down(): Promise { + // No-op: the refreshed pricing prose is the correct forward state; reverting + // to hardcoded figures would reintroduce the rate-schedule contradiction. + } +} diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container handling opt-in: each physical container can now be marked + * hazardous / reefer / with-return individually, next to its VGM. The hazardous + * and reefer flags already existed on the unit row; only the return leg was + * missing, so a booking of 20 containers with 10 returning empty can bill the + * WITH_RETURN surcharge on 10 instead of all 20. + * + * Backfill: existing rows keep false. The line-level counts + * (booking_container.return_quantity etc.) stay authoritative for bookings made + * before this change — the rule engine falls back to them when no unit is flagged. + */ +export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface { + name = 'AddContainerUnitReturnFlag2370000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts new file mode 100644 index 000000000..2c252f127 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * New built-train lifecycle status DEACTIVATED: staff park a train indefinitely + * (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like + * UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never + * overwrites it and refuses to schedule a deactivated train. + * + * Postgres cannot drop an enum value, so down() is a no-op. + */ +export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface { + name = 'AddTrainDeactivatedStatus2380000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`, + ); + } + + public async down(): Promise { + // Enum values cannot be removed in Postgres; leaving the label is harmless. + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts new file mode 100644 index 000000000..99c764806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia) + * selectable in the Train Builder. The paired EXPORT number is derived + * (import − 1), so only the import side is configured. Seeded with the runs + * historically hardcoded in the backoffice's trainRuns constants; admins add + * new runs from the Dropdown Settings editor. + */ +export class SeedImportTrainNumbers2390000000000 implements MigrationInterface { + name = 'SeedImportTrainNumbers2390000000000'; + private readonly code = 'import_train_numbers'; + private readonly options: string[] = [ + '8002', + '8102', + '8202', + '8302', + '8402', + '8502', + '8602', + '8702', + '8802', + '8902', + '9002', + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta) + VALUES ($1, $2, $3, false, $4::jsonb) + RETURNING id;`, + [ + this.code, + 'Import train numbers', + 'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).', + JSON.stringify({ searchable: true, clearable: true }), + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const value = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, value, value, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts new file mode 100644 index 000000000..31263f4b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard soft-delete now appends `@` to the unique code (SEBETA → + * SEBETA@1755612345678) so the name can be reused by a new yard while + * UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long + * codes plus the 14-char suffix, so widen to 40. + */ +export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface { + name = 'WidenYardCodeForSoftDeleteSuffix2390000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`, + ); + } + + public async down(): Promise { + // Narrowing would fail on suffixed codes; keep 40. + } +} diff --git a/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts new file mode 100644 index 000000000..6cddae23d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck exit weights for customer self-haul, mirroring what + * `last_mile_vehicle_assignments` already carries for EDR trucks. + * + * A bulk booking is hauled away truck by truck until no tonnage is left, and the + * EDR side enforces that by summing `net_weight_tons` of departed trucks. The + * customer side had no net and no tare — only `gross_weight_kg`, which nothing + * in the live flow ever wrote (the release flow updated the EDR table only). So + * a self-haul bulk booking could take unlimited trucks: hauled tonnage always + * summed to zero. + * + * `gross_weight_kg` is left alone but note it holds TONNES despite its name — + * the weighing UI is in tonnes throughout. The new columns are named for the + * unit they actually hold. + */ +export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface { + name = 'AddCustomerTruckExitWeights2400000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL + `); + + // Departed trucks are what the drawdown sums, so it reads this index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed" + ON freight.customer_truck_assignments (booking_id, departed_at) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`, + ); + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts new file mode 100644 index 000000000..b3369b82d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-NormalizeCompanyRegions.ts @@ -0,0 +1,77 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `freight.companies.region` was free text until region became a closed set + * (see ETHIOPIAN_REGIONS in @edr/types). This normalizes the rows written under + * the old rules so they satisfy the new dropdown. + * + * Two classes of bad data exist, handled differently: + * + * - Unambiguous spelling/case drift ("Addis ababa", "oromoia") — rewritten to + * the canonical spelling. + * - Values that are not regions at all ("Arba Minch", a city), and rows whose + * region contradicts their own zone/woreda — set to NULL. These are NOT + * guessed at: inferring "Gurage/Meskan" means Central Ethiopia would silently + * overwrite what the customer actually submitted. NULL surfaces the gap and + * the required dropdown forces a deliberate pick on next edit. + */ +export class NormalizeCompanyRegions2400000000000 implements MigrationInterface { + name = 'NormalizeCompanyRegions2400000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Canonical spellings — case/whitespace insensitive, safe to re-run. + await queryRunner.query(` + UPDATE freight.companies + SET region = v.canonical + FROM (VALUES + ('addis ababa', 'Addis Ababa'), + ('addis abeba', 'Addis Ababa'), + ('addisababa', 'Addis Ababa'), + ('oromia', 'Oromia'), + ('oromoia', 'Oromia'), + ('oromiya', 'Oromia'), + ('amhara', 'Amhara'), + ('somali', 'Somali'), + ('afar', 'Afar'), + ('tigray', 'Tigray'), + ('tigrai', 'Tigray'), + ('sidama', 'Sidama'), + ('harari', 'Harari'), + ('gambela', 'Gambela'), + ('gambella', 'Gambela'), + ('dire dawa', 'Dire Dawa'), + ('benishangul-gumuz', 'Benishangul-Gumuz'), + ('benishangul gumuz', 'Benishangul-Gumuz'), + ('central ethiopia', 'Central Ethiopia'), + ('south ethiopia', 'South Ethiopia') + ) AS v(variant, canonical) + WHERE freight.companies.region IS NOT NULL + AND lower(regexp_replace(btrim(freight.companies.region), '\\s+', ' ', 'g')) = v.variant + AND freight.companies.region <> v.canonical + `); + + // Anything still outside the canonical set is unresolvable — null it. + await queryRunner.query(` + UPDATE freight.companies + SET region = NULL + WHERE region IS NOT NULL + AND region <> '' + AND region NOT IN ( + 'Addis Ababa','Afar','Amhara','Benishangul-Gumuz','Central Ethiopia', + 'Dire Dawa','Gambela','Harari','Oromia','Sidama','Somali', + 'South Ethiopia','South West Ethiopia Peoples''','Tigray' + ) + `); + + // Normalize empty string to NULL so "unset" has one representation. + await queryRunner.query(` + UPDATE freight.companies SET region = NULL WHERE region = '' + `); + } + + public async down(): Promise { + // Irreversible by design: the original free-text values are not retained + // anywhere, so there is nothing to restore. Rolling back the code is safe — + // the column is still a nullable varchar(100) and accepts free text again. + } +} diff --git a/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts new file mode 100644 index 000000000..1b4674b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2410000000000-DropBookingApprovalWidenRoles.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bookings no longer run an approval chain — accepting an intake approves the + * booking outright and generates its contract. The approval chain is now a + * contract-only concern, so `freight.approval_rules` is read by contracts alone. + * + * Also widens the role columns: chain steps now reference IAM position-type + * keys (`iam.position_types.key`), and real keys run past the old varchar(30) + * (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail + * on insert. + */ +export class DropBookingApprovalWidenRoles2410000000000 + implements MigrationInterface +{ + name = 'DropBookingApprovalWidenRoles2410000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.booking_approval_step;`, + ); + + for (const [table, column] of [ + ['approval_rules', 'required_role'], + ['approval_rules', 'blocks_role'], + ['contract_approval_steps', 'required_role'], + ['contract_approval_steps', 'blocks_role'], + ] as const) { + await queryRunner.query( + `ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`, + ); + } + } + + /** + * No-op: the booking approval chain is retired, so re-creating the table + * would leave dead schema behind. Narrowing the role columns again would + * truncate any position-type key already stored. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts new file mode 100644 index 000000000..db06b2ba2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2420000000000-CreateContractDocumentRevisions.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Audit trail for contract document edits. The document stays editable through + * the whole approval chain (each approver may edit on their turn), so the + * contract itself only ever holds the current snapshot — this table records who + * changed which article, and when. + */ +export class CreateContractDocumentRevisions2420000000000 + implements MigrationInterface +{ + name = 'CreateContractDocumentRevisions2420000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_document_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + actor_id uuid, + actor_role varchar(64), + step_id uuid, + summary varchar(255), + changes jsonb NOT NULL DEFAULT '[]'::jsonb + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract + ON freight.contract_document_revisions (contract_id, created_at DESC); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.contract_document_revisions;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts new file mode 100644 index 000000000..833d49fea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-document review state, so a backoffice reviewer can request a correction + * on one specific onboarding document instead of rejecting the whole role. + * + * Until now `freight.files` carried no status at all: the `pending_add` / + * `pending_remove` badges the portal shows are derived by diffing live rows + * against an open company change request, which says nothing about whether a + * reviewer is happy with a given document. `review_status` is that missing + * verdict — NULL means never reviewed, which is the state every existing row + * correctly starts in, so no backfill is needed. + * + * The partial index serves the approval gate, which asks "does this company (or + * profile) still have any document with an open change request?" on every + * role-status write. + */ +export class AddFileReviewStatus2430000000000 implements MigrationInterface { + name = 'AddFileReviewStatus2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL, + ADD COLUMN IF NOT EXISTS review_note text NULL, + ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL, + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request" + ON freight.files (resource, resource_id) + WHERE review_status = 'change_requested' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`, + ); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS review_status, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS reviewed_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts new file mode 100644 index 000000000..5468e2207 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Locomotive names must be unique so staff can identify a unit by name alone + * (the card view leads with `name`, falling back to `code`). Uniqueness is: + * + * - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name; + * - scoped to live rows — a decommissioned (soft-deleted) locomotive must not + * hold its name hostage, matching how the fleet reuses yard codes; + * - skipped for blank names — `name` stays optional, and NULL/'' rows are + * excluded rather than colliding with each other. + * + * A partial expression index gives all three; a plain UNIQUE column cannot. + */ +export class UniqueLocomotiveName2430000000000 implements MigrationInterface { + name = 'UniqueLocomotiveName2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every + // copy after the oldest (…-2, …-3) so the index can build; the oldest row + // keeps the original name. Deterministic on created_at, then id. + await queryRunner.query(` + WITH ranked AS ( + SELECT + id, + name, + row_number() OVER ( + PARTITION BY lower(btrim(name)) + ORDER BY created_at, id + ) AS rn + FROM "freight"."locomotives" + WHERE deleted_at IS NULL + AND name IS NOT NULL + AND btrim(name) <> '' + ) + UPDATE "freight"."locomotives" AS l + SET name = btrim(ranked.name) || '-' || ranked.rn + FROM ranked + WHERE l.id = ranked.id + AND ranked.rn > 1 + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active" + ON "freight"."locomotives" (lower(btrim("name"))) + WHERE "deleted_at" IS NULL + AND "name" IS NOT NULL + AND btrim("name") <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`, + ); + // The de-duplicating renames are not reversed: the original names are no + // longer recoverable, and restoring them would re-introduce the conflict. + } +} diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop the `active_profile_type` "active mode" column. A booking/contract now + * resolves its company_profile from the trade direction at creation time (with + * a forwarder passing an explicit companyProfileId), so no per-user active mode + * is stored. `onboarding_step` / `onboarding_completed` are unaffected. + */ +export class DropActiveProfileTypeFromExternalProfiles2450000000000 + implements MigrationInterface +{ + name = 'DropActiveProfileTypeFromExternalProfiles2450000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + // Rebuild the mode the same way the original column was backfilled: + // importer first, then exporter, then whichever profile the company has. + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The entity + frontend already list 'cac-bank' as a valid method, but the + // DB enum was never extended. Filtering payments by 'cac-bank' cast the + // literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301. + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts new file mode 100644 index 000000000..61431c5bc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Dedup stamp for the km/date-due maintenance alert — without it the daily + * cron would re-notify every day a schedule stays due. + */ +export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface { + name = 'AddMaintenanceDueNotifiedAt2480000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules + ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..f4f125eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * KM-based maintenance scheduling: per-vehicle service intervals (by km + * and/or days) driving the maintenance due engine. Raw schema-qualified SQL — + * the builder API resolved bare table names against the default schema and + * failed on boot ("Table maintenance_intervals does not exist"). + */ +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + name = 'AddMaintenanceIntervals2800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.maintenance_intervals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE, + maintenance_type varchar NOT NULL, + interval_km numeric(14,2), + interval_days integer, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts new file mode 100644 index 000000000..679846484 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist the signer's saved-signature image on the handover record, so the + * signed handover document can render the actual signature (not just the + * typed name) — parity with the booking-contract signing flow. + */ +export class AddSignatureToHandover2800000000001 implements MigrationInterface { + name = 'AddSignatureToHandover2800000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts new file mode 100644 index 000000000..17902ff0d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the customs clearance service fee to a direction + route. + * + * The fee was a single global flat rate; the business sells it per lane — + * "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now + * carry trade_direction + the yard pair, and contract pricing matches on them + * strictly (no route-less fallback). + * + * Existing route-less clearance rates cannot be backfilled (no way to know + * which lane each was meant for) — retired exactly like the base-freight + * retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for + * snapshot history. + */ +export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface { + name = 'CustomsClearanceRouteScope2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired (their lanes were never recorded); down only + // restores the pre-customs constraint shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts new file mode 100644 index 000000000..26c512cfc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the empty-container return surcharge to a direction + route + + * container type, like base freight (import-only for now — the box only goes + * back to the port on imports). + * + * Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired + * (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance + * were, kept readable for snapshot history. Route-scoped replacements must be + * re-entered; a booking that asks for return with no matching rate hard-blocks. + */ +export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface { + name = 'ReturnSurchargeRouteScope2830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'RETURN_SURCHARGE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired; down only restores the customs-era shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts new file mode 100644 index 000000000..9fd854b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customs clearance service fee is no longer prepaid via its own + * `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the + * booking invoice, together with the freight (see BookingPricingService). + * + * - Contracts/bookings parked at the payment gate move straight to the + * document step (the gate no longer exists — nothing could ever pay them). + * - Open (unpaid) clearance invoices are expired; PAID ones stay as history. + * NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but + * has not booked yet will be billed the fee again on its booking invoice — + * accepted for dev data; reverses the old AddClearanceFeePayment migration. + * - clearance_fee_paid_at columns are dropped from contracts and bookings. + */ +export class DropClearanceFeePrepay2860000000000 implements MigrationInterface { + name = 'DropClearanceFeePrepay2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.contracts + SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.contracts + SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE clearance_status = 'AWAITING_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.bookings + SET status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.invoices + SET status = 'EXPIRED', updated_at = now() + WHERE source = 'clearance' + AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'); + `); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Moved rows and expired invoices stay — only the columns come back. + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts new file mode 100644 index 000000000..be01e7825 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customs clearance fees are now sold per cargo kind: container fees name a + * container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type + * (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be + * mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the + * base-freight and return-surcharge reshapes, kept readable for snapshot + * history. Per-kind replacements must be re-entered; a customs contract or + * booking without a matching fee hard-blocks. Contracts that already froze a + * FLAT snapshot keep billing it (legacy honoured at booking pricing). + */ +export class CustomsClearancePerKind2870000000000 implements MigrationInterface { + name = 'CustomsClearancePerKind2870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts new file mode 100644 index 000000000..9d311c60f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now sold per cargo kind, like the customs clearance fee: + * container rates name a container type (PER_CONTAINER / PER_WAGON), bulk + * rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape + * cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept + * readable for snapshot history. Per-kind replacements must be re-entered; + * an unconfigured lashing rate simply bills nothing (lenient, like + * hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates + * share the LASHING rate_type and must survive. + */ +export class LashingPerKind2880000000000 implements MigrationInterface { + name = 'LashingPerKind2880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'LASHING' + AND rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts new file mode 100644 index 000000000..8904c6194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT), + * optionally narrowed to one leaf commodity. Rates that no longer fit — + * container-scoped, or carrying no direction — cannot be mapped and are + * retired (SUPERSEDED + soft-deleted), kept readable for snapshot history. + * Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING). + */ +export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface { + name = 'LashingBulkOnlyPerDirection2890000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'LASHING' + AND (container_type_id IS NOT NULL + OR trade_direction IS NULL + OR trade_direction NOT IN ('IMPORT', 'EXPORT')); + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-direction bulk rates instead. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } 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 { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} 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 index 2bf9f82fd..52a900fe8 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, NotFoundException, Param, ParseUUIDPipe, @@ -11,11 +12,14 @@ 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"; +import { + CustomerResetService, + CustomerResetTarget, +} 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. + * Staff-triggered password reset. The customer receives a single-use link and + * sets their own password — staff never see or handle a credential. */ @ApiTags("backoffice") @Controller("backoffice/customers") @@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service"; export class CustomerResetController { constructor(private readonly customerResetService: CustomerResetService) {} + @Get(":companyId/reset-target") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "The primary contact's IAM account a reset link would be sent to", + }) + async resetTarget( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const target = await this.customerResetService.getResetTarget(companyId); + + if (!target) { + throw new NotFoundException( + "This customer has no active primary-contact account to reset", + ); + } + + return target; + } + @Post(":companyId/reset-password") @BookingStaff(FREIGHT_PERMS.customers.resetPassword) @ApiOperation({ - summary: "Send a password-reset code to a customer's primary contact", + summary: "Send a password-reset link to a customer's primary contact", }) async resetPassword( @Param("companyId", ParseUUIDPipe) companyId: string, @Body() dto: BackofficeResetPasswordDto, ) { - const maskedTarget = await this.customerResetService.sendResetToCustomer( + const sent = await this.customerResetService.sendResetLinkToCustomer( companyId, dto.channel, ); - if (!maskedTarget) { + if (!sent) { 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 }; + return sent; } } 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 index 4c8f67599..4eb6ecc31 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,10 +1,38 @@ import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { EmailClientService } from "../notifications/email-client.service"; +import { SmsClientService } from "../notifications/sms-client.service"; import { ResetChannel } from "./dto/forgot-password.dto"; -import { ForgotPasswordService } from "./forgot-password.service"; +import { + ForgotPasswordService, + RESET_LINK_TTL_MS, +} from "./forgot-password.service"; +import { maskOtpTarget } from "./mask-target.util"; +import { isDomesticPhone } from "../otp/otp.service"; + +/** The account a staff-triggered reset would land on. */ +export interface CustomerResetTarget { + userId: string; + name: string; + email: string | null; + phone: string | null; + /** + * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when + * there is no phone. The backoffice uses this to disable the SMS channel for + * foreign numbers instead of sending a link that will never arrive. + */ + phoneIsDomestic: boolean | null; +} + +export interface SentResetLink { + channel: ResetChannel; + maskedTarget: string; + expiresAt: string; +} @Injectable() export class CustomerResetService { @@ -14,19 +42,124 @@ export class CustomerResetService { @InjectRepository(ExternalProfile) private readonly externalProfileRepository: Repository, private readonly forgotPasswordService: ForgotPasswordService, + private readonly emailClient: EmailClientService, + private readonly smsClient: SmsClientService, + private readonly config: ConfigService, ) {} /** - * 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. + * The IAM account a reset would actually reach. The backoffice shows these + * values rather than `company.email` / `company.phone`: the company row holds + * business contact detail, while the link is delivered to the primary + * contact's own login credentials — the two drift apart routinely, and showing + * the wrong one has staff telling customers to check an inbox nothing was sent + * to. + */ + async getResetTarget(companyId: string): Promise { + const resolved = await this.resolvePrimaryContactUser(companyId); + if (!resolved) return null; + + const { profile, user, userId } = resolved; + return { + userId, + name: `${profile.firstName} ${profile.lastName}`.trim(), + email: user.email ?? null, + phone: user.phoneNumber ?? null, + phoneIsDomestic: user.phoneNumber + ? isDomesticPhone(user.phoneNumber) + : null, + }; + } + + /** + * Mint a password-reset link and send it 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( + async sendResetLinkToCustomer( companyId: string, channel: ResetChannel, - ): Promise { + ): Promise { + const resolved = await this.resolvePrimaryContactUser(companyId); + if (!resolved) return null; + + const { user, userId } = resolved; + const target = this.forgotPasswordService.targetFor(user, channel); + if (!target) return null; + + // A foreign number is unreachable by the domestic-only SMS gateway — treat + // it like a missing phone rather than reporting "link sent" for a message + // that will never arrive. The backoffice disables the channel up front via + // `phoneIsDomestic`; this guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + return null; + } + + // Mint first, send second: a failed send leaves an unused ticket that simply + // expires, whereas sending a link before the ticket exists would hand the + // customer a URL that is dead on arrival. + const ticket = await this.forgotPasswordService.mintResetTicket( + userId, + RESET_LINK_TTL_MS, + ); + const link = this.buildResetLink(ticket.userId, ticket.verificationCode); + const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); + + const { queued } = target.email + ? await this.emailClient.sendEmail({ + to: target.email, + subject: "Reset your EDR Freight password", + text: + "A password reset was started for your EDR Freight account.\n\n" + + `Open this link to choose a new password:\n${link}\n\n` + + "The link expires in 24 hours and can only be used once. If you did " + + "not expect this, ignore this message — your password stays unchanged.", + }) + : await this.smsClient.sendSms({ + to: target.phone as string, + message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`, + }); + + this.logger.log( + `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, + ); + + if (!queued) { + // The ticket is committed and the backoffice is about to say "link sent", + // but nothing left this process — with RABBITMQ_ENABLED=false both clients + // are no-ops. Without this line the only symptom is a customer who never + // receives anything, indistinguishable from carrier loss. + this.logger.error( + `reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${ + process.env.RABBITMQ_ENABLED ?? "unset" + } — transport reported no hand-off; no link will arrive`, + ); + // SECURITY: logs a live password-reset credential in cleartext. Same + // deliberate tradeoff the OTP service makes — this is the only way to + // complete a reset on an environment with no broker. Only reached when + // delivery already failed. + this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`); + } + + return { + channel, + maskedTarget: maskOtpTarget(target), + expiresAt: expiresAt.toISOString(), + }; + } + + /** + * The company's primary contact, gated on the same active-account rule the + * public flow uses — so a suspended customer cannot be reactivated by a + * staff-triggered reset (IAM's `set-password` flips `isActive` back on). + */ + private async resolvePrimaryContactUser(companyId: string) { const profile = await this.externalProfileRepository.findOne({ where: { companyId, isPrimaryContact: true }, }); @@ -36,24 +169,28 @@ export class CustomerResetService { 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) { + if (!user?.id) { 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; + return { profile, user, userId: user.id }; + } - this.logger.log( - `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, - ); - return this.forgotPasswordService.maskTarget(target); + /** + * The portal route that trades the token for a set-password form. Params are + * URL-encoded because the token is base64url — safe as-is, but the encoding + * keeps this correct if the token format ever changes. + */ + private buildResetLink(userId: string, token: string): string { + const base = this.config.get("app.portalBaseUrl"); + return `${base}/reset-password?uid=${encodeURIComponent( + userId, + )}&token=${encodeURIComponent(token)}`; } } diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} 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 index be2f9bdac..34e16f628 100644 --- 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 @@ -1,7 +1,11 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum, IsNotEmpty, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; -/** The channel the reset code is delivered over. */ +/** + * The channel a reset LINK is delivered over. The OTP flow no longer picks one — + * it sends to every contact on the account — but the staff-triggered link flow + * still delivers over exactly one transport. + */ export enum ResetChannel { Email = "email", Phone = "phone", @@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto { @IsNotEmpty() identifier!: string; - @ApiProperty({ enum: ResetChannel }) + /** + * Accepted and ignored. The code now goes to the account's email AND phone, + * so there is nothing to choose — kept optional so clients still sending it + * (older portal/backoffice builds) are not rejected outright. + * @deprecated + */ + @ApiPropertyOptional({ + enum: ResetChannel, + deprecated: true, + description: "Ignored — the code is sent to every contact on the account.", + }) + @IsOptional() @IsEnum(ResetChannel) - channel!: ResetChannel; + channel?: ResetChannel; } export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { - @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @ApiProperty({ + description: + "The 6-digit code sent to the account's email and phone. Either delivery carries the same code.", + }) @IsString() @IsNotEmpty() otp!: string; @@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto { @IsEnum(ResetChannel) channel!: ResetChannel; } + +/** + * The two halves of a reset link's query string. Together they stand in for the + * identifier + OTP pair of the typed flow: the token proves possession of the + * inbox/handset the link was delivered to. + */ +export class ResolveResetLinkDto { + @ApiProperty({ description: "IAM user id from the reset link's `uid` param" }) + @IsUUID() + userId!: string; + + @ApiProperty({ description: "Opaque token from the reset link's `token` param" }) + @IsString() + @IsNotEmpty() + token!: string; +} 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 index da49982d2..448a380c9 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -5,8 +5,13 @@ import { Public } from "@edr/api-common"; import { ForgotPasswordRequestDto, ForgotPasswordVerifyDto, + ResolveResetLinkDto, } from "./dto/forgot-password.dto"; -import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; +import { + ForgotPasswordService, + ResetLinkAccount, + ResetTicket, +} from "./forgot-password.service"; /** * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only @@ -24,17 +29,19 @@ export class ForgotPasswordController { @Post("forgot-password/request") @ApiOperation({ - summary: "Send a password-reset code over email or SMS", + summary: "Send a password-reset code to the account's email AND phone", 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.", + "One code, delivered over every contact the account has; either delivery " + + "verifies it. Always reports success — an unknown, inactive, or contactless " + + "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); + await this.forgotPasswordService.requestReset(user); } catch (error) { // A delivery failure must not change the response shape either — log it // and let the caller sit on the OTP screen. @@ -60,10 +67,18 @@ export class ForgotPasswordController { "alongside the same identifier and the new password.", }) verify(@Body() dto: ForgotPasswordVerifyDto): Promise { - return this.forgotPasswordService.verifyAndMintTicket( - dto.identifier, - dto.channel, - dto.otp, - ); + return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp); + } + + @Post("forgot-password/resolve-link") + @ApiOperation({ + summary: "Validate a staff-issued reset link and return its set-password ticket", + description: + "Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " + + "is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " + + "A bad or expired link is rejected here rather than after the password is typed.", + }) + resolveLink(@Body() dto: ResolveResetLinkDto): Promise { + return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token); } } 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 index 42dc723d5..a3dbf1061 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -4,13 +4,14 @@ 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 { hashPassword, verifyPassword } 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"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -21,11 +22,32 @@ 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; +/** + * A staff-triggered reset link lives longer than a typed OTP: the customer may + * only see the SMS/email hours after the call that prompted it. + */ +export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000; + +/** IAM refuses a ticket once its row hits this many failed attempts. */ +const MAX_TICKET_ATTEMPTS = 5; + export interface ResetTicket { userId: string; verificationCode: string; } +/** + * What a valid reset link resolves to. `identifier` is the value IAM's + * `set-password` matches the user on (it accepts email / username / phone), so + * the portal can spend the ticket without the customer typing anything. + */ +export interface ResetLinkAccount { + userId: string; + identifier: string; + maskedIdentifier: string; + verificationCode: string; +} + @Injectable() export class ForgotPasswordService { private readonly logger = new Logger(ForgotPasswordService.name); @@ -80,8 +102,12 @@ export class ForgotPasswordService { .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 { + /** + * A single channel of the account, for flows that genuinely deliver over one + * transport (the staff-triggered reset LINK picks email or SMS). Taken from + * the account — never from input. + */ + targetFor(user: User, channel: ResetChannel): OtpTarget | null { if (channel === ResetChannel.Email) { return user.email ? { email: user.email } : null; } @@ -89,20 +115,40 @@ export class ForgotPasswordService { } /** - * 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. + * Every contact the account has. The reset OTP goes to all of them and any one + * verifies it — a customer whose SMS never lands can finish from their inbox + * without restarting the flow on a different channel. An account holding only + * one of the two degrades to that channel; only a contactless account is null. + */ + targetsFor(user: User): OtpTarget | null { + const target: OtpTarget = {}; + if (user.email) target.email = user.email; + if (user.phoneNumber) target.phone = user.phoneNumber; + return target.email || target.phone ? target : null; + } + + /** + * The value IAM's `set-password` will match this account on. It looks the user + * up by email OR username OR phoneNumber (and lowercases whatever it is + * given), so prefer email, then phone, and fall back to username last — + * a mixed-case username would not survive that lowercasing. + */ + private identifierFor(user: User): string | null { + return user.email ?? user.phoneNumber ?? user.username ?? null; + } + + /** + * Send one reset code to every contact on the account — email AND phone — + * returning 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. + * replaces every row the target overlaps. A reset request therefore overwrites + * any pending signup code for the same addresses — 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); + async requestReset(user: User): Promise { + const target = this.targetsFor(user); if (!target) return null; await this.otpService.sendOtp(target); @@ -119,11 +165,12 @@ export class ForgotPasswordService { */ async verifyAndMintTicket( identifier: string, - channel: ResetChannel, otp: string, ): Promise { const user = await this.resolveActiveUser(identifier); - const target = user && this.targetFor(user, channel); + // Same set of contacts `requestReset` sent to, so the code resolves whichever + // of the two the customer actually received it on. + const target = user && this.targetsFor(user); if (!user?.id || !target) { // Same shape as a wrong code: a caller probing for accounts learns nothing @@ -133,9 +180,18 @@ export class ForgotPasswordService { await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS); + } + + /** + * Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code + * is the proof of possession) and the staff-triggered link flow (where the + * ticket travels in the link and delivery to the account's own inbox/handset + * is the proof). + */ + async mintResetTicket(userId: string, ttlMs: number): Promise { 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); @@ -146,7 +202,7 @@ export class ForgotPasswordService { userId, otpType: EOtpType.RESET_PASSWORD, verificationCode, - expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + expiresAt: new Date(Date.now() + ttlMs), isUsed: false, attemptCount: 0, }); @@ -156,14 +212,65 @@ export class ForgotPasswordService { return { userId, verificationCode: code }; } + /** + * Validate a reset link and hand back everything the portal needs to spend it + * on IAM's `PATCH /api/auth/set-password`. + * + * The checks mirror IAM's own — newest row, unused, unexpired, attempts left, + * argon match — so a link that resolves here is one IAM will honour. Doing + * them up front is what lets the page say "this link has expired" before the + * customer types a password rather than after. + * + * Every rejection is the same message: a link is a bearer credential, and the + * holder of a bad one learns nothing about why it failed or whether the user + * id exists. + */ + async resolveResetLink( + userId: string, + token: string, + ): Promise { + const invalid = new BadRequestException( + "This password-reset link is invalid or has expired. Request a new one.", + ); + + const user = await this.resolveActiveUserById(userId); + const identifier = user && this.identifierFor(user); + if (!user || !identifier) throw invalid; + + const verification = await this.dataSource + .getRepository(UserVerification) + .findOne({ + where: { userId, otpType: EOtpType.RESET_PASSWORD }, + order: { createdAt: "DESC" }, + }); + + // `expiresAt` / `attemptCount` are optional on IAM's entity but always + // written by `mintResetTicket`. A row missing either is malformed, so treat + // it as expired rather than letting it through unchecked. + if ( + !verification || + verification.isUsed || + !verification.expiresAt || + verification.expiresAt < new Date() || + (verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS || + !(await verifyPassword(token, verification.verificationCode)) + ) { + this.logger.warn(`Reset link rejected for user ${userId}`); + throw invalid; + } + + return { + userId, + identifier, + maskedIdentifier: maskOtpTarget( + identifier.includes("@") ? { email: identifier } : { phone: identifier }, + ), + verificationCode: token, + }; + } + /** `+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)}`; + return maskOtpTarget(target); } } 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 6415cf4c1..10dbd0b37 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 @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; 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 { NotificationsModule } from '../notifications/notifications.module'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +22,27 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, + // Reset links go out over email/SMS directly, not through the OTP service. + NotificationsModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 50c90213b..006b482f4 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { DataSource } from 'typeorm'; import { collectPermissionKeys, @@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; @Injectable() export class FreightMeService { - getEnrichedProfile(user: TCurrentUser) { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * The JWT session snapshot has no position TYPE, but the backoffice needs it + * (GL sub-positions are identified by type key). Resolved live from IAM. + */ + private async lookupPositionType( + positionId: string | undefined, + ): Promise<{ key: string; name: unknown } | null> { + if (!positionId) return null; + try { + const rows: { key: string; name: unknown }[] = await this.dataSource.query( + `SELECT pt.key, pt.name + FROM iam.positions p + JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE p.id = $1`, + [positionId], + ); + return rows[0] ?? null; + } catch { + return null; // iam schema unreachable — degrade to the old payload shape + } + } + + async getEnrichedProfile(user: TCurrentUser) { + const positionType = await this.lookupPositionType( + user.employee?.position?.id, + ); + const employee = user.employee ? [ { @@ -27,6 +57,7 @@ export class FreightMeService { isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, permissions: user.employee.position.permissions ?? [], + positionType, }, ] : [], diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..81d49a522 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,27 @@ +import { OtpTarget } from "../otp/otp.service"; + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; +} + +function maskPhone(phone: string): string { + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + * + * A dual-channel target masks both and joins them, so the UI can say exactly + * where the code went ("a•@x.com and +251•••••4567") — a user who only checks + * one of the two otherwise assumes the other never received anything. + */ +export function maskOtpTarget(target: OtpTarget): string { + const parts: string[] = []; + if (target.email) parts.push(maskEmail(target.email)); + if (target.phone) parts.push(maskPhone(target.phone)); + return parts.join(" and "); +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 037957367..e7682879b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction", expect(result).toBeNull(); expect(transaction).not.toHaveBeenCalled(); }); + + it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => { + const { service, defaultManager } = build({ + ...openInvoice, + status: Freight.InvoiceStatus.Draft, + }); + + await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + const { where } = defaultManager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); +}); + +describe("BillingService.issuePayable", () => { + const dueAt = new Date("2026-01-02T00:00:00.000Z"); + + const build = (found: Record | null) => { + const manager = { + findOne: jest.fn().mockResolvedValue(found), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { manager, transaction: jest.fn() } as never, + {} as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + ); + return { service, manager }; + }; + + const issue = (service: BillingService) => + service.issuePayable( + Freight.InvoiceSource.Booking, + "booking-1", + dueAt, + "PREPAID", + ); + + it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => { + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Draft, + issuedAt: null, + }); + + const result = await issue(service); + + const patch = manager.update.mock.calls[0][2]; + expect(patch.status).toBe(Freight.InvoiceStatus.Pending); + expect(patch.dueAt).toBe(dueAt); + expect(patch.issuedAt).toBeInstanceOf(Date); + expect(result?.status).toBe(Freight.InvoiceStatus.Pending); + }); + + it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => { + const { service, manager } = build(null); + + await issue(service); + + const { where } = manager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); + + it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => { + const issuedAt = new Date("2026-01-01T00:00:00.000Z"); + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Pending, + issuedAt, + }); + + const result = await issue(service); + + expect(manager.update.mock.calls[0][2]).toEqual({ dueAt }); + expect(result?.issuedAt).toBe(issuedAt); + }); + + it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => { + const { service, manager } = build(null); + + await expect(issue(service)).resolves.toBeNull(); + expect(manager.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 3e104c7ed..2bdc6ee16 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) {} + ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -432,7 +432,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); @@ -602,6 +602,19 @@ export class BillingService { if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException("Invoice is already fully paid."); } + // M27: a Draft invoice is not yet issued and an Expired invoice's pay + // window has closed — neither is payable. Without these guards a payment + // could settle an unissued draft or a lapsed invoice. + if (invoice.status === Freight.InvoiceStatus.Draft) { + throw new BadRequestException( + "Cannot pay a draft invoice — it must be issued first.", + ); + } + if (invoice.status === Freight.InvoiceStatus.Expired) { + throw new BadRequestException( + "Cannot pay an expired invoice — its payment window has closed.", + ); + } if (round2(input.amount) > Number(invoice.balanceAmount)) { throw new BadRequestException( `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, @@ -813,8 +826,15 @@ export class BillingService { * Expire a source's currently-open invoice (its pay window closed before * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice * and transitions it to EXPIRED — a terminal, non-payable status (kept out of - * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice - * (already paid/cancelled/expired). + * `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to + * retire (already paid/cancelled/expired). + * + * DRAFT invoices are matched too, even though they were never issued: this is + * also the "retire the invoice this source no longer needs" path (a cancelled + * booking, or a full-amount invoice superseded by a partial-offer one). Skipping + * drafts would leave the stale one behind for `findPayable` to hand back — the + * superseding invoice would then never be minted, and a cancelled booking would + * keep a draft that a later `issuePayable` could still make payable. * * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in * the batch engine) to enlist in its DB transaction. @@ -837,7 +857,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -854,30 +874,58 @@ export class BillingService { } /** - * Sync a source's open invoice `dueAt` to its real pay-window deadline. The - * booking invoice is generated before the pay window opens (at booking - * creation/approval), so its printed due date is refreshed when the batch engine - * sets `paymentDeadline`. No-op when the source has no open invoice. + * Issue a source's invoice and stamp its real pay-window deadline — the single + * transition that makes a source payable. + * + * A source's invoice is minted DRAFT, before any pay window exists (e.g. a + * booking invoice is generated at creation / operation-accept, long before the + * batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`, + * so such an invoice is not settleable and the portal renders no pay button. + * The domain calls this at the moment the pay window actually opens (booking → + * `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues + * the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`. + * + * Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so + * a re-reserve never re-issues. No-op (returns null) when the source has no + * draft-or-open invoice (already paid/cancelled/expired). */ - async syncPayableDueDate( + async issuePayable( source: Freight.InvoiceSource, sourceId: string, dueAt: Date, type?: string, manager?: EntityManager, - ): Promise { + ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); - if (!invoice) return; - await mg.update(Invoice, { id: invoice.id }, { dueAt }); + if (!invoice) return null; + + const issuing = invoice.status === Freight.InvoiceStatus.Draft; + const patch = { + dueAt, + ...(issuing + ? { + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } + : {}), + }; + await mg.update(Invoice, { id: invoice.id }, patch); + + if (issuing) { + this.logger.log( + `Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`, + ); + } + return { ...invoice, ...patch } as Invoice; } /** @@ -891,6 +939,20 @@ export class BillingService { status: Freight.InvoiceStatus, manager?: EntityManager, ): Promise { + // M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but + // does NOT touch paidAmount/balanceAmount. Its only legitimate use is the + // Draft → Pending/Issued issue transition. It must NEVER mark an invoice + // Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry + // balance implications and must go through the dedicated settlement methods + // (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable). + if ( + status !== Freight.InvoiceStatus.Pending && + status !== Freight.InvoiceStatus.Issued + ) { + throw new BadRequestException( + `updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`, + ); + } const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { @@ -954,7 +1016,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber.replace("-", "_"), + orderRef: invoice.invoiceNumber.replace(/-/g, "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, @@ -964,16 +1026,14 @@ export class BillingService { returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); -// + // // Link the intent to the invoice BEFORE any settlement can correlate against it. await this.dataSource .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // remove — real settlement flips this via the `${source}.invoice.paid` handler. + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); + // billing must not simulate it. Kept commented for local demos only. if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", diff --git a/apps/edr-freight-api/src/modules/billing/payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts index 543c84201..f72ad8922 100644 --- a/apps/edr-freight-api/src/modules/billing/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -103,8 +103,35 @@ export class PaymentController { } } + /** + * HTML-escape a value interpolated into the public checkout pages. These + * pages are served unauthenticated and the interpolated values (provider + * error messages, status strings, intent ids, redirect URLs) can carry + * attacker-influenced input — unescaped they are a reflected-XSS sink. + */ + private escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, """); + // Only http(s) URLs may be used as a redirect target — a javascript: + // URL would execute in the victim's browser from the /location.href. + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return this.buildErrorHtml("Invalid payment redirect URL"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return this.buildErrorHtml("Invalid payment redirect URL"); + } + const escaped = this.escapeHtml(url); + const jsEscaped = JSON.stringify(url); return ` @@ -126,12 +153,14 @@ export class PaymentController {

Redirecting to payment provider…

Click here if you are not redirected

- + `; } - private buildStatusHtml(status: string, intentId: string): string { + private buildStatusHtml(rawStatus: string, rawIntentId: string): string { + const status = this.escapeHtml(rawStatus); + const intentId = this.escapeHtml(rawIntentId); return ` @@ -153,7 +182,8 @@ export class PaymentController { `; } - private buildErrorHtml(message: string): string { + private buildErrorHtml(rawMessage: string): string { + const message = this.escapeHtml(rawMessage); return ` diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index ddf09dea6..82bb1144e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -119,6 +119,26 @@ export class BookingInvoiceService { return this.billing.updateStatus(invoiceId, status, manager); } + /** + * Expire the booking's currently-open freight (PREPAID) invoice when the booking is + * cancelled or rejected — the counterpart to the pay-window-expiry path + * (which also calls {@link BillingService.expirePayable}). Stops a terminated + * booking from leaving a payable invoice open. No-op when the booking has no + * open invoice (never invoiced, already paid/cancelled/expired). Pass a + * caller `manager` to enlist in its transaction. + */ + async expireOpenInvoices( + bookingId: string, + manager?: EntityManager, + ): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Booking, + bookingId, + "PREPAID", + manager, + ); + } + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -138,7 +158,32 @@ export class BookingInvoiceService { ); return; } - // if (booking.paymentStatus === "PAID") return; + + // Idempotency + state-machine guard (restored). The prepaid-invoice paid + // event can be delivered more than once (retries / re-emit), and a booking + // may have moved on or been terminated between invoicing and settlement. + // Only advance one that is still awaiting payment: no-op when already PAID, + // and refuse to advance a booking in a terminal/advanced status + // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never + // rewrite its status or re-run allocation. + if (booking.paymentStatus === "PAID" || booking.status === "PAID") { + return; + } + const TERMINAL_OR_ADVANCED_STATUSES: string[] = [ + "CANCELLED", + "REJECTED", + "EXPIRED", + "IN_TRANSIT", + "ARRIVED", + "COMPLETED", + "CONTRACT_CLOSED", + ]; + if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) { + this.logger.warn( + `Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`, + ); + return; + } await this.dataSource.transaction(async (mg) => { await mg.update( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index a4546e301..caa41f5e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { @@ -150,13 +157,24 @@ export class BookingLifecycleNotifierService { }); } - /** Clearance finalized → customer can proceed to request operation. */ + /** Document approval finalized → customer can proceed to request operation. */ clearanceReady(b: Booking): void { const msg = - `Clearance for booking ${b.reference} is complete. ` + + `Document approval for booking ${b.reference} is finalized. ` + `You can now proceed to request operation from the portal.`; - void this.notifyContact(b, msg, 'CLEARANCE READY'); - this.inApp(b, 'Clearance complete', msg, { + void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED'); + this.inApp(b, 'Document approval finalized', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Intercity documents approved → booking waits in the ride-along pool. */ + intercityDocumentsApproved(b: Booking): void { + const msg = + `Documents for intercity booking ${b.reference} are approved. ` + + `Operations will assign your shipment to a passing train; payment opens once it is accepted.`; + void this.notifyContact(b, msg, 'DOCUMENTS APPROVED'); + this.inApp(b, 'Documents approved', msg, { type: NotificationType.CLEARANCE_DECISION, }); } @@ -246,6 +264,19 @@ export class BookingLifecycleNotifierService { // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + /** + * A booking was created under a contract. Contract drawdowns never pass + * through submit, so this is the only point at which staff learn the booking + * exists — {@link submittedToStaff} covers the direct-booking flow instead. + */ + createdToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking created', + `Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`, + ); + } + /** Customer submitted a booking for review. */ submittedToStaff(b: Booking): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index d93b5b7bd..ddb4ce1e5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -1,4 +1,3 @@ -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { Booking } from './entities/booking.entity'; export interface BookingNextStep { @@ -9,7 +8,11 @@ export interface BookingNextStep { export function computeNextStep( booking: Pick, - nextPendingStep?: Pick | null, + /** + * Retained for call-site compatibility — bookings no longer run an approval + * chain, so this is always null. Approvals are a contract-only concern. + */ + nextPendingStep?: { requiredRole: string; stepOrder: number } | null, ): BookingNextStep | null { const { status } = booking; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index db6b70eae..667abe842 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity'; const MOCK_CBE_RATE = 130; +// Base freight is configured per leg, so every rate and every booking names the +// route it runs. MOJO → DIRE is the corridor these rates are priced for. +const MOJO = 'yard-mojo'; +const DIRE = 'yard-dire-dawa'; +const LEBU = 'yard-lebu'; + describe('BookingPricingService — domestic corridor', () => { const intercityBulkUsd: Rate = { id: 'rate-intercity-bulk-usd', @@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_TON', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; const intercityContainerUsd: Rate = { @@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_CONTAINER', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; let service: BookingPricingService; @@ -46,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => { ratesService as never, exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + {} as never, ); }); @@ -56,6 +67,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -81,6 +94,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'USD', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -106,6 +121,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 50, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -126,4 +143,333 @@ describe('BookingPricingService — domestic corridor', () => { const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; expect(line.currency).toBe('ETB'); }); + + // Rates are quoted per leg, so one configured for MOJO → DIRE must not price a + // shipment that runs LEBU → DIRE. Charging the wrong corridor's price because + // nobody configured this one yet is worse than billing no base freight. + it('does not price bulk off a rate configured for a different leg', async () => { + const booking = { + id: 'b-3', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(0); + expect(result.blocked).toHaveLength(1); + }); + + it('does not price containers off a rate configured for a different leg', async () => { + const booking = { + id: 'b-4', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 50, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: 'ct-20', quantity: 3 }], + }); + + expect(result.lineItems).toHaveLength(0); + }); + + // A mixed booking where only one container size has a configured rate must + // hard-block, not silently carry the unconfigured size for free. + it('blocks the unconfigured container size and prices the configured one', async () => { + const fortyOnly: Rate = { + ...intercityContainerUsd, + id: 'rate-ct-40-only', + containerTypeId: 'ct-40', + } as Rate; + ratesService.findLiveRates.mockResolvedValue([fortyOnly]); + + const booking = { + id: 'b-5', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [ + { containerTypeId: 'ct-40', quantity: 2 }, + { containerTypeId: 'ct-20', quantity: 3 }, + ], + }); + + expect(result.lineItems).toHaveLength(1); + expect(result.blocked).toHaveLength(1); + expect(result.blocked[0]).toContain('rate is configured'); + }); +}); + +describe('BookingPricingService — customs clearance fee billed on the booking price', () => { + const DJ = 'yard-dj'; + + const containerFee20: Rate = { + id: 'rate-cc-20', + rateType: 'CUSTOMS_CLEARANCE', + trigger: 'CUSTOMS_CLEARANCE', + currency: 'USD', + rateValue: 100, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: 'ct-20', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: DIRE, + } as Rate; + + const bulkFeePerTon: Rate = { + ...containerFee20, + id: 'rate-cc-bulk', + rateValue: 5, + rateUnit: 'PER_TON', + containerTypeId: null, + } as Rate; + + const emptyEval = { + priorityScore: 0, + appliedModifiers: [], + containerWeightResults: [], + warnings: [], + hardBlocked: [], + requiresDirectorApproval: false, + }; + + const makeService = (opts: { + snapshots?: unknown[]; + liveRates?: Rate[]; + wagonCapacity?: number; + }) => + new BookingPricingService( + { + calculateWagonCount: jest.fn().mockResolvedValue(0), + findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []), + } as never, + { evaluate: jest.fn().mockResolvedValue(emptyEval) } as never, + { + findById: jest.fn(async (id: string) => ({ + id, + sizeFt: id === 'ct-40' ? 40 : 20, + isReefer: false, + code: id === 'ct-40' ? 'C40' : 'C20', + })), + } as never, + { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + findById: jest.fn().mockResolvedValue({ + wagonTypes: + opts.wagonCapacity !== undefined + ? [{ capacityTons: opts.wagonCapacity }] + : [], + }), + } as never, + ); + + const containerBooking = (overrides: Record = {}) => + ({ + id: 'b-cc', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 }, + ], + ...overrides, + }) as unknown as Booking; + + const bulkBooking = (overrides: Record = {}) => + ({ + id: 'b-cc-bulk', + freightType: 'BULK', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + cargoTypeId: 'cargo-1', + cargoTotalWeightVgm: 120, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [], + ...overrides, + }) as unknown as Booking; + + it('bills a container booking per box at its own container type fee', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.unit).toBe('PER_CONTAINER'); + expect(line!.quantity).toBe(4); + expect(line!.amount).toBe(400); + }); + + it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => { + const service = makeService({ + liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); + expect(line!.amount).toBe(200); + }); + + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(containerBooking()); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true); + }); + + it('bills a bulk booking per ton at the route bulk fee', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_TON'); + expect(line!.quantity).toBe(120); + expect(line!.amount).toBe(600); + }); + + it('the fee scoped to the booking commodity wins over the catch-all', async () => { + const service = makeService({ + liveRates: [ + { ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate, + { + ...bulkFeePerTon, + id: 'rate-cc-sugar', + rateValue: 9, + cargoTypeId: 'cargo-1', + } as Rate, + ], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all + expect(line!.amount).toBe(1080); + }); + + it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate], + wagonCapacity: 60, + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon + expect(line!.amount).toBe(100); + }); + + it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true); + }); + + it('prefers the contract frozen per-size snapshot over the live rate', async () => { + const service = makeService({ + liveRates: [containerFee20], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE_20FT', + unitPrice: 80, + currency: 'USD', + unitOfMeasure: 'per_container', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-1' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100 + }); + + it('honours a legacy FLAT snapshot once for the whole container booking', async () => { + const service = makeService({ + liveRates: [], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE', + unitPrice: 500, + currency: 'USD', + unitOfMeasure: 'flat', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-legacy' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('FLAT'); + expect(line!.amount).toBe(500); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false); + }); + + it('adds no fee line when customs clearing is disabled', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking( + containerBooking({ customsClearingEnabled: false }), + ); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 00cf55e17..89825e100 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -1,19 +1,22 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; +import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; -import { BookingsRepository } from './bookings.repository'; import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; + containersPerWagonForSize, + wagonsPerUnitForSize, +} from '../rule-engine/container-type.util'; +import { BookingsRepository } from './bookings.repository'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -77,6 +80,7 @@ export class BookingPricingService { private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, + private readonly cargoTypesService: CargoTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -128,11 +132,22 @@ export class BookingPricingService { const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + // H15: a booking created under a contract prices from that contract's FROZEN + // rate snapshots (the agreed rates), not the live rate of the day. Loaded + // once and threaded through the line builders; each rate code that has a + // snapshot uses it, and any code without one falls back to the live rate. + // Non-contract bookings resolve to null and keep the live-rate path. + const frozenRates = await this.loadFrozenContractRates(booking); + const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates } = - await this.computeBaseRailLinesWithRates(booking, evalInput); + const { + lineItems: baseLines, + usedRates: baseRates, + warnings: baseWarnings, + blocked: baseBlocked, + } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -141,7 +156,7 @@ export class BookingPricingService { // First / last mile trucking — billed per the rate's unit (km / container / // ton / flat), only for legs the booking actually carries. const { lineItems: mileLines, usedRates: mileRates } = - await this.computeFirstLastMileLines(booking, evalInput); + await this.computeFirstLastMileLines(booking, evalInput, frozenRates); for (const line of mileLines) { lineItems.push(line); total += line.amount; @@ -153,15 +168,22 @@ export class BookingPricingService { for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; - const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const rate = rateById.get(mod.rateId); - const unit = rate?.rateUnit ?? 'FLAT'; - const unitUsd = rate ? Number(rate.rateValue) : usdAmount; - const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + // Derived/route-matched charges (import overweight, empty-container + // return) carry their own unit price + billing unit — bill and display + // those, not whatever the referenced rate row says. + const isDerived = mod.unitPriceUsd != null; + const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; + const unitUsd = isDerived + ? Number(mod.unitPriceUsd) + : rate + ? Number(rate.rateValue) + : usdAmount; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise - // derive from total ÷ unit price. + // derive from total ÷ unit price (the live unit price — a count, not a + // currency amount, so it is snapshot-independent). const quantity = unit === 'FLAT' || unit === 'PER_INVOICE' ? 1 @@ -171,6 +193,26 @@ export class BookingPricingService { ? Math.max(1, Math.round(usdAmount / unitUsd)) : 1; + // H15: bill the frozen contract surcharge rate (already in the booking + // currency) when this code has a snapshot; else keep the live amount. + // Derived charges skip the snapshot — import overweight prices off the + // route's container freight, never a frozen OVERWEIGHT_PER_TON value. + const frozen = isDerived + ? null + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); + const unitAmount = frozen + ? Number(frozen.unitPrice) + : isEtbBooking + ? Math.round(unitUsd * usdToEtb) + : unitUsd; + const convertedAmount = frozen + ? isEtbBooking + ? Math.round(unitAmount * quantity) + : unitAmount * quantity + : isEtbBooking + ? Math.round(usdAmount * usdToEtb) + : usdAmount; + const item: PriceLineItemDto = { code: mod.surchargeCode, description: surchargeLabel(mod.surchargeCode), @@ -186,6 +228,23 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Customs clearance service fee (Path B) — billed HERE, on the booking + // invoice with the freight; no separate prepaid clearance invoice. Sold per + // cargo kind: container bookings bill each container type's own fee (per + // box or per wagon), bulk bookings the route's bulk fee (per ton or per + // wagon). Frozen contract snapshots win over live rates; a customs booking + // with nothing configured hard-blocks — clearance never ships for free. + const clearanceBlocked: string[] = []; + if (booking.customsClearingEnabled) { + const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates); + for (const line of clearance.lineItems) { + lineItems.push(line); + total += line.amount; + } + for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate); + clearanceBlocked.push(...clearance.blocked); + } + // Overweight detail for the customer: map the engine's per-line results back // to the booking's container lines (same order) for code + weights. maxAllowed // is derived from the line total minus the excess the engine computed. @@ -222,8 +281,8 @@ export class BookingPricingService { usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, - warnings: ruleResult.warnings, - hardBlocked: ruleResult.hardBlocked, + warnings: [...ruleResult.warnings, ...baseWarnings], + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked], overweightLines, }; } @@ -280,8 +339,14 @@ export class BookingPricingService { vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, + // Per-container opt-ins — PER_CONTAINER surcharges bill these. + hazardousQuantity: Number(bc.hazardousQuantity ?? 0), + reeferQuantity: Number(bc.reeferQuantity ?? 0), + returnQuantity: Number(bc.returnQuantity ?? 0), + // Wagon share per box — a PER_WAGON empty-return rate bills on it. + wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt), }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), @@ -297,6 +362,13 @@ export class BookingPricingService { ), ) : 0; + // Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing). + // Deliberately NOT totalWagons — that would shift wagon-count priority + // scoring for bulk bookings. + const bulkWagons = + booking.freightType === 'BULK' + ? ((await this.bulkWagonCount(booking)) ?? 0) + : 0; // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever // a container type leaves a wagon partially filled. Aggregate by type first — @@ -328,9 +400,16 @@ export class BookingPricingService { // reefer quantity) applies the REEFER surcharge even for non-reefer // container types. ORed with per-container reefer in the engine. isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true', + // Empty-container return service (container freight only) — bills the + // WITH_RETURN surcharge per container, like hazard/reefer. + withReturn: + booking.freightType === 'CONTAINER' && + booking.equipmentReturn === 'WITH_RETURN', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. @@ -338,6 +417,7 @@ export class BookingPricingService { booking.freightType === 'BULK' ? Number(booking.cargoTotalWeightVgm ?? 0) : 0, + bulkWagons, containers, }; } @@ -419,7 +499,13 @@ export class BookingPricingService { private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + frozenRates: Map | null = null, + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -441,53 +527,133 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); + const warnings: string[] = []; + const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { - const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); - if (!rate) continue; - - usedRatesMap.set(rate.id, rate); - const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - const unitUsd = Number(rate.rateValue); + const rate = this.pickRate( + liveRates, + rateType, + container.containerTypeId, + 'USD', + booking.originYardId, + booking.destinationYardId, + ); + // H15: frozen contract rate for this container size, when present — its + // unitPrice is already in the booking currency (no USD→currency convert). + // It also stands on its own: a contract line prices off the agreed rate + // even when nobody configured a live rate for this leg + type yet. + const frozen = await this.frozenRateForContainer( + frozenRates, + container.containerTypeId, + paymentCurrency, + ); const label = await this.containerTypeLabel(container.containerTypeId); + if (!rate && !frozen) { + // Never price this line off another container type's (or another + // route's) rate, and never let an unpriced line through: a booking + // that ships a container type nobody configured a rate for would be + // carried for free. Hard-block instead — the customer drops the line + // or EDR configures the rate. + blocked.push( + `No ${rateType} rate is configured for ${label} on this route — ` + + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', + ); + continue; + } + + const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = this.amountForUnit( + rateUnit, + unitAmount, + container.quantity, + wagonCount, + ); + } else { + const unitUsd = Number(rate!.rateValue); + const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + } + if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, - unit: rate.rateUnit, - quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), + unitAmount, + unit: rateUnit, + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } - if (lines.length === 0) { + if (lines.length === 0 && evalInput.containers.length === 0) { + // Bulk (and any booking with no container lines) still has to price off a + // rate configured for this leg — never one belonging to another route. + // Container bookings never reach this fallback: their lines price per + // container type above or stay unpriced with a warning — falling back to + // a corridor rate of a DIFFERENT container type billed once (qty 1) is + // how a 38-container booking was invoiced 40 USD instead of 1900. const fallback = liveRates.find( - (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, ); if (fallback) { usedRatesMap.set(fallback.id, fallback); const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; - const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const unitUsd = Number(fallback.rateValue); + // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. + const frozen = isBulk + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + : null; + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = this.amountForUnit( + fallback.rateUnit, + unitAmount, + quantity, + wagonCount, + ); + } else { + const usdAmount = this.amountForRate(fallback, quantity, wagonCount); + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + } lines.push({ code: rateType, description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unitAmount, unit: fallback.rateUnit, quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); + } else if (isBulk) { + // Same rule as container lines: bulk freight with no rate on this leg + // must not proceed unpriced. + blocked.push( + `No ${rateType} rate is configured for this route — the booking ` + + 'cannot be priced. Ask EDR to configure the rate for this ' + + 'origin → destination.', + ); } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** @@ -503,6 +669,7 @@ export class BookingPricingService { private async computeFirstLastMileLines( booking: Booking, evalInput: BookingEvaluationInput, + frozenRates: Map | null = null, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [ { @@ -560,18 +727,34 @@ export class BookingPricingService { break; } - const usdAmount = value * quantity; + // H15: frozen mile rate (already in booking currency) when the contract + // has one; else the live USD rate converted as before. + const frozen = this.frozenRateByCode( + frozenRates, + leg.rateType, + paymentCurrency, + ); + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = isEtbBooking + ? Math.round(unitAmount * quantity) + : unitAmount * quantity; + } else { + const usdAmount = value * quantity; + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; + } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). - if (!(usdAmount > 0)) continue; + if (!(amount > 0)) continue; - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - const unitUsd = value; usedRatesMap.set(rate.id, rate); lines.push({ code: leg.rateType, description: leg.label, amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unitAmount, unit: rate.rateUnit, quantity, currency: paymentCurrency, @@ -626,36 +809,305 @@ export class BookingPricingService { } } + /** + * Base freight is quoted per leg, so a rate only applies to a booking running + * the exact origin → destination it was configured for. There is deliberately + * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment + * because nobody configured Mojo yet is worse than surfacing no line at all. + * Within the leg, a rate scoped to the container type wins over one that + * covers every type. + */ private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, + originYardId: string, + destinationYardId: string, ): Rate | undefined { + const onLeg = rates.filter( + (r) => + r.rateType === rateType && + r.currency === currency && + r.originYardId === originYardId && + r.destinationYardId === destinationYardId, + ); return ( - rates.find( - (r) => - r.rateType === rateType && - r.currency === currency && - r.containerTypeId === containerTypeId, - ) ?? - rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + onLeg.find((r) => r.containerTypeId === containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId) ); } private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { - const value = Number(rate.rateValue); - switch (rate.rateUnit) { + return this.amountForUnit( + rate.rateUnit, + Number(rate.rateValue), + quantity, + wagonCount, + ); + } + + /** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */ + private amountForUnit( + rateUnit: string, + unitValue: number, + quantity: number, + wagonCount: number, + ): number { + switch (rateUnit) { case 'PER_CONTAINER': - return value * quantity; + return unitValue * quantity; case 'PER_WAGON': - return value * wagonCount; + return unitValue * wagonCount; case 'PER_TON': - return value * quantity; + return unitValue * quantity; case 'FLAT': - return value; + return unitValue; default: - return value * quantity; + return unitValue * quantity; + } + } + + // ── H15: frozen contract rate snapshots ──────────────────────────────────── + + /** + * Load a contract's frozen rate snapshots into a by-rate-code lookup, or null + * for a non-contract booking (or a contract with no snapshots). The pricing + * line builders prefer a matching snapshot's unit price over the live rate. + */ + private async loadFrozenContractRates( + booking: Booking, + ): Promise | null> { + if (!booking.contractId) return null; + const snapshots = await this.bookingsRepository.findContractRateSnapshots( + booking.contractId, + ); + if (!snapshots.length) return null; + const byCode = new Map(); + for (const snap of snapshots) byCode.set(snap.rateCode, snap); + return byCode; + } + + /** + * The frozen snapshot for a rate code, or null when there is none, its price + * is negative, or it is in a different currency than the booking (in which + * case the live-rate path is safer than a mis-converted frozen price). + */ + private frozenRateByCode( + frozenRates: Map | null, + code: string, + bookingCurrency: string, + ): ContractRateSnapshot | null { + const snap = frozenRates?.get(code); + if (!snap) return null; + if (snap.currency !== bookingCurrency) return null; + if (!(Number(snap.unitPrice) >= 0)) return null; + return snap; + } + + /** + * The frozen base-rail snapshot for a container line, matched by the + * container's size (CONTAINER_20FT / CONTAINER_40FT — the codes + * ContractPricingService freezes). Null when there is no snapshot. + */ + private async frozenRateForContainer( + frozenRates: Map | null, + containerTypeId: string, + bookingCurrency: string, + ): Promise { + if (!frozenRates) return null; + let sizeFt: number | null = null; + try { + sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null; + } catch { + return null; + } + if (!sizeFt) return null; + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + } + + /** + * Customs clearance service fee lines for a customs booking (Path B), billed + * with the freight. Container bookings bill each container line at its own + * container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the + * line occupies (two 20ft share one). Bulk bookings bill the route's type-less + * fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen + * contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE) + * win over live rates; contracts frozen before the per-kind model carry one + * FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking. + */ + private async customsClearanceLines( + booking: Booking, + frozenRates: Map | null, + liveRates: Rate[], + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> { + const lineItems: PriceLineItemDto[] = []; + const usedRates: Rate[] = []; + const blocked: string[] = []; + const currency = booking.paymentCurrency; + const isEtb = currency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === booking.tradeDirection && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, + ); + const missingRateMessage = (scope: string): string => + `No customs clearance service fee is configured for ${scope} on this ` + + 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; + + if (booking.freightType === 'CONTAINER') { + // Legacy short-circuit: an old contract froze one flat fee — bill it once. + const hasPerSizeSnapshot = + frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || + frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + if (legacyFlat && !hasPerSizeSnapshot) { + const amount = Number(legacyFlat.unitPrice); + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service', + amount, + unitAmount: amount, + unit: 'FLAT', + quantity: 1, + currency, + }); + } + return { lineItems, usedRates, blocked }; + } + + for (const bc of booking.bookingContainers ?? []) { + if (!bc.containerTypeId) continue; + const qty = Number(bc.quantity || 0); + if (!(qty > 0)) continue; + let sizeFt = 0; + try { + sizeFt = + Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0; + } catch { + // unknown type — falls through to the live per-type lookup below + } + const frozen = sizeFt + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + : null; + const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`)); + continue; + } + const unit = frozen + ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) + : live!.rateUnit; + const unitAmount = frozen + ? Number(frozen.unitPrice) + : convert(Number(live!.rateValue)); + const billedQty = + unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty; + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (!(amount > 0)) continue; + lineItems.push({ + code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', + description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + // Bulk — one fee for the whole booking. The bulk snapshot and the legacy + // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. + // Live lookup: the rate scoped to the booking's commodity wins; a + // commodity-less rate (legacy) is the catch-all fallback. + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const live = + (booking.cargoTypeId + ? onLeg.find( + (r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId, + ) + : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage('bulk cargo')); + return { lineItems, usedRates, blocked }; + } + const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; + const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); + let billedQty = 1; + if (unit === 'PER_TON') { + billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); + } else if (unit === 'PER_WAGON') { + const wagons = await this.bulkWagonCount(booking); + if (wagons == null) { + blocked.push( + 'The bulk customs clearance fee is per wagon, but this cargo type has ' + + 'no wagon type with a capacity configured — the wagon count cannot ' + + 'be derived. Ask EDR to configure the cargo type’s wagon types.', + ); + return { lineItems, usedRates, blocked }; + } + billedQty = wagons; + } + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service (bulk)', + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + /** Snapshot unit-of-measure → the rate unit the billing math applies. */ + private rateUnitFromSnapshot(unitOfMeasure: string): string { + switch (unitOfMeasure) { + case 'per_wagon': + return 'PER_WAGON'; + case 'per_ton': + return 'PER_TON'; + case 'per_container': + return 'PER_CONTAINER'; + default: + return 'FLAT'; + } + } + + /** + * Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the + * largest-capacity wagon type its cargo type allows. Null when the chain is + * unconfigured (no cargo type, no wagon types, no capacity). + * ponytail: pricing-time estimate off the biggest allowed wagon; scheduling + * may stock a smaller type and use more wagons. + */ + private async bulkWagonCount(booking: Booking): Promise { + const tons = Number(booking.cargoTotalWeightVgm ?? 0); + if (!(tons > 0) || !booking.cargoTypeId) return null; + try { + const cargo = await this.cargoTypesService.findById(booking.cargoTypeId); + const capacity = Math.max( + 0, + ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), + ); + if (!(capacity > 0)) return null; + return Math.max(1, Math.ceil(tons / capacity)); + } catch { + return null; } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..507ef43d8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from "@nestjs/common"; -import { In, Not } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../rule-engine/entities/container-type.entity"; @@ -34,8 +33,6 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; -const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const; - export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { @@ -110,7 +107,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); @@ -135,10 +131,7 @@ export class BookingReferenceDataService { const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = await Promise.all([ this.yardsRepository.findAll({ - where: { - isActive: true, - code: Not(In([...LEGACY_YARD_CODES])), - }, + where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), this.containerTypesRepository.findAll({ diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 607a0d7a4..068ed53af 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => { findById: jest.fn().mockResolvedValue(booking), }; const ruleEngineService = { - instantiateApprovalSteps: jest.fn().mockResolvedValue([]), + assertNoHardBlocks: jest.fn(), + }; + const contractService = { + generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }), }; const service = new BookingTransitionService( bookingsRepository as never, ruleEngineService as never, {} as never, // pricingService - {} as never, // contractService + contractService as never, {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService @@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { dutySlipUploadedToStaff: jest.fn(), } as never, // notifier ); - return { service, bookingsRepository, ruleEngineService }; + return { service, bookingsRepository, ruleEngineService, contractService }; } it('rejects accept when validity days is missing or non-positive', async () => { @@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { const [id, updates] = bookingsRepository.update.mock.calls[0]; expect(id).toBe('b-1'); expect(updates).toMatchObject({ - status: 'PENDING_APPROVAL', + status: 'APPROVED', approvedByStaffId: 'staff-1', contractValidityDays: 10, }); @@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => { expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime()); }); - it('instantiates the approval chain when accepting', async () => { - const { service, ruleEngineService } = makeService(); + it('approves outright and generates the contract (no approval chain)', async () => { + const { service, contractService } = makeService(); await service.acceptIntake('b-1', 'staff-1', 30); - expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ freightType: 'CONTAINER' }), - ); + expect(contractService.generateContract).toHaveBeenCalledWith('b-1'); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 9201e4fa9..cbbb999ef 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** @@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => { ); }); }); + +/** + * Export over-book gate at the customer's requestOperation step: export never + * splits, so the free-space check runs the moment the customer commits to a + * shipment day. When no single export train that day can carry the whole + * booking, `pickExportSchedule` throws and the request is refused BEFORE the + * booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated + * here (they are batched + splittable later). + */ +describe('BookingTransitionService — requestOperation export space gate', () => { + function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: 'CLEARANCE_READY', + tradeDirection, + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest + .fn() + .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), + }; + const bookingBatchService = { + // Over-book → the export gate rejects; otherwise it returns a schedule id. + pickExportSchedule: overbook + ? jest.fn().mockRejectedValue(new ConflictException('Not enough train space')) + : jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRequestedToStaff: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + ); + return { service, bookingsRepository, bookingBatchService }; + } + + it('rejects an over-booked export request and does NOT advance the booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + true, + ); + await expect( + service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('lets an export request through when a train fits the whole booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + false, + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); + + it('never runs the export gate for an import request', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'IMPORT', + true, // would reject IF called — proves it is not called + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled(); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7b1522446..ec6e466b2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1,14 +1,14 @@ import { BadRequestException, + ConflictException, forwardRef, Inject, Injectable, Logger, Optional, } from "@nestjs/common"; -import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { OnEvent } from "@nestjs/event-emitter"; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; @@ -32,8 +32,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; - -import { Freight } from "@edr/types"; import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() @@ -43,6 +41,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -248,16 +247,6 @@ export class BookingTransitionService { return fresh; } - /** Auto-create booking approval steps from system rules when none exist yet. */ - private async ensureBookingApprovalSteps(booking: Booking): Promise { - if ((booking.approvalSteps?.length ?? 0) > 0) return; - - await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - } - async acceptIntake( bookingId: string, actorId: string, @@ -283,21 +272,33 @@ export class BookingTransitionService { const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as "CONTAINER" | "BULK", - cargoTypeId: booking.cargoTypeId, - }); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "PENDING_APPROVAL", + // Bookings no longer run a multi-step approval chain — accepting the intake + // approves the booking outright and generates its contract. (The approval + // chain is a contract-only concern now; see contract-transition.service.) + await this.bookingsRepository.update(bookingId, { + status: "APPROVED", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - const fresh = await this.bookingsService.findById(updated!.id); + + // Generating the contract is best-effort: the acceptance is already + // committed, so a failure here must not roll it back. The booking stays + // APPROVED and staff can retry generation from the booking page. + try { + await this.contractService.generateContract(bookingId); + } catch (err) { + this.logger.warn( + `Contract generation failed after accepting booking ${bookingId}: ${err}. ` + + `The booking is APPROVED — retry generation from the booking page.`, + ); + } + + const fresh = await this.bookingsService.findById(bookingId); this.notifier.accepted(fresh); + this.notifier.approved(fresh); return fresh; } @@ -324,140 +325,6 @@ export class BookingTransitionService { return fresh; } - async approveStep( - bookingId: string, - stepId: string, - actorId: string, - requiredRole: string, - authUser?: TCurrentUser, - ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - - let booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - if ((booking.approvalSteps?.length ?? 0) === 0) { - await this.ensureBookingApprovalSteps(booking); - booking = await this.bookingsService.findById(bookingId); - } - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step || step.status !== "PENDING") { - throw new BadRequestException( - "Approval step not found or already actioned", - ); - } - - const next = - await this.bookingsRepository.findNextPendingApprovalStep(bookingId); - if (!next || next.id !== step.id) { - throw new BadRequestException( - "Approval steps must be completed in order", - ); - } - - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - - const blocksRole = step.blocksRole; - if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException( - `Role ${requiredRole} is blocked for this step`, - ); - } - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "APPROVED", - ); - - const updates: Record = {}; - const now = new Date(); - - if (requiredRole === "LINE_STAFF") { - updates.status = "APPROVED_PENDING_SIGNATURE"; - updates.approvedByStaffId = actorId; - updates.approvedByStaffAt = now; - } else if (requiredRole === "DIRECTOR") { - updates.signedByDirectorId = actorId; - updates.signedByDirectorAt = now; - } else if (requiredRole === "CEO") { - updates.signedByCeoId = actorId; - updates.signedByCeoAt = now; - } - - const allDone = - await this.bookingsRepository.allApprovalStepsComplete(bookingId); - if (allDone) { - updates.status = "APPROVED"; - } - - if (Object.keys(updates).length > 0) { - await this.bookingsRepository.update(bookingId, updates as never); - } - - if (allDone) { - const generated = await this.contractService.generateContract(bookingId); - const fresh = await this.bookingsService.findById(generated.id); - this.notifier.approved(fresh); - return fresh; - } - - return this.bookingsService.findById(bookingId); - } - - async rejectStep( - bookingId: string, - stepId: string, - actorId: string, - reason: string, - ): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "PENDING_APPROVAL", - "APPROVED_PENDING_SIGNATURE", - ]); - - const step = await this.bookingsRepository.findApprovalStepById( - bookingId, - stepId, - ); - if (!step) throw new BadRequestException("Approval step not found"); - - await this.bookingsRepository.completeApprovalStep( - step.id, - actorId, - "REJECTED", - reason, - ); - - await this.bookingsRepository.createReviewNote( - bookingId, - reason, - "REJECTION", - actorId, - ); - - const updated = await this.bookingsRepository.update(bookingId, { - status: "REJECTED", - } as never); - const fresh = await this.bookingsService.findById(updated!.id); - this.notifier.rejected(fresh, reason); - return fresh; - } - async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["CONTRACT_READY"]); @@ -483,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); @@ -533,6 +416,10 @@ export class BookingTransitionService { "REJECTION", ); + // Stop the open-invoice leak: a cancelled booking must not leave a payable + // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). + await this.invoiceService.expireOpenInvoices(bookingId); + const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); @@ -561,6 +448,10 @@ export class BookingTransitionService { "REJECTION", ); + // Stop the open-invoice leak: a rejected booking must not leave a payable + // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). + await this.invoiceService.expireOpenInvoices(bookingId); + const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); @@ -961,6 +852,22 @@ export class BookingTransitionService { } } + // Intercity: there is no shipment-day request step — an approved booking + // goes straight to FULLY_EXECUTED, which is what the intercity ride-along + // pool keys on. Staff then accept it onto a passing train (that accept + // opens the pay window). + if (booking.tradeDirection === "DOMESTIC") { + const now = new Date(); + await this.bookingsRepository.update(bookingId, { + status: "FULLY_EXECUTED", + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.intercityDocumentsApproved(fresh); + return fresh; + } + await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); @@ -1003,18 +910,60 @@ export class BookingTransitionService { } // The binding shipment day must have at least one OPEN departure on the - // route — only schedule-backed days are selectable. The batch engine - // assigns the specific train within that (route, day) pool later. - const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( - booking.originYardId, - booking.destinationYardId, - eatDay(date), - ); + // route — only schedule-backed days are selectable — AND some departure + // that day must be able to physically carry this cargo type (wagon-TYPE + // gate; quantity never blocks — oversized bookings get a partial split + // offer). The batch engine assigns the specific train within that + // (route, day) pool later. + const { hasDeparture, hasCompatible } = + await this.bookingsService.checkDayCompatibilityForBooking( + booking, + eatDay(date), + ); if (!hasDeparture) { throw new BadRequestException( "No departures available on the selected day for this route", ); } + if (!hasCompatible) { + throw new BadRequestException( + "No wagon on the selected day can carry this cargo type — please choose another day", + ); + } + + // Export is FCFS and never splits — a booking must ride one train whole. So + // the free-space check belongs HERE, the moment the customer commits to a + // shipment day, not later at staff operation-accept. Blocking now stops the + // customer booking more wagons than any single export train that day can + // still carry; `exportSpaceReport` throws a 409 whose message carries the + // largest bookable leftover ("reduce to N wagons or pick another day"). + // Import/domestic bookings are batched + splittable, so they are NOT gated + // here — they get an advisory count below and the batch engine sizes them. + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + if (isExportTrain) { + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } + } await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", @@ -1025,6 +974,47 @@ export class BookingTransitionService { return fresh; } + /** + * Advisory availability for a shipment day the customer is considering — a + * planning hint for the day picker, computed but never enforced. For EXPORT it + * mirrors the real request-time gate: `fits` is whether a single open train + * that day can carry the WHOLE booking (export never splits), and `freeWagons` + * is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the + * TOTAL room across the day's trains for the booking's wagon type (the batch + * engine may still split or defer a remainder), and `fits` is whether that + * total covers the booking. `trainsForDay` is false when no departure carries + * the leg — the day is unbookable regardless of space. + */ + async dayAvailabilityForBooking( + bookingId: string, + scheduledDate: string, + ): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + const day = eatDay(date); + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + + if (isExportTrain) { + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const report = + await this.bookingBatchService.exportSpaceReport(scheduledBooking); + return { + fits: report.scheduleId != null, + freeWagons: report.bestAvailable?.wagons ?? 0, + trainsForDay: report.trainsForDay && report.corridorMatched, + }; + } + + const { freeWagons, need, trainsForDay } = + await this.bookingBatchService.dayImportAvailability(booking, day); + return { fits: freeWagons >= need, freeWagons, trainsForDay }; + } + /** * Operations team reviews a pending operation request (capacity, documents, * route). Two outcomes: @@ -1090,14 +1080,25 @@ export class BookingTransitionService { await this.bookingBatchService.pickExportSchedule(booking); } + // Mint the booking's invoice (DRAFT) so the priced order carries its billing + // record from accept onward. It is deliberately NOT issued here: accepting an + // operation only puts the booking in the batch holding pool — no slot has been + // offered and no pay window exists yet. Issuing at this point made the invoice + // payable straight away (portal invoice list/detail gate on invoice status + // alone), letting a customer pay before being selected for a batch, while the + // booking page correctly still showed it as not payable. The batch engine + // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window + // and the real deadline are created — matching the portal's `canPay` gate. const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( - `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, - ); - await this.invoiceService.updateStatus( - invoice.id, - Freight.InvoiceStatus.Pending, + `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, ); + // TODO: road (truck) orders are an incomplete feature — they stop at the + // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no + // per-km pricing wired via roadKmPrice, no pay surface in the portal). They + // skip the train batch, so they never reach `reserve` and their invoice stays + // DRAFT / unpayable. When the road flow is built, issue its invoice + // (billing.issuePayable) at whatever transition opens the road pay window. if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: "ROAD_DISPATCH_PENDING", @@ -1191,12 +1192,9 @@ export class BookingTransitionService { } let nextStep: BookingNextStep | null = null; try { - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - nextStep = computeNextStep(booking, nextPending); + // Bookings no longer carry an approval chain, so there is never a pending + // approval step to hint at. + nextStep = computeNextStep(booking, null); } catch (err) { this.logger.warn( `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f556751fb..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, - ApproveStepDto, CancelBookingDto, RejectBookingDto, - RejectStepDto, RequestChangesDto, ReviewDocumentDto, RequestOperationDto, @@ -254,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) @@ -348,6 +349,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/available-days') + @ApiOperation({ + summary: + 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', + }) + async availableDays( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.availableDaysForBooking(id); + } + + @Get(':id/day-availability') + @ApiOperation({ + summary: + 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + + 'Export: whole-booking fit + largest single-train leftover. ' + + 'Import/domestic: total room across the day for the booking\'s wagon type.', + }) + async dayAvailability( + @Param('id', ParseUUIDPipe) id: string, + @Query('date') date: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.dayAvailabilityForBooking(id, date); + } + @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', @@ -391,18 +439,26 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') - @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + @ApiOperation({ + summary: + 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, + @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } + const extraCopyIndexes = (copies ?? '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id); + await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); @@ -435,6 +491,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( @@ -976,47 +1046,6 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(":id/approval-steps/:stepId/approve") - @BookingStaff([ - FREIGHT_PERMS.bookings.approveLineStaff, - FREIGHT_PERMS.bookings.approveDirector, - FREIGHT_PERMS.bookings.approveCeo, - ]) - @ApiOperation({ summary: "Approve one approval step in sequence" }) - async approveStep( - @Param("id", ParseUUIDPipe) id: string, - @Param("stepId", ParseUUIDPipe) stepId: string, - @Body() dto: ApproveStepDto, - @CurrentUser() user: TCurrentUser, - ) { - const booking = await this.transitionService.approveStep( - id, - stepId, - resolveAuthUserId(user), - dto.requiredRole, - user, - ); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(":id/approval-steps/:stepId/reject") - @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: "Reject at approval step" }) - async rejectStep( - @Param("id", ParseUUIDPipe) id: string, - @Param("stepId", ParseUUIDPipe) stepId: string, - @Body() dto: RejectStepDto, - @CurrentUser() user: AuthUserPayload, - ) { - const booking = await this.transitionService.rejectStep( - id, - stepId, - resolveAuthUserId(user), - dto.reason, - ); - return this.transitionService.enrichBookingResponse(booking); - } - @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) @ApiOperation({ summary: "Generate contract PDF from template" }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e7806c13c..38d7d2ac6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; @@ -47,6 +46,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; @@ -59,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; Booking, BookingContainer, BookingCargoModifier, - BookingApprovalStep, BookingDocumentReview, BookingRateSnapshot, BookingReviewNote, @@ -106,6 +105,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, + ContractRateScheduleBuilder, ContractRendererService, ContractPdfService, CustomerTruckAssignmentsRepository, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 317066cc4..590bd7262 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,10 +4,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -112,7 +113,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'oy') .leftJoinAndSelect('booking.destinationYard', 'dy') .leftJoinAndSelect('booking.shippingLine', 'sl') - .leftJoinAndSelect('booking.approvalSteps', 'steps') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') @@ -148,7 +148,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -178,7 +178,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) @@ -200,6 +203,19 @@ export class BookingsRepository extends BaseRepository { return Number(route?.km ?? 0); } + /** + * Frozen contract unit-rate snapshots for a contract (H15). A booking created + * under a contract prices from these agreed, frozen rates rather than the live + * rate of the day; the pricing service matches them by rate code. + */ + findContractRateSnapshots( + contractId: string, + ): Promise { + return this.dataSource + .getRepository(ContractRateSnapshot) + .find({ where: { contractId } }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever @@ -217,10 +233,12 @@ export class BookingsRepository extends BaseRepository { quantity: number; containersPerWagon: number; }, + manager?: EntityManager, ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - const qb = this.repository + const repo = manager ? manager.getRepository(Booking) : this.repository; + const qb = repo .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -257,7 +275,18 @@ export class BookingsRepository extends BaseRepository { ); } - return qb.orderBy('b.createdAt', 'ASC').getOne(); + qb.orderBy('b.createdAt', 'ASC'); + + // H9: under the caller's transaction, take a write lock on the matched + // partner booking row (FOR UPDATE OF b — booking rows only, not the joined + // reference tables) so a concurrent consolidation cannot claim the same + // partner between this find and the pair write. Only when a transaction + // manager is supplied — a pessimistic lock requires an open transaction. + if (manager) { + qb.setLock('pessimistic_write', undefined, ['b']); + } + + return qb.getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ @@ -268,9 +297,14 @@ export class BookingsRepository extends BaseRepository { quantity: number; containersPerWagon: number; }>, + manager?: EntityManager, ): Promise { for (const slot of slots) { - const partner = await this.findComplementaryConsolidationPartner(booking, slot); + const partner = await this.findComplementaryConsolidationPartner( + booking, + slot, + manager, + ); if (partner) return partner; } return null; @@ -308,6 +342,63 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Race-safe pairing (H9): the transactional counterpart of + * {@link pairConsolidation}. Must run inside the caller's transaction + * (`manager`), which should already hold the partner-row write lock taken by + * {@link findComplementaryConsolidationPartner}. Re-reads both rows and + * re-asserts `consolidationPartnerId IS NULL` on each before writing; returns + * `false` (no write) when either booking was already paired by a concurrent + * flow, so the caller can fall back to parking. + */ + async pairConsolidationIfUnpaired( + bookingId: string, + partnerId: string, + manager: EntityManager, + ): Promise { + const repo = manager.getRepository(Booking); + // Sequential (one connection per transaction) — never Promise.all here. + const booking = await repo.findOne({ + where: { id: bookingId }, + select: { + id: true, + consolidationPartnerId: true, + consolidationResumeStatus: true, + }, + }); + const partner = await repo.findOne({ + where: { id: partnerId }, + select: { + id: true, + consolidationPartnerId: true, + consolidationResumeStatus: true, + }, + }); + + // Re-assert both are still unpaired before writing (the partner row is held + // under the finder's write lock, so its state is stable here). + if ( + !booking || + !partner || + booking.consolidationPartnerId != null || + partner.consolidationPartnerId != null + ) { + return false; + } + + await repo.update(bookingId, { + consolidationPartnerId: partnerId, + status: booking.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + } as never); + await repo.update(partnerId, { + consolidationPartnerId: bookingId, + status: partner.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + } as never); + return true; + } + /** * Park a booking that needs consolidation but has no partner yet. The optional * resumeStatus is where the booking returns once it pairs — pass it for a @@ -342,58 +433,6 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } - /** Lowest-order pending approval step (sequential enforcement). */ - async findNextPendingApprovalStep( - bookingId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, status: 'PENDING' }, - order: { stepOrder: 'ASC' }, - }); - } - - async findApprovalStepById( - bookingId: string, - stepId: string, - ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, id: stepId }, - }); - } - - /** Get pending approval step for a role (must match next in sequence). */ - async findPendingApprovalStep( - bookingId: string, - requiredRole: string, - ): Promise { - const next = await this.findNextPendingApprovalStep(bookingId); - if (!next || next.requiredRole !== requiredRole) return null; - return next; - } - - /** Mark an approval step complete. */ - async completeApprovalStep( - stepId: string, - actorId: string, - status: 'APPROVED' | 'REJECTED', - remarks?: string, - ): Promise { - await this.dataSource.getRepository(BookingApprovalStep).update(stepId, { - status, - actionedByStaffId: actorId, - actionedAt: new Date(), - remarks, - }); - } - - /** Check if all approval steps are approved. */ - async allApprovalStepsComplete(bookingId: string): Promise { - const pending = await this.dataSource.getRepository(BookingApprovalStep).count({ - where: { bookingId, status: 'PENDING' }, - }); - return pending === 0; - } - // ── Clearance document reviews ──────────────────────────────────────────── findDocumentReviews(bookingId: string): Promise { @@ -580,7 +619,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .where('booking.status IN (:...statuses)', { statuses }); if (options.excludeBulk) { @@ -629,7 +667,6 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.serviceType', 'serviceType') - .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on // Booking → contract, so join the entity by id and select just the @@ -1194,6 +1231,23 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Same as {@link findAllBySchedule} but for a page of schedules at once — + * one query instead of one per schedule (batch monitoring board). */ + findAllBySchedules(scheduleIds: string[]): Promise { + if (!scheduleIds.length) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository @@ -1249,6 +1303,10 @@ export class BookingsRepository extends BaseRepository { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ where: { id: In(bookingIds) }, + // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins + // multiply rows badly in a single join (hot path for every allocation + // preview / assignment validation). + relationLoadStrategy: 'query', relations: { company: true, originYard: true, 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 dd5011df3..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,12 +12,12 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -142,8 +142,21 @@ export class BookingsService { return this.findById(bookingId); } + /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ + static readonly FREIGHT_ORDER_EXTRA_COPIES = [ + 'Original 1 (for Issuing Carrier)', + 'Original 2 (for Consignee)', + 'Original 3 (for Shipper)', + 'Copy 4 (Delivery Receipt)', + 'Copy 5 (Extra Copy)', + 'Copy 6 (Extra Copy)', + 'Copy 7 (Extra Copy)', + 'Copy 8 (for Agent)', + ] as const; + async customerTruckFreightOrderCopies( bookingId: string, + extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { @@ -171,7 +184,12 @@ export class BookingsService { [bookingId], ); - const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + // The 2 gate copies are ALWAYS printed; the waybill-style copies are + // whatever the customer ticked (indexes into the fixed catalog). + const extraCopies = [...new Set(extraCopyIndexes)] + .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) + .filter(Boolean); + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { @@ -268,6 +286,7 @@ export class BookingsService { arrivedAt: string | null; containers: string | null; }>, + extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt @@ -386,6 +405,7 @@ export class BookingsService { ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} + ${extraCopies.map((label) => copy(label)).join('')} `; } @@ -422,6 +442,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -438,7 +460,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); @@ -467,6 +489,8 @@ export class BookingsService { isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId ?? null, + destinationYardId: dto.destinationYardId ?? null, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, @@ -508,13 +532,28 @@ export class BookingsService { return { booking, messages }; } - const partner = await this.bookingsRepository.findConsolidationPartner( - booking, - slots, - ); + // H9: find + pair must be atomic. Run both inside one transaction where the + // finder holds a write lock on the candidate partner row and pairing + // re-asserts both rows are still unpaired before writing — otherwise two + // concurrent bookings can claim the same partner (or pair an + // already-paired booking). `didPair` is false when a concurrent flow won + // the partner, in which case we fall through to parking below. + const partner = await this.dataSource.transaction(async (manager) => { + const candidate = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + manager, + ); + if (!candidate) return null; + const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired( + booking.id, + candidate.id, + manager, + ); + return didPair ? candidate : null; + }); if (partner) { - await this.bookingsRepository.pairConsolidation(booking.id, partner.id); const paired = await this.findById(booking.id); messages.push( this.consolidationService.describePaired(partner.reference, slots), @@ -619,12 +658,9 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // A customer can only book once their company has been approved. - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create bookings yet.", - ); - } + // A customer can only book once their company has been approved; the + // helper names the real status (suspended/blacklisted) when it isn't. + this.companiesService.assertCompanyActiveFor(company, 'bookings'); companyId = company.id; } @@ -653,22 +689,37 @@ export class BookingsService { } else if (dto.scheduledDate) { // A real (binding) scheduledDate was supplied (e.g. staff pinning a day // directly). Require that the route has at least one OPEN departure on - // that EAT day. The booking wizard does NOT send scheduledDate at creation - // — it captures a non-binding estimatedShipmentDate instead, and the - // binding day is chosen later at the operation-request step. General - // contracts also skip this (each drawdown order validates its own day). + // that EAT day AND that some departure that day can physically carry the + // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get + // a partial split offer later). The booking wizard does NOT send + // scheduledDate at creation — it captures a non-binding + // estimatedShipmentDate instead, and the binding day is chosen later at + // the operation-request step. General contracts also skip this (each + // drawdown order validates its own day). const day = eatDay(new Date(dto.scheduledDate)); - const hasDeparture = - await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + const { hasDeparture, hasCompatible } = + await this.trainSchedulingService.checkDayCargoCompatibility( dto.originYardId, dto.destinationYardId, day, + { + freightType: dto.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: dto.cargoTypeId, + containerTypeIds: (dto.containers ?? []) + .map((c) => c.containerTypeId) + .filter((id): id is string => Boolean(id)), + }, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } + if (!hasCompatible) { + throw new BadRequestException( + 'No wagon on the selected day can carry this cargo type — please choose another day', + ); + } } const containers = dto.containers ?? []; @@ -715,21 +766,13 @@ export class BookingsService { ); companyProfileId = profile.id; } else if (companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } + // No explicit profile pin: resolve from the booking's trade direction + // (import→importer, export→exporter; otherwise the first profile). A + // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, - fallbackType, ); // A customer booking under their own account may only do so once the @@ -769,6 +812,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -979,6 +1024,8 @@ export class BookingsService { isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + originYardId: dto.originYardId ?? existing.originYardId, + destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); @@ -1037,9 +1084,6 @@ export class BookingsService { await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, - existing.companyProfileId - ? undefined - : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); @@ -1149,11 +1193,57 @@ export class BookingsService { ); } + /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */ + private cargoIdentityOf(booking: Booking): { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + containerTypeIds?: string[]; + } { + return { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId ?? null, + containerTypeIds: (booking.bookingContainers ?? []) + .map((line) => line.containerTypeId) + .filter((id): id is string => Boolean(id)), + }; + } + + /** + * Day gate for a specific booking: OPEN departure exists AND some departure + * that day can physically carry the booking's cargo/container type. + * Quantity never blocks — oversized bookings get a partial split offer. + */ + async checkDayCompatibilityForBooking( + booking: Booking, + day: string, + ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> { + return this.trainSchedulingService.checkDayCargoCompatibility( + booking.originYardId, + booking.destinationYardId, + day, + this.cargoIdentityOf(booking), + ); + } + + /** + * Days the customer may pick for THIS booking (operation-request step): + * cargo-aware — only days whose departures can carry the booking's cargo + * type. Returns days only, no capacity counts. + */ + async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> { + const booking = await this.findById(bookingId); + return this.trainSchedulingService.getAvailableDaysForCargo({ + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + ...this.cargoIdentityOf(booking), + }); + } + /** * 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. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), 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); @@ -1162,8 +1252,7 @@ export class BookingsService { `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'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1315,15 +1404,6 @@ export class BookingsService { } } - /** - * Resolve the active company_profile id a customer's bookings should be - * scoped to (importer/exporter mode). Null when not onboarded — callers fall - * back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - return this.companiesService.resolveActiveCompanyProfileId(userId); - } - /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong @@ -1506,14 +1586,12 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned SELF_HAUL handover means the customer must approve - // delivery from the portal (booking-based, one per booking). EDR last-mile - // handovers are per delivering truck and signed by the receiver at the door, - // so they never surface the portal "Approve delivery" action. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 969de5583..0e858e702 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -1,7 +1,10 @@ import { clearanceSettingCode, clearanceOutputSettingCode, + clearanceCodesForBooking, + INTERCITY_DOCUMENTS_SETTING_CODE, } from './clearance.util'; +import type { Booking } from './entities/booking.entity'; describe('clearance.util — clearanceSettingCode', () => { it('resolves import container with/without customs', () => { @@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => { ); }); - it('returns null for DOMESTIC (no clearance gate)', () => { - expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); - expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + }); +}); + +describe('clearance.util — clearanceCodesForBooking (intercity)', () => { + const base = { + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + serviceType: null, + customsClearingEnabled: false, + }; + + it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => { + const general = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'GENERAL', + } as unknown as Booking); + expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + expect(general.outputCode).toBeNull(); + + const direct = clearanceCodesForBooking({ + ...base, + contractId: null, + contractKind: null, + } as unknown as Booking); + expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + }); + + it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + const drawdown = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'ONE_TIME', + } as unknown as Booking); + expect(drawdown.inputCode).toBeNull(); + expect(drawdown.outputCode).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 1cc6503df..5a63beca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity'; type Op = 'import' | 'export'; type Freight = 'container' | 'bulk'; +/** + * The single (admin-configured) document set intercity shipments upload. + * DOMESTIC has no customs, so one shared set serves contracts and bookings: + * ONE_TIME collects it at contract level, GENERAL per booking — Operations + * reviews either way. + */ +export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; + /** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ function operationFor(tradeDirection: string): Op | null { if (tradeDirection === 'IMPORT') return 'import'; if (tradeDirection === 'EXPORT') return 'export'; - return null; // DOMESTIC / intercity — no clearance gate + return null; // DOMESTIC / intercity — no customs operation } function freightFor(freightType: string): Freight { @@ -26,6 +34,9 @@ export function clearanceSettingCode( freightType: string, includesCustoms: boolean, ): string | null { + // Intercity: no customs, but the admin-configured intercity document set is + // still collected and ops-reviewed before the shipment may board a train. + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); @@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); + // Intercity drawdowns under a ONE_TIME contract already cleared the intercity + // document set on the CONTRACT (post-signature); only GENERAL drawdowns and + // direct (contract-less) bookings carry the per-booking set. + if ( + booking.tradeDirection === 'DOMESTIC' && + booking.contractId && + booking.contractKind === 'ONE_TIME' + ) { + return { inputCode: null, outputCode: null, includesCustoms: false }; + } return { inputCode: clearanceSettingCode( booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index b61d0078d..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { + EDR_HAULAGE_CONFLICT_MESSAGE, + usesEdrMileService, +} from '../../common/mile-haulage.util'; +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + bookingContainerSizes, + remainingBulkTons, +} from '../../common/truck-load.util'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; @@ -67,39 +78,27 @@ export class CustomerTruckService { if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); + + // Bulk is capped by tonnage, not container count: trucks may be added until + // the booking's declared weight has been hauled away. Container bookings are + // capped below by #trucks <= #containers. + if (isBulk) { + const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); + assertBulkTonnageRemains(totalTons, remainingTons); } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); - // Never assign more trucks than the booking has containers. const existingTrucks = await this.dataSource .getRepository(CustomerTruckAssignment) .count({ where: { bookingId } }); - if (existingTrucks + 1 > bookingNumbers.length) { - throw new BadRequestException( - `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, - ); - } - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - // Size cap: a 40ft container fills the truck. - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length); + assertTruckLoad({ + containers: requested, + bookingContainers: bookingNumbers, + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbers(bookingId), + }); } await this.dataSource.transaction(async (manager) => { @@ -191,28 +190,13 @@ export class CustomerTruckService { if (requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); - } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - // Exclude THIS truck's own containers so re-saving the same set is allowed. - const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (assignedElsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { @@ -328,30 +312,24 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } - // Containers can only be loaded after the truck has physically arrived at the - // warehouse (arrival weighing recorded). Assignment alone is just planning. - if (!assignment.arrivedAt) { - throw new BadRequestException( - 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', - ); - } + // Loading a truck at the warehouse implies it is physically present, so a + // truck that is still only assigned (not yet marked arrived) is auto-arrived + // here rather than blocking the operator — the real gross is weighed on + // departure anyway. + const needsArrival = !assignment.arrivedAt; const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { throw new BadRequestException('Select at least one container to load onto the truck'); } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } + // Capacity is size-based: a truck carries at most 2 containers, and a 40ft + // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { @@ -371,9 +349,20 @@ export class CustomerTruckService { ); // Provisional gross (tonnes) from the loaded containers' VGM — overridden // by the weighed gross on departure. (Column is *_kg but holds tonnes.) + // Auto-stamp arrival if the truck was still only assigned. await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { grossWeightKg: grossTons, + ...(needsArrival ? { arrivedAt: new Date() } : {}), }); + if (needsArrival) { + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } }); return this.listTrucks(bookingId); } @@ -514,18 +503,11 @@ export class CustomerTruckService { } private assertSelfHaulPaid(booking: BookingGuardRow): void { - const hasFirstMile = Boolean(booking.firstMile?.trim()); - const hasLastMile = Boolean(booking.lastMile?.trim()); - const usesMileService = - booking.tradeDirection === 'IMPORT' - ? hasLastMile - : booking.tradeDirection === 'EXPORT' - ? hasFirstMile - : hasFirstMile || hasLastMile; - if (usesMileService) { - throw new BadRequestException( - 'Customer truck assignment is only allowed when first/last mile delivery is not selected', - ); + // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two + // halves of this rule cannot drift apart — they did, and a booking ended up + // with a customer truck and an EDR leg at once. + if (usesEdrMileService(booking)) { + throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { throw new BadRequestException( @@ -594,18 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ - private async containerSizes(bookingId: string, numbers: string[]): Promise { - if (!numbers.length) return []; - const rows: Array<{ size: string | null }> = await this.dataSource.query( - `SELECT bc.container_size AS "size" - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 - AND UPPER(bcu.container_number) = ANY($2) - AND bcu.deleted_at IS NULL`, - [bookingId, numbers], - ); - return rows.map((r) => (r.size ?? '').trim()); + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts index 11c80f687..1386e5bd4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -1,9 +1,11 @@ -import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; /** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ export class LoadCustomerTruckDto { @IsArray() @ArrayMinSize(1) + // A truck carries at most 2 containers (two 20ft, or one 40ft). + @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts deleted file mode 100644 index 68018e883..000000000 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity'; -import { Booking } from './booking.entity'; - -export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const; -export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number]; - -@Entity({ schema: 'freight', name: 'booking_approval_step' }) -@Index(['bookingId']) -@Index(['status']) -@Index(['bookingId', 'stepOrder']) -export class BookingApprovalStep extends BaseEntity { - @Column({ name: 'booking_id', type: 'uuid' }) - bookingId!: string; - - @ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'booking_id' }) - booking?: Booking; - - @Column({ name: 'approval_rule_id', type: 'uuid' }) - approvalRuleId!: string; - - @ManyToOne(() => ApprovalRule) - @JoinColumn({ name: 'approval_rule_id' }) - approvalRule?: ApprovalRule; - - @Column({ name: 'step_order', type: 'smallint' }) - stepOrder!: number; - - @Column({ name: 'required_role', type: 'varchar', length: 30 }) - requiredRole!: string; - - @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) - blocksRole?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) - status!: ApprovalStepStatus; - - @Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true }) - actionedByStaffId?: string | null; - - @Column({ name: 'actioned_at', type: 'timestamptz', nullable: true }) - actionedAt?: Date | null; - - @Column({ name: 'remarks', type: 'text', nullable: true }) - remarks?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** This container ships back empty after unloading (equipment return). */ + @Column({ name: 'is_return', type: 'boolean', default: false }) + isReturn!: boolean; + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index 182ff153d..24ec5db7c 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'reefer_quantity', type: 'smallint', default: 0 }) reeferQuantity!: number; + /** How many units of this line ship with empty-container return (≤ quantity). */ + @Column({ name: 'return_quantity', type: 'smallint', default: 0 }) + returnQuantity!: number; + @Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }) vgmPerUnitTons!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4cc15854e..821b9c9f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Train } from '../../trains/entities/train.entity'; import { FileRecord } from '../../files/entities/file.entity'; -import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingContainerAllocation } from './booking-container-allocation.entity'; @@ -86,6 +85,7 @@ export const SCHEDULING_STATUSES = [ SchedulingStatus.Eligible, SchedulingStatus.Scheduled, SchedulingStatus.Dispatched, + SchedulingStatus.WaitingForWagon, ] as const; export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; @@ -551,8 +551,6 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; - @OneToMany(() => BookingApprovalStep, (s) => s.booking) - approvalSteps?: BookingApprovalStep[]; @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 6eeaba963..3892d2a97 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) grossWeightKg?: number | null; + /** Empty truck weight at the gate, in tonnes. Null until the truck departs. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + /** + * Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown: + * a bulk booking is hauled until the sum of this across departed trucks + * reaches its declared VGM. Mirrors last_mile_vehicle_assignments. + */ + @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + netWeightTons?: number | null; + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index 7f3f06ec2..ac3adb7e8 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView() +@FleetView(FREIGHT_PERMS.cargoes.view) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.create) @ApiOperation({ summary: 'Create a new cargo' }) create(@Body() dto: CreateCargoDto) { return this.cargoesService.create(dto); @@ -43,35 +44,35 @@ export class CargoesController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Update a cargo' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { return this.cargoesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.delete) @ApiOperation({ summary: 'Delete a cargo' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.remove(id); } @Post(':id/load') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Load cargo into a container' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { return this.cargoesService.loadCargo(id, dto); } @Post(':id/unload') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Unload cargo from container' }) unload(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.unloadCargo(id); } @Post(':id/deliver') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Mark cargo as delivered' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { return this.cargoesService.deliverCargo(id, dto); diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts index 6f73035b4..54e47abaf 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -32,6 +32,7 @@ export class CargoesService { if (!container) { throw new NotFoundException(`Container ${dto.containerId} not found`); } + await this.assertContainerCapacity(container, dto.weight); if (dto.cargoTypeId) { const cargoType = await this.cargoTypeRepo.findOne({ @@ -121,6 +122,10 @@ export class CargoesService { throw new ConflictException('Cargo already loaded or delivered'); } + if (cargo.container) { + await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id); + } + cargo.status = 'LOADED'; cargo.loadedAt = new Date(); cargo.quantity = dto.quantity; @@ -137,13 +142,31 @@ export class CargoesService { } async unloadCargo(id: string): Promise { - const cargo = await this.findById(id); + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, + }); + if (!cargo) throw new NotFoundException('Cargo not found'); if (cargo.status !== 'LOADED') { throw new ConflictException('Cargo is not loaded'); } cargo.status = 'UNLOADED'; cargo.unloadedAt = new Date(); - return this.cargoRepo.save(cargo); + const saved = await this.cargoRepo.save(cargo); + + // loadCargo flips the container to LOADED; on unload, free it back to + // AVAILABLE once no other LOADED cargo still references the container. + if (cargo.containerId != null && cargo.container) { + const remaining = await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) }, + }); + if (remaining === 0) { + cargo.container.status = 'AVAILABLE'; + await this.containerRepo.save(cargo.container); + } + } + + return saved; } async deliverCargo(id: string, dto?: DeliverCargoDto): Promise { @@ -161,10 +184,13 @@ export class CargoesService { if (dto?.receiverName) cargo.receiverName = dto.receiverName; if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks; + // Exclude the cargo being delivered — it is still LOADED in the DB until the + // save below, so counting it would keep `remaining` > 0 and never free the + // container. const remaining = cargo.containerId != null ? await this.cargoRepo.count({ - where: { containerId: cargo.containerId, status: 'LOADED' }, + where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) }, }) : 0; if (remaining === 0 && cargo.container) { @@ -174,4 +200,34 @@ export class CargoesService { return this.cargoRepo.save(cargo); } + + /** + * Reject when placing `newWeightKg` on the container would exceed its max gross + * weight. All values are kilograms: cargoes.weight is kg (entity), and the + * container's tare_weight / max_gross_weight are kg (entity). Capacity check is + * tare + already-LOADED cargo + new cargo <= max gross weight. + */ + private async assertContainerCapacity( + container: Container, + newWeightKg: number, + excludeCargoId?: string, + ): Promise { + const qb = this.cargoRepo + .createQueryBuilder('c') + .select('COALESCE(SUM(c.weight), 0)', 'sum') + .where('c.containerId = :containerId', { containerId: container.id }) + .andWhere('c.status = :status', { status: 'LOADED' }); + if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId }); + const raw = await qb.getRawOne<{ sum: string }>(); + + const loadedKg = Number(raw?.sum ?? 0); + const tareKg = Number(container.tareWeight); + const maxGrossKg = Number(container.maxGrossWeight); + if (tareKg + loadedKg + newWeightKg > maxGrossKg) { + throw new BadRequestException( + `Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` + + `new ${newWeightKg}kg > max gross ${maxGrossKg}kg`, + ); + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8db9eba66..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -26,7 +35,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; -import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -48,6 +56,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -352,21 +378,6 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } - @Patch("active-mode") - @ApiOperation({ - summary: "Switch the current user's active operational mode (importer/exporter)", - }) - async setActiveMode( - @CurrentUser() user: CurrentIamUser, - @Body() dto: SetActiveModeDto, - ): Promise { - const { profile, company } = await this.companiesService.setActiveMode( - user.id, - dto.type, - ); - return new CompanyInfoResponseDto(profile, company); - } - @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) @@ -425,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -436,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -451,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -461,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -494,6 +555,9 @@ export class CompaniesController { mimeType: f.mimeType, size: f.size, uploadedAt: f.createdAt, + reviewStatus: f.reviewStatus, + reviewNote: f.reviewNote, + reviewedAt: f.reviewedAt, // Raw `f.url` is an un-signed MinIO path the browser can't open — sign // it so the file previews/downloads in the client. url: f.url ? await this.filesService.signUrl(f.url) : f.url, @@ -501,6 +565,35 @@ export class CompaniesController { ); } + @Post("documents/:fileId/request-change") + @BookingStaff(FREIGHT_PERMS.customers.verify) + @ApiOperation({ + summary: "Ask the customer to correct one uploaded document", + description: + "Flags a single document with a reason the customer sees, notifies them, " + + "and blocks role approval until they re-upload. Narrower than rejecting " + + "the whole role.", + }) + async requestDocumentChange( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + @Body() dto: RequestDocumentChangeDto, + ) { + const file = await this.companiesService.requestDocumentChange( + fileId, + dto.note, + user.id, + ); + return { + id: file.id, + name: file.name, + code: file.code, + reviewStatus: file.reviewStatus, + reviewNote: file.reviewNote, + reviewedAt: file.reviewedAt, + }; + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @@ -515,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -533,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -543,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -559,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -577,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -591,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -601,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,51 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + + /** + * A company waiting on a reviewer to decide an edit it submitted after being + * approved. These rows are `status = active`, so the pending-application filter + * can never surface them — the review queue needs its own predicate. + */ + private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = company.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; + + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +83,25 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + hasPendingChangeRequest, + sortBy = 'review', + sortOrder = 'DESC', + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +116,22 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + + if (hasPendingChangeRequest !== undefined) { + qb.andWhere( + hasPendingChangeRequest + ? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL + : `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -73,8 +148,22 @@ export class CompaniesRepository extends BaseRepository { ); } + // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy('company.name', 'ASC') + // Names are not unique and createdAt can tie on bulk imports; the id + // tiebreaker keeps paging stable instead of dropping/repeating rows. + .addOrderBy('company.id', 'ASC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); @@ -83,23 +172,44 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const pendingChanges = await this.repository + .createQueryBuilder('company') + .where('company.deleted_at IS NULL') + .andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL) + .getCount(); + + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, + pendingChanges, }; } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; +import { DataSource } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -98,6 +99,7 @@ export class CompaniesService { private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, + private readonly dataSource: DataSource, ) { } /** @@ -199,18 +201,6 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); - // Default active mode from the chosen role(s): importer wins when both are - // picked, otherwise the first allowed type chosen. - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - const chosenTypes = (dto.companyProfiles ?? []) - .map((p) => p.type) - .filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; - const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -218,7 +208,6 @@ export class CompaniesService { lastName: identity.lastName, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, - activeProfileType, onboardingStep: "company", }); @@ -291,11 +280,6 @@ export class CompaniesService { const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; const company = await this.companiesRepo.create({ name: identity.firstName @@ -314,7 +298,6 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); @@ -372,6 +355,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -745,7 +731,15 @@ export class CompaniesService { submittedAt: now, note: null, })) ?? existing; + this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { + // Rejecting a request leaves it Rejected rather than reopening it, so a + // customer amending after a rejection lands here with a fresh Pending row. + // That is the resubmission case the reviewer needs flagged. + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); request = await this.changeRequestRepo.create({ companyId: company.id, snapshot: fields, @@ -753,6 +747,11 @@ export class CompaniesService { submittedBy: userId, submittedAt: now, }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); } // Live company is unchanged; surface the pending state for the settings page. @@ -824,6 +823,12 @@ export class CompaniesService { "companies", files, ); + await this.resolveDocumentChangeRequests( + companyId, + "companies", + uploaded.map((f) => f.code), + uploaded.map((f) => f.id), + ); if (company.status === CompanyStatus.Active) { await this.stageDocumentChange( company.id, @@ -834,6 +839,95 @@ export class CompaniesService { return uploaded; } + /** + * Clear the `change_requested` flag from the documents a fresh upload replaces. + * + * Uploading does not overwrite the old row — it adds a new one under the same + * `code` — so the flagged original would otherwise linger and keep the approval + * gate closed even after the customer did exactly what was asked. Only rows of + * the same code are touched, and never the newly uploaded ones. + */ + private async resolveDocumentChangeRequests( + resourceId: string, + resource: string, + codes: string[], + uploadedIds: string[], + ): Promise { + if (codes.length === 0) return; + const replaced = new Set(codes); + const fresh = new Set(uploadedIds); + const open = await this.filesService.findWithOpenChangeRequest( + [resourceId], + resource, + ); + await Promise.all( + open + .filter((f) => replaced.has(f.code) && !fresh.has(f.id)) + .map((f) => this.filesService.clearReview(f.id)), + ); + } + + /** + * Backoffice: ask the customer to correct one specific document, instead of + * rejecting their whole role over it. Mirrors the contract change-request + * flow — a note the customer sees verbatim, plus a block on approval until + * they re-upload. + */ + async requestDocumentChange( + fileId: string, + note: string, + reviewerId?: string, + ): Promise { + const file = await this.filesService.findById(fileId); + const companyId = await this.resolveDocumentCompanyId(file); + const company = await this.findCompanyById(companyId); + + // Flag the document while holding a write lock on its company row. The + // approval gate takes the same lock before it reads the flags, so the two + // serialize: a change request can never land in the window between the gate + // checking "any open corrections?" and writing the profile Active. + const updated = await this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: companyId }, + lock: { mode: "pessimistic_write" }, + }); + return this.filesService.setReviewStatus( + file.id, + "change_requested", + note, + reviewerId, + ); + }); + this.companyNotifier.documentChangeRequested( + company, + file.name, + note, + file.id, + ); + return updated; + } + + /** + * Which company a stored document belongs to. Company documents are keyed by + * the company id directly; profile licences and POA letters hang off a company + * profile, so those resolve through it. + */ + private async resolveDocumentCompanyId(file: FileRecord): Promise { + if (file.resource === "companies") return file.resourceId; + if (file.resource === "company_profiles") { + const profile = await this.companyProfilesRepo.findById(file.resourceId); + if (!profile) { + throw new NotFoundException( + `Company profile ${file.resourceId} not found`, + ); + } + return profile.companyId; + } + throw new BadRequestException( + `Documents on "${file.resource}" do not support change requests`, + ); + } + /** Open or append a pending change request recording staged document uploads. */ private async stageDocumentChange( companyId: string, @@ -844,6 +938,7 @@ export class CompaniesService { const now = new Date(); const existing = await this.changeRequestRepo.findPendingByCompanyId(companyId); + const company = await this.companiesRepo.findById(companyId); if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { @@ -857,8 +952,15 @@ export class CompaniesService { submittedAt: now, note: null, }); + if (company) { + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + } } else { - await this.changeRequestRepo.create({ + const history = await this.changeRequestRepo.findByCompanyId(companyId); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const created = await this.changeRequestRepo.create({ companyId, snapshot: {}, documents: { documentFileIds: fileIds }, @@ -866,6 +968,13 @@ export class CompaniesService { submittedBy: submittedBy ?? null, submittedAt: now, }); + if (company) { + this.companyNotifier.changeRequestSubmitted( + company, + created.id, + resubmitted, + ); + } } } @@ -962,6 +1071,100 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + + // Anything other than approval has no document gate and no concurrency + // hazard — apply it directly. + if (status !== ProfileStatus.Active) { + return this.applyProfileStatus(existing, status, note, reviewerId); + } + + // Approving over an outstanding document correction would silently accept the + // very document a reviewer just rejected, and would strand the customer's + // "please fix this" banner with nothing left to fix. The gate check and the + // status write share a write lock on the company row — `requestDocumentChange` + // takes the same lock, so a fresh correction can never land in the window + // between "any open corrections?" and the profile going Active. Suspend and + // blacklist skip all this — staff must always be able to act against a bad + // account. + return this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: existing.companyId }, + lock: { mode: "pessimistic_write" }, + }); + + const [companyDocs, profileDocs] = await Promise.all([ + this.filesService.findWithOpenChangeRequest( + [existing.companyId], + "companies", + ), + this.filesService.findWithOpenChangeRequest( + [existing.id], + "company_profiles", + ), + ]); + const pending = [...companyDocs, ...profileDocs]; + if (pending.length > 0) { + const names = pending.map((f) => f.name).join(", "); + throw new BadRequestException( + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + + `Approve it once the customer has re-uploaded them, or withdraw the change request first.`, + ); + } + + return this.applyProfileStatus(existing, status, note, reviewerId); + }); + } + + /** + * Write a reviewed profile status (reference minting, note handling, reviewer + * stamp) and promote the company if this is its first approved role. Split out + * of `setCompanyProfileStatus` so the approval path can run it inside the gate + * transaction while every other status skips that overhead. + */ + private async applyProfileStatus( + existing: CompanyProfile, + status: ProfileStatus, + note?: string, + reviewerId?: string, + ): Promise { // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -971,9 +1174,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -983,27 +1190,54 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(profileId, patch); + const updated = await this.companyProfilesRepo.update(existing.id, patch); if (!updated) - throw new NotFoundException(`Company profile ${profileId} not found`); + throw new NotFoundException(`Company profile ${existing.id} not found`); - // Approving any profile promotes a pending company to active, so the - // customer can start working as soon as their first profile is cleared. - if (status === ProfileStatus.Active) { + // Every reviewed transition that changes what the customer can do is told + // to them, carrying the staff message so they know why. Approval has no + // message (the note is cleared); the others require one. + const change = + status === ProfileStatus.Suspended + ? "suspended" + : status === ProfileStatus.Rejected + ? "rejected" + : status === ProfileStatus.Active + ? existing.status === ProfileStatus.Suspended + ? "reactivated" + : "approved" + : null; + if (change) { const company = await this.companiesRepo.findById(updated.companyId); - if (company && company.status === CompanyStatus.Pending) { - await this.companiesRepo.update(updated.companyId, { - status: CompanyStatus.Active, - }); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + change, + note ?? "", + ); + // The first approved role promotes a pending company to active — a + // bigger event (the account itself goes live), so tell them that too. + if ( + status === ProfileStatus.Active && + company.status === CompanyStatus.Pending + ) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + this.companyNotifier.companyApproved(company); + } } } return updated; } /** - * Customer reapplies for a rejected operational role (after fixing whatever the - * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and - * clear the rejection note so it re-enters the approval queue. + * Customer reapplies for a rejected or suspended operational role (after + * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it + * back to Pending and clear the review note so it re-enters the approval + * queue. Suspension is a staff lockout, so resubmitting is an appeal — the + * backoffice still has to approve before the role goes live again. */ async reapplyCompanyProfile( userId: string, @@ -1018,9 +1252,12 @@ export class CompaniesService { if (!target || target.companyId !== companyId) { throw new NotFoundException(`Company profile ${profileId} not found`); } - if (target.status !== ProfileStatus.Rejected) { + if ( + target.status !== ProfileStatus.Rejected && + target.status !== ProfileStatus.Suspended + ) { throw new BadRequestException( - "Only a rejected role can be resubmitted for approval", + "Only a rejected or suspended role can be resubmitted for approval", ); } @@ -1032,6 +1269,13 @@ export class CompaniesService { }); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // The role is back in the pending queue — tell the reviewers, otherwise the + // resubmission is invisible until someone happens to reopen the customer. + const company = await this.companiesRepo.findById(companyId); + if (company) { + this.companyNotifier.roleReapplied(company, updated.id, updated.type); + } return updated; } @@ -1137,10 +1381,9 @@ export class CompaniesService { /** * Create a single operational profile for the current user's company. The new - * role starts Pending, so it deliberately does NOT become the active mode: - * switching onto an unapproved profile would strip the user of `canBook` and - * block them from creating contracts under the role they already had approved. - * Callers switch explicitly via {@link setActiveMode} once the role is Active. + * role starts Pending and carries no reference until a backoffice reviewer + * approves it; a booking/contract resolves its profile from the trade + * direction at creation time, so no "active mode" is stored. */ async createCompanyProfileForUser( userId: string, @@ -1175,40 +1418,6 @@ export class CompaniesService { return created; } - /** - * Switch the user's active operational mode. The target profile must already - * exist — clients create it first via createCompanyProfileForUser. - */ - async setActiveMode( - userId: string, - type: ProfileType, - ): Promise<{ profile: ExternalProfile; company: Company }> { - const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) - throw new NotFoundException(`Profile for user ${userId} not found`); - - const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - if (!allowedTypes.includes(type)) { - throw new BadRequestException( - `Profile type "${type}" is not allowed for company type "${company.type}"`, - ); - } - - const existing = await this.companyProfilesRepo.findByType(companyId, type); - if (!existing) { - throw new ConflictException( - `No ${type} profile exists yet — create it before switching`, - ); - } - - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - - return this.getCompanyInfoByUserId(userId); - } - async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -1399,21 +1608,68 @@ export class CompaniesService { } /** - * Block a customer from booking under a profile that isn't approved yet. - * Called from the booking-create path for self-service bookings; staff- and - * government-initiated bookings bypass this. No-op when the profile can't be - * found (defensive — resolution is best-effort upstream). + * Block a self-service action when the company account isn't active, naming + * the actual status — a suspended customer told "awaiting approval" has no + * idea what happened or who to call. + */ + assertCompanyActiveFor(company: Company, action: string): void { + if (company.status === CompanyStatus.Active) return; + switch (company.status) { + case CompanyStatus.Suspended: + throw new ForbiddenException( + `Your company account is suspended — you can't create ${action} right now. ` + + `Please contact EDR support for details.`, + ); + case CompanyStatus.Blacklisted: + throw new ForbiddenException( + `Your company account is blacklisted — you can't create ${action}. ` + + `Please contact EDR support.`, + ); + default: + throw new ForbiddenException( + `Your company is awaiting approval — you can't create ${action} yet.`, + ); + } + } + + /** + * Block a customer from booking under a profile that isn't approved yet — or + * that a reviewer has since suspended. Called from the booking/contract + * create path for self-service actions; staff- and government-initiated ones + * bypass this. No-op when the profile can't be found (defensive — resolution + * is best-effort upstream). The message names the profile's real status: + * suspension in particular is per-role, so the customer must learn which + * operation is blocked (their other roles still work). */ async assertCompanyProfileApprovedForBooking( companyProfileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(companyProfileId); if (!profile) return; - if (profile.status !== ProfileStatus.Active) { - const role = profile.type.replace(/_/g, " "); - throw new ForbiddenException( - `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, - ); + if (profile.status === ProfileStatus.Active) return; + + const role = profile.type.replace(/_/g, " "); + switch (profile.status) { + case ProfileStatus.Suspended: + throw new ForbiddenException( + `Your ${role} role is suspended${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Your other roles are unaffected. Please contact EDR support to resolve this.`, + ); + case ProfileStatus.Blacklisted: + throw new ForbiddenException( + `Your ${role} role is blacklisted. Please contact EDR support.`, + ); + case ProfileStatus.Rejected: + throw new ForbiddenException( + `Your ${role} role was rejected${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Amend and resubmit it from your settings page.`, + ); + default: + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`, + ); } } @@ -1487,6 +1743,15 @@ export class CompaniesService { ); } + // A fresh licence upload answers any correction the reviewer asked for on the + // previous one, so the old row must stop blocking approval. + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + uploaded.map((r) => r.id), + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1568,6 +1833,13 @@ export class CompaniesService { await this.filesService.remove(fileId); } + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + [created.id], + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1664,6 +1936,8 @@ export class CompaniesService { : pendingRemoveIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -1830,6 +2104,13 @@ export class CompaniesService { for (const r of live) await this.filesService.remove(r.id); } + await this.resolveDocumentChangeRequests( + company.id, + COMPANY_RESOURCE, + [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE], + [created.id], + ); + return this.getPoaDelegationView(company.id); } @@ -1907,6 +2188,8 @@ export class CompaniesService { : removeIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -2005,15 +2288,14 @@ export class CompaniesService { /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → - * exporter profile; for DOMESTIC or a forwarder/single-profile company (or - * when the natural profile doesn't exist) it falls back to the user's active - * profile, then the company's first profile. Returns null when the company - * has no profiles at all. + * exporter profile; for DOMESTIC (or when the natural profile doesn't exist, + * e.g. a freight forwarder) it falls back to the company's first profile. + * Callers that need a specific role (a forwarder) pass an explicit + * companyProfileId instead. Returns null when the company has no profiles. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, - fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; @@ -2025,30 +2307,12 @@ export class CompaniesService { ? ProfileType.exporter : null; - const byType = (type?: ProfileType | null) => - type ? profiles.find((p) => p.type === type) : undefined; - - const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + const match = + (naturalType && profiles.find((p) => p.type === naturalType)) ?? + profiles[0]; return match?.id ?? null; } - /** - * Resolve the company_profile a customer's data should be scoped to, from - * their persisted active mode. Returns null when nothing can be resolved - * (not onboarded yet) so callers can fall back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - try { - const { profile, company } = await this.getCompanyInfoByUserId(userId); - const type = profile.activeProfileType; - if (!type) return null; - const match = company.companyProfiles?.find((p) => p.type === type); - return match?.id ?? null; - } catch { - return null; - } - } - async fetchETradeData(tin: string) { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 167526988..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { @@ -54,23 +59,106 @@ export class CompanyNotifierService { } } + /** SMS + email + in-app account-status item to the company contact. */ + private notifyAccount( + company: Company, + title: string, + body: string, + link = "/settings", + ): void { + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link, + data: { companyId: company.id, status: company.status }, + priority: NotificationPriority.HIGH, + }); + } + /** - * Tell the customer their account was suspended or blacklisted. Called only on - * a real transition into one of those statuses; other status writes are silent. + * Tell the customer their account changed status. Fires on the transitions + * that change what they can do: suspended/blacklisted (locked out) and + * reactivated (back to Active from a lockout). Silent otherwise. */ statusChanged(company: Company, previous: CompanyStatus): void { const status = company.status; if (status === previous) return; + + if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) { + this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`); + this.notifyAccount( + company, + "Account reactivated", + "Your company account has been reactivated. " + + "You can submit new contracts and bookings again.", + ); + return; + } + if (!PUNITIVE_STATUSES.includes(status)) return; const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; - const title = `Account ${label}`; - const body = - `Your company account has been ${label}. ` + - `You will not be able to submit new contracts or bookings. ` + - `Please contact EDR support for assistance.`; - this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + this.notifyAccount( + company, + `Account ${label}`, + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`, + ); + } + + /** + * Tell the customer their company account was approved and is now live — the + * first operational role clearing review promotes a pending company to Active. + */ + companyApproved(company: Company): void { + this.logger.log(`ACCOUNT_APPROVED — ${company.id}`); + this.notifyAccount( + company, + "Account approved", + "Your company account has been approved and is now active. " + + "You can start submitting bookings and contracts.", + "/dashboard", + ); + } + + /** + * Tell the customer one of their operational roles changed review status — + * approved, rejected, suspended, or reactivated — quoting the staff message + * when one was given (rejection/suspension/reactivation require one; approval + * carries none). + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "approved" | "rejected" | "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence: Record = { + approved: "You can now operate under this role.", + rejected: + "You will not be able to operate under this role. Amend the required " + + "documents and resubmit it for approval from your settings page.", + suspended: + "You will not be able to operate under this role until it is " + + "reactivated; your other roles are unaffected.", + reactivated: "You can operate under this role again.", + }; + const message = staffMessage.trim(); + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence[change]}` + + (message ? ` Message from EDR staff: ${message}` : ""); + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); void this.notifyContact(company, `${title}. ${body}`); void this.inbox.notify({ recipients: { companyId: company.id }, @@ -79,7 +167,109 @@ export class CompanyNotifierService { title, body, link: "/settings", - data: { companyId: company.id, status }, + data: { companyId: company.id, profileType, change, staffMessage: message }, + priority: NotificationPriority.HIGH, + }); + } + + // ── Backoffice-facing: work has arrived back in the review queue ──────────── + + /** + * Persist + push an in-app item to every backoffice staff user, deep-linked to + * the customer's detail page. + * + * The recipient resolver has no role/permission targeting (see + * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest + * selector available, so marketing is reached by notifying all staff. + */ + private notifyStaff( + company: Company, + title: string, + body: string, + data: Record = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/customers/${company.id}`, + data: { companyId: company.id, companyName: company.name, ...data }, + }); + } + + /** + * A customer resubmitted an operational role after it was rejected for + * adjustment. Without this the role silently flips back to Pending and nobody + * is told there is anything to look at again. + */ + roleReapplied(company: Company, profileId: string, profileType: string): void { + this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`); + this.notifyStaff( + company, + "Customer resubmitted a role for approval", + `${company.name} has adjusted and resubmitted its ${profileType} role. ` + + `It is back in the pending approval queue for review.`, + { profileId, profileType }, + ); + } + + /** + * A customer submitted (or amended and resubmitted) a profile change request. + * `resubmitted` distinguishes the two so the reviewer knows this is a second + * look at something they already sent back. + */ + changeRequestSubmitted( + company: Company, + changeRequestId: string, + resubmitted: boolean, + ): void { + this.logger.log( + `CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`, + ); + this.notifyStaff( + company, + resubmitted + ? "Customer resubmitted profile changes" + : "Customer submitted profile changes", + resubmitted + ? `${company.name} has adjusted the changes you sent back and resubmitted ` + + `them. They are pending your review.` + : `${company.name} has submitted profile changes that are pending review.`, + { changeRequestId }, + ); + } + + // ── Customer-facing: a specific document needs correcting ────────────────── + + /** + * Tell the customer a reviewer wants one specific document corrected. Mirrors + * the contract `changesRequested` flow: SMS + email out, plus an in-app item + * deep-linked to the documents tab where they can re-upload. + */ + documentChangeRequested( + company: Company, + documentName: string, + note: string, + fileId: string, + ): void { + const title = "Document change requested"; + const body = + `A reviewer has asked you to correct "${documentName}". ` + + `Reason: ${note} ` + + `Please upload a corrected version from your settings page.`; + + this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title, + body, + link: "/settings", + data: { companyId: company.id, fileId, documentName }, priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -24,7 +24,7 @@ export class CompanyInfoResponseDto { company: Company, changeRequest?: CompanyChangeRequest | null, ) { - this.profile = new ResponseExternalProfileDto(profile, company); + this.profile = new ResponseExternalProfileDto(profile); this.company = new ResponseCompanyDto(company); const open = diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..e97a21266 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,16 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; + /** + * Approved customers with an open profile change request. Counted separately + * because they are `active` and so are invisible to the `pending` KPI, even + * though they are just as much waiting on a reviewer. + */ + pendingChanges!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index eb32f72ae..7816572ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() - @MaxLength(10) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index a56ea5ad8..be911b3ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CreateCompanyDto { @IsString() @@ -17,7 +18,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 2eb37c92d..466c03ed6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,8 +1,9 @@ -import { IsString, IsNotEmpty, Length } from "class-validator"; +import { IsString, IsNotEmpty } from "class-validator"; +import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,44 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; + + @ApiPropertyOptional({ + description: + "`true` = only companies with an open (pending) profile change request. " + + "These are already-approved customers, so they never appear under " + + "`status=pending` and would otherwise be invisible in the review queue.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + hasPendingChangeRequest?: boolean; + + @ApiPropertyOptional({ + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", + }) + @IsOptional() + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts new file mode 100644 index 000000000..8119e041d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RequestDocumentChangeDto { + /** What is wrong with this document — shown verbatim to the customer. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,8 +1,6 @@ -import { Company } from '../entities/company.entity'; import { ExternalProfile, } from '../entities/external-profile.entity'; -import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -13,20 +11,12 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType?: ProfileType | null; - /** - * The id of the company_profile matching activeProfileType, resolved - * server-side so the client never re-derives it. Null until a company - * (with profiles) is loaded and a matching profile exists. - */ - activeCompanyProfileId?: string | null; onboardingStep?: string | null; onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile, company?: Company) { + constructor(profile: ExternalProfile) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -35,13 +25,8 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; - this.activeProfileType = profile.activeProfileType ?? null; this.onboardingStep = profile.onboardingStep ?? null; this.onboardingCompleted = profile.onboardingCompleted ?? false; - this.activeCompanyProfileId = - company?.companyProfiles?.find( - (p) => p.type === profile.activeProfileType, - )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9fd8f28ae..ba3e27aeb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,6 +1,8 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class UpdateProfileDto { @IsOptional() @@ -34,7 +36,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() @@ -137,10 +139,14 @@ export class UpdateProfileDto { @MaxLength(50) renewedTo?: string; + // Zone/woreda/kebele below stay free text: there is no authoritative dataset + // of Ethiopian zones/woredas/kebeles in the platform yet, and eTrade returns + // them uncoded. Only region is a closed set today. @IsOptional() - @IsString() - @MaxLength(100) - region?: string; + @IsIn(ETHIOPIAN_REGIONS as unknown as string[], { + message: "region must be a recognised Ethiopian region", + }) + region?: EthiopianRegion; @IsOptional() @IsString() 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 ebda7a0b9..9bba9396e 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 @@ -37,8 +37,20 @@ export interface BusinessLicenseFile { */ export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; +/** + * Reviewer verdict on a document, as surfaced to clients. Distinct from + * {@link StagedFileStatus}: that describes where the file sits in the staged + * add/remove workflow, this describes whether a reviewer wants it corrected. + */ +export interface FileReviewView { + /** `change_requested` while the customer still owes a corrected upload. */ + reviewStatus?: "change_requested" | "approved" | null; + /** The reviewer's reason, shown verbatim to the customer. */ + reviewNote?: string | null; +} + /** A business-license file plus its change-review state, surfaced to clients. */ -export interface ProfileLicenseFileView { +export interface ProfileLicenseFileView extends FileReviewView { id: string; name: string; size: number; @@ -47,7 +59,7 @@ export interface ProfileLicenseFileView { } /** A company-level document (e.g. the PoA letter) with its change-review state. */ -export interface CompanyDocumentFileView { +export interface CompanyDocumentFileView extends FileReviewView { id: string; name: string; size: number; diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,7 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; -import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; - /** - * The operational profile the user is currently "in" (importer vs exporter, - * or the single forwarder profile). Drives header switching and scopes the - * customer's bookings / dashboard to that company_profile. Nullable for - * users who haven't picked a role yet. - */ - @Column({ - name: 'active_profile_type', - type: 'varchar', - length: 32, - nullable: true, - enum: ProfileType, - }) - activeProfileType?: ProfileType | null; - /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ @Column({ name: 'onboarding_step', diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index b588c241f..51bdb2df6 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -6,6 +6,7 @@ import { ETradeCompanyInfo, ETradeBusinessInfo, CompanyRegistrationData, + normalizeRegion, } from "@edr/types"; @Injectable() @@ -108,7 +109,11 @@ export class ETradeService { renewedFrom: businessInfo.RenewedFrom, renewalDate: businessInfo.RenewalDate, renewedTo: businessInfo.RenewedTo, - region: businessInfo.AddressInfo?.Region || "", + // eTrade returns uncoded uppercase text and sometimes a zone name in the + // Region slot. Map it onto the canonical list; an unresolved value yields + // "" so the form asks the user to pick rather than failing validation on + // save with a value they never typed. + region: normalizeRegion(businessInfo.AddressInfo?.Region) ?? "", zone: businessInfo.AddressInfo?.Zone || "", woreda: businessInfo.AddressInfo?.Woreda || "", kebele: businessInfo.AddressInfo?.Kebele || "", diff --git a/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts new file mode 100644 index 000000000..910b3199a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/region-normalization.spec.ts @@ -0,0 +1,54 @@ +import { ETHIOPIAN_REGIONS, normalizeRegion } from '@edr/types'; + +/** + * normalizeRegion lives in @edr/types (no jest there), but it exists to keep + * eTrade autofill from feeding UpdateProfileDto a region its @IsIn will reject. + * That contract is an API concern, so it is guarded here. + */ +describe('normalizeRegion', () => { + it('passes through every canonical region unchanged', () => { + for (const region of ETHIOPIAN_REGIONS) { + expect(normalizeRegion(region)).toBe(region); + } + }); + + it.each([ + ['ADDIS ABABA', 'Addis Ababa'], + ['Addis ababa', 'Addis Ababa'], + [' addis ababa ', 'Addis Ababa'], + ['oromoia', 'Oromia'], + ['OROMIYA', 'Oromia'], + ['gambella', 'Gambela'], + ['TIGRAI', 'Tigray'], + ['benishangul gumuz', 'Benishangul-Gumuz'], + ])('resolves the variant %s', (input, expected) => { + expect(normalizeRegion(input)).toBe(expected); + }); + + it('maps a zone name in the region slot back to its parent region', () => { + // eTrade's own placeholder data does this — "EASTERN TIGRAY" is a zone. + expect(normalizeRegion('EASTERN TIGRAY')).toBe('Tigray'); + expect(normalizeRegion('North Wollo')).toBe('Amhara'); + }); + + it.each([ + ['a city, not a region', 'Arba Minch'], + ['unknown text', 'Nowhere Land'], + ['empty', ''], + ['whitespace only', ' '], + ['null', null], + ['undefined', undefined], + ])('returns null for %s rather than guessing', (_label, input) => { + expect(normalizeRegion(input as string | null | undefined)).toBeNull(); + }); + + it('never returns a value outside the canonical set', () => { + const samples = ['ADDIS ABABA', 'oromoia', 'EASTERN TIGRAY', 'garbage', '']; + for (const s of samples) { + const out = normalizeRegion(s); + if (out !== null) { + expect(ETHIOPIAN_REGIONS).toContain(out); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index b107e8935..579b9ee26 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -10,18 +10,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView() +@FleetView(FREIGHT_PERMS.consignments.view) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.consignments.create) @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 1a0cdb14f..0a5e6bb0f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,12 +19,12 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView() +@FleetView(FREIGHT_PERMS.containers.view) export class ContainersController { constructor(private readonly containersService: ContainersService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.create) @ApiOperation({ summary: 'Create a new container' }) create(@Body() dto: CreateContainerDto) { return this.containersService.create(dto); @@ -42,28 +43,28 @@ export class ContainersController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Update a container' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { return this.containersService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.delete) @ApiOperation({ summary: 'Delete a container' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.remove(id); } @Post(':id/assign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Assign container to a wagon' }) assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { return this.containersService.assignToWagon(id, dto); } @Post(':id/unassign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Unassign container from wagon' }) unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.unassignFromWagon(id); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts index bf7c966aa..2f9d984e6 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -1,7 +1,7 @@ // apps/edr-freight-api/src/modules/container-management/containers.service.ts import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,6 +18,7 @@ export class ContainersService { private readonly wagonRepo: Repository, // ✅ use raw repository @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, + private readonly dataSource: DataSource, ) {} async create(dto: CreateContainerDto): Promise { @@ -115,24 +116,42 @@ export class ContainersService { if (container.status === 'LOADED') { throw new ConflictException('Cannot reassign a loaded container'); } + // Reject a container that is already placed on a wagon — it must be + // unassigned first, otherwise it would silently jump to another wagon. + if (container.wagonId) { + throw new ConflictException( + `Container ${containerId} is already assigned to wagon ${container.wagonId}`, + ); + } const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); - let position: number | null = dto.position ?? null; - if (position === null) { - const maxPos = await this.containerRepo - .createQueryBuilder('c') - .select('MAX(c.position)', 'max') - .where('c.wagonId = :wagonId', { wagonId: wagon.id }) - .getRawOne(); - position = (maxPos?.max ?? 0) + 1; - } + // The MAX(position)+1 allocation is check-then-act: two concurrent assigns can + // read the same MAX and collide on the same position. Do the read + save inside + // one transaction to narrow the race window. + // TODO: add a unique (wagon_id, position) DB index so the database itself + // rejects a colliding position even under concurrency. + return this.dataSource.transaction(async (manager) => { + const containerRepo = manager.getRepository(Container); - container.wagonId = wagon.id; - container.position = position; - container.status = 'AVAILABLE'; - return this.containerRepo.save(container); + let position: number | null = dto.position ?? null; + if (position === null) { + const maxPos = await containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne<{ max: number | null }>(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; + // Placing a container on a wagon does not make it AVAILABLE. The status enum + // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON + // state, so leave the existing status unchanged rather than forcing AVAILABLE. + return containerRepo.save(container); + }); } async unassignFromWagon(containerId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2aea9bc7..d2755fece 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { RateSchedule } from "../../contracts/contract-rate-schedule.builder"; import { getTemplateMeta } from "../../contracts/contract-template.registry"; import { ContractDynamicTemplateView, @@ -177,17 +178,9 @@ export class ContractTemplatesService { const isBulk = code.endsWith("_BULK"); const now = new Date(); - const unitRates = isBulk - ? [ - { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, - { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, - { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, - ] - : [ - { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, - { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, - { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, - ]; + // Representative rate schedule so the admin preview shows the live-rate + // table shape. Real contracts populate this from freight.rates (LIVE). + const rateSchedule = this.mockRateSchedule(code, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -239,13 +232,16 @@ export class ContractTemplatesService { lastMileDeliveryAddress: "—", }, pricing: { - displayMode: "UNIT_RATES", - unitRates, + lineItems: [], + surcharges: [], + totalAmount: 0, currency: "USD", equipmentReturn: isBulk ? "—" : "With empty return", originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", + containerLines: [], } as unknown as ContractViewModel["pricing"], + rateSchedule, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -256,6 +252,43 @@ export class ContractTemplatesService { }; } + /** Static, representative rate schedule for the admin preview only. */ + private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + const dir = code.startsWith("IMPORT") + ? "import" + : code.startsWith("EXPORT") + ? "export" + : "domestic"; + const lane = + dir === "export" + ? "Galaan Multipurpose Port → SGTD" + : dir === "domestic" + ? "Mojo Dry Port → Dire Dawa" + : "Negad → Mojo Dry Port"; + + const freightLanes = isBulk + ? [ + { route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" }, + ] + : [ + { route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" }, + { route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" }, + ]; + + return { + freightLanes, + additionalServices: [ + { route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + { route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + ], + surcharges: [ + { route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" }, + ], + isEmpty: false, + currencyLabel: "USD", + }; + } + private assertCode(code: string): ContractTemplateCode { const upper = code?.toUpperCase() as ContractTemplateCode; if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 59dad2248..810232993 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; @@ -85,6 +88,8 @@ export interface BookingClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -282,6 +287,12 @@ export class BookingClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are + // the current one; this is the trail behind it. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts index eb77533ac..4ad6dab75 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => { expect(saved.status).toBe('COMPLETED'); expect(saved.metadata?.riskLevel).toBe('YELLOW'); }); + + /** + * The level is customer-visible and stays correctable until duty is advised, + * so a changed level must leave a trail rather than overwrite the last one. + */ + describe('risk history', () => { + it('records the first assignment with no previous level', async () => { + const { service } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedByUserId: 'user-1', + assignedBy: 'Abebe K.', + note: 'initial rating', + }); + expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel'); + }); + + it('keeps the earlier decision when the level is reassigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.'); + const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.'); + + expect(saved.metadata?.riskLevel).toBe('GREEN'); + expect(saved.metadata?.riskHistory).toHaveLength(2); + // The original RED decision survives, with who made it. + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedBy: 'Abebe K.', + }); + expect(saved.metadata?.riskHistory?.[1]).toMatchObject({ + level: 'GREEN', + previousLevel: 'RED', + assignedByUserId: 'user-2', + assignedBy: 'Sara M.', + note: 'downgraded', + }); + }); + + it('keeps the whole chain across several reassignments, oldest first', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + await service.assignRisk('b-1', 'YELLOW'); + const saved = await service.assignRisk('b-1', 'RED'); + + expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([ + 'GREEN', + 'YELLOW', + 'RED', + ]); + }); + + it('does not record a repeat of the level already assigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + const saved = await service.assignRisk('b-1', 'GREEN'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + }); + + it('always leaves riskLevel equal to the last history entry', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED'); + const saved = await service.assignRisk('b-1', 'YELLOW'); + + const history = saved.metadata?.riskHistory ?? []; + expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index ed3597d57..b6d57263a 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -210,15 +210,50 @@ export class ClearanceMilestoneService { * Customs cannot risk-rate cargo still moving under transit: the T1 must be * closed (accepted by GL Ethiopia after the train arrives) first, which is the * catalog order T1_CLOSED → RISK_ASSIGNED. + * + * The level stays correctable until duty is advised off it, so each assignment + * is appended to `riskHistory` instead of silently replacing the last one — a + * customer-visible level that changes needs a trail of who changed it and when. */ async assignRisk( bookingId: string, riskLevel: CustomsRiskLevel, userId?: string, note?: string, + actor?: string, ): Promise { await this.assertT1Closed(bookingId); - return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); + + const existing = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'RISK_ASSIGNED' }, + }); + const previousLevel = existing?.metadata?.riskLevel; + const history = existing?.metadata?.riskHistory ?? []; + + // A repeat of the level already assigned is not a decision — recording it + // would pad the trail with entries that changed nothing. + const entries = + previousLevel === riskLevel + ? history + : [ + ...history, + { + level: riskLevel, + ...(previousLevel ? { previousLevel } : {}), + assignedAt: new Date().toISOString(), + assignedByUserId: userId ?? null, + assignedBy: actor ?? null, + note: note ?? null, + }, + ]; + + return this.completeWithMetadata( + bookingId, + 'RISK_ASSIGNED', + { riskLevel, riskHistory: entries }, + userId, + note, + ); } /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 7dc676398..563f056d2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // milestoneService {} as never, // workflowService {} as never, // invoiceService + { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index c2e3a107b..bb74f062c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, + { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f055a0c15..8e102e5a1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; @@ -25,6 +26,8 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -37,7 +40,10 @@ import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingContainerLineDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; @@ -52,6 +58,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -78,6 +96,7 @@ export class ContractBookingService { private readonly milestoneService: ClearanceMilestoneService, private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, @@ -177,11 +196,12 @@ export class ContractBookingService { // GENERAL without customs (Path A) ALSO clears per booking: the customer // uploads his own clearance proof on each booking and Operations reviews it // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → - // requestOperation machine). DOMESTIC has no border, so no gate. + // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same + // per-booking gate with the intercity document set — ops finalize then puts + // the booking straight into the ride-along pool (FULLY_EXECUTED), since + // intercity has no shipment-day request step. const generalSelfClear = - contract.contractKind === 'GENERAL' && - !contract.customsClearingEnabled && - contract.tradeDirection !== 'DOMESTIC'; + contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at @@ -261,14 +281,15 @@ export class ContractBookingService { contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, - equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN', + equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + cargoFreeText: dto.cargoFreeText?.trim() || null, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, @@ -279,48 +300,63 @@ export class ContractBookingService { } as never), ); - // Persist container lines + per-unit container numbers (container freight only). - if (freightType === 'CONTAINER') { - await this.persistContainers(booking.id, contract, dto); - } - - // Reload with containers to compute the total from contract unit rates × qty. - const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); - if (loaded) { + // Everything between the insert and the priced update must be all-or-nothing: + // a throw part-way (container persist, weight rules, pricing) would otherwise + // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that + // occupies the one-time contract's single active-booking slot until the + // doc-review sweep expires it — and the clearance cycle still points at the + // previous booking, so the hub keeps offering "Rebook" against a dead draft. + try { + // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { - await this.applyWeightResults(loaded); + await this.persistContainers(booking.id, contract, dto); } - const computed = await this.bookingPricingService.computePriceForBooking(loaded); - // Reject a zero-price booking outright. A total of 0 means no contract rate - // matched the route/container (or the rate is unset), so the booking is not - // valid to ship or invoice. Roll back the just-inserted row + its lines so it - // does NOT occupy the one-time contract's single active-booking slot — else - // the customer's retry hits "already has an active booking" against a broken - // draft. The customer must fix the contract's rates, then rebook. - if (!(computed.totalAmount > 0)) { - await this.bookingsRepository.deleteContainers(booking.id); - await this.bookingsRepository.hardDelete(booking.id); - throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', - ); - } - await this.bookingsRepository.update(booking.id, { - totalAmount: computed.totalAmount, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems: computed.lineItems, + + // Reload with containers to compute the total from contract unit rates × qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has + // a positive total, so the zero-price gate below misses it — enforce + // the pricing hard blocks first. The catch below rolls everything back. + if (computed.hardBlocked.length > 0) { + throw new BadRequestException(computed.hardBlocked.join('; ')); + } + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. The catch below rolls back the row + its lines. + if (!(computed.totalAmount > 0)) { + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - await this.bookingPricingService.createPricingSnapshots( - booking.id, - computed.usedRates, - computed.appliedModifiers, - ); - warnings.push(...computed.warnings); + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + } catch (err) { + await this.bookingsRepository + .deleteContainers(booking.id) + .catch(() => undefined); + await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined); + throw err; } // Wagon consolidation gate. A container drawdown whose lines leave a partial @@ -333,6 +369,12 @@ export class ContractBookingService { const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); + + // Tell staff the booking exists. Placed after the zero-price rollback (which + // hard-deletes the row) and before the consolidation gate, so it fires + // exactly once whether the booking parks for a partner or finalizes inline. + this.bookingNotifier.createdToStaff(withContainers ?? booking); + const intendedStatus = generalCustoms || generalSelfClear ? 'AWAITING_DOCUMENTS' @@ -462,6 +504,7 @@ export class ContractBookingService { ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } @@ -492,6 +535,8 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, opts.contractRouteId); + // No prepay gate: the clearance service fee is billed on the booking + // invoice at completion, so the document step opens immediately. const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => @@ -540,7 +585,10 @@ export class ContractBookingService { contract.tradeDirection, ); - return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + const created = + (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + this.bookingNotifier.createdToStaff(created); + return created; } /** @@ -568,11 +616,40 @@ export class ContractBookingService { if (!booking || booking.contractId !== contract.id) { throw new NotFoundException(`Booking ${bookingId} not found on this contract`); } - if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + if ( + !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes( + booking.status, + ) + ) { throw new BadRequestException( 'Clearance must be finalized before the booking can be completed.', ); } + // An unpaid booking that expired at train dispatch keeps its finished + // per-booking clearance — GL rebooks it onto a new shipment day instead of + // forcing the customer through a new shipment request + clearance fee. + if (booking.status === 'EXPIRED') { + // Only a booking that completed once (it has a price, so its clearance + // finished and cargo is persisted) can be rebooked after expiry. + if (!(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + 'Only a previously completed booking can be rebooked after it expires.', + ); + } + // Expiry released the booking's contract-capacity hold; if the payload + // re-states the cargo, make sure the released share is still free. + if (dto.containers?.length || dto.bulkLines?.length) { + await this.assertWithinQuantityCap(contract, dto); + } + // Drop the departed train's link and fall into the day-only resubmit + // path below — same machinery as OPERATION_CHANGES_REQUESTED. + await this.bookingsRepository.update(booking.id, { + status: 'OPERATION_CHANGES_REQUESTED', + trainScheduleId: null, + } as never); + booking.status = 'OPERATION_CHANGES_REQUESTED'; + booking.trainScheduleId = null; + } // Path B: only GL Ethiopia completes a customs instance — the customer // never enters shipment data on a customs contract. if (contract.customsClearingEnabled) { @@ -653,8 +730,9 @@ export class ContractBookingService { } await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), - ...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}), + equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), } as never); const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -665,15 +743,20 @@ export class ContractBookingService { const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once - // the contract rates are fixed (the clearance work is not lost). - if (!(computed.totalAmount > 0)) { + // the contract rates are fixed (the clearance work is not lost). A + // pricing hard block (e.g. one of two container sizes has no rate) + // rolls back the same way: a partially-priced total is positive but + // the booking must not proceed. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join('; ') + : 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { @@ -954,9 +1037,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1003,6 +1088,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1042,7 +1146,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1405,6 +1509,94 @@ export class ContractBookingService { ); } + /** + * Per-line handling counts. Each physical container carries its own hazardous + * / reefer / return switch (entered next to its VGM), so the count is however + * many units opted in. Forms that predate per-unit switches send line-level + * counts and no unit flags — those are honoured as-is. + */ + private handlingCounts(line: CreateBookingContainerLineDto): { + hazardousQuantity: number; + reeferQuantity: number; + returnQuantity: number; + } { + const units = line.units ?? []; + const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); + if (!flagged) { + return { + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + returnQuantity: Number(line.returnQuantity ?? 0), + }; + } + return { + hazardousQuantity: units.filter((u) => u.isHazardous).length, + reeferQuantity: units.filter((u) => u.isReefer).length, + returnQuantity: units.filter((u) => u.isReturn).length, + }; + } + + /** + * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the + * per-container opt-ins decide whether THIS shipment actually uses it. A + * container contract that allows hazardous but a booking where nobody ticked + * the switch is not a hazardous booking, and must not fire the surcharge. + * Bulk keeps the contract flag — it has its own bulk*Quantity fields. + */ + private resolveShipmentHandlingFlag( + contract: Contract, + dto: CreateBookingUnderContractDto, + field: 'hazardousQuantity' | 'reeferQuantity', + ): boolean { + const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; + if (!gated) return false; + if (contract.freightType !== 'CONTAINER') return true; + const lines = dto.containers ?? []; + if (!lines.length) return Boolean(gated); + return lines.some((l) => this.handlingCounts(l)[field] > 0); + } + + /** + * Resolve the booking's equipment return from the per-line return quantities + * (container freight). The CONTRACT gates the service — like hazardous: + * - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any + * line > 0 makes the booking WITH_RETURN (fires the pricing surcharge). + * - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy + * booking-level override (dto.equipmentReturn ?? contract default) applies. + * Bulk freight keeps the legacy behaviour untouched. + */ + private resolveShipmentEquipmentReturn( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): string { + const legacy = + dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN'; + if (contract.freightType !== 'CONTAINER') return legacy; + + const lines = dto.containers ?? []; + for (const line of lines) { + const qty = this.handlingCounts(line).returnQuantity; + if (qty === 0) continue; + if (contract.equipmentReturn !== 'WITH_RETURN') { + throw new BadRequestException( + 'This contract was not created with the empty-container return ' + + 'service — return quantities are not allowed on its bookings.', + ); + } + if (qty > line.quantity) { + throw new BadRequestException( + `Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`, + ); + } + } + + if (contract.equipmentReturn === 'WITH_RETURN') { + const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); + return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; + } + return legacy; + } + /** * Map each contract-scope container size to a concrete container type and * persist the booking_container line + its per-unit container numbers. Weight @@ -1421,25 +1613,32 @@ export class ContractBookingService { throw new BadRequestException('At least one container line is required.'); } - const allowedSizes = new Set( + // Size strings arrive in mixed formats ("20ft" from the contract scope, + // bare "20" from the rebook seed) — compare numerically so format never + // fails a size that IS in scope. + const allowedSizesFt = new Set( (contract.cargoScope ?? []) - .map((c) => c.containerSize) - .filter((s): s is string => !!s), + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { - if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + if ( + allowedSizesFt.size && + !allowedSizesFt.has(parseInt(line.containerSize, 10)) + ) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); } + const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, - contract.isReefer || (line.reeferQuantity ?? 0) > 0, + contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length @@ -1453,11 +1652,13 @@ export class ContractBookingService { containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: counts.hazardousQuantity, + reeferQuantity: counts.reeferQuantity, + returnQuantity: + contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1473,6 +1674,8 @@ export class ContractBookingService { vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, + isReturn: + contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); @@ -1563,21 +1766,46 @@ export class ContractBookingService { }), ); + // Same size-scope gate persistContainers enforces at create, surfaced as a + // blocking preview error so the form can't confirm a size the contract does + // not cover. Numeric compare — "20" and "20ft" are the same size. + const allowedSizesFt = new Set( + (contract.cargoScope ?? []) + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), + ); + const scopeErrors = allowedSizesFt.size + ? [ + ...new Set( + lines + .map((l) => l.containerSize) + .filter((s) => !allowedSizesFt.has(parseInt(s, 10))), + ), + ].map((s) => `Container size ${s} is outside the contract scope.`) + : []; + // The unsaved twin of the booking createUnderContract would write: same // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. const route = await this.resolveRoute(contract, dto.contractRouteId); const previewBooking = Object.assign(new Booking(), { + // contractId makes the preview price off the contract's frozen rate + // snapshots exactly like the persisted booking will — without it the + // preview total is 0 on a leg with no live rate and the form blocks. + contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), + equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, @@ -1586,11 +1814,15 @@ export class ContractBookingService { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: this.handlingCounts(line).hazardousQuantity, + reeferQuantity: this.handlingCounts(line).reeferQuantity, + returnQuantity: + contract.equipmentReturn === 'WITH_RETURN' + ? this.handlingCounts(line).returnQuantity + : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; @@ -1668,7 +1900,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors, + // Pricing hard blocks (missing rate for a container size / requested + // service) ride the capacity-errors channel so the form hard-blocks in + // the preview instead of failing at the create call. + capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 60eebf3da..d76391f42 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -102,6 +105,8 @@ export interface ContractClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -365,6 +370,11 @@ export class ContractClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first — see booking-clearance.service. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', @@ -446,7 +456,7 @@ export class ContractClearanceService { const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; if (!allowed.includes(contract.status)) { throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, + `Cannot finalize document approval on status "${contract.status}".`, ); } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index bc1b4ad49..2d26f51dc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,4 +1,5 @@ import { Contract } from './entities/contract.entity'; +import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; /** * Resolves which seeded clearance FileUploadSetting applies to a contract during @@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight { * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, * reviewed by Operations rather than GL. * - * DOMESTIC/intercity has no border, so no clearance gate applies on either path. + * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still + * collects the admin-configured intercity document set after both signatures + * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract + * gate and collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, freightType: string, includesCustoms: boolean, ): string | null { + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts new file mode 100644 index 000000000..154777aea --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -0,0 +1,168 @@ +import type { + ContractDocumentArticle, + ContractDocumentSnapshot, +} from './entities/contract.entity'; + +/** + * One recorded change between two document snapshots. Granularity is per + * article: a body edit is reported as "the body changed", not as a text diff. + */ +export type ContractDocumentChange = + | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } + | { kind: 'ARTICLE_REMOVED'; articleId: string; title: string } + | { + kind: 'ARTICLE_RENAMED'; + articleId: string; + title: string; + fromTitle: string; + } + | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string } + | { + kind: 'ARTICLE_REORDERED'; + articleId: string; + title: string; + fromOrder: number; + toOrder: number; + } + | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + +type SnapshotLike = Pick< + ContractDocumentSnapshot, + 'documentTitle' | 'whereasClauses' | 'articles' +> | null; + +/** Match on id when present, else on normalized title (editors may omit ids). */ +function articleKey(article: ContractDocumentArticle): string { + return article.id || `title:${article.title.trim().toLowerCase()}`; +} + +function indexArticles( + articles: ContractDocumentArticle[] | undefined, +): Map { + const map = new Map(); + for (const article of articles ?? []) { + map.set(articleKey(article), article); + } + return map; +} + +/** + * Compare two document snapshots and describe what changed, article by article. + * Returns an empty array when the snapshots are equivalent, so callers can skip + * recording a no-op revision. + */ +export function diffSnapshots( + before: SnapshotLike, + after: SnapshotLike, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + const beforeTitle = before?.documentTitle ?? null; + const afterTitle = after?.documentTitle ?? null; + if (beforeTitle !== afterTitle && afterTitle !== null) { + changes.push({ + kind: 'DOCUMENT_TITLE_CHANGED', + title: afterTitle, + fromTitle: beforeTitle, + }); + } + + const beforeWhereas = before?.whereasClauses ?? []; + const afterWhereas = after?.whereasClauses ?? []; + const beforeWhereasSet = new Set(beforeWhereas); + const afterWhereasSet = new Set(afterWhereas); + const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length; + const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length; + if (whereasAdded > 0 || whereasRemoved > 0) { + changes.push({ + kind: 'WHEREAS_CHANGED', + added: whereasAdded, + removed: whereasRemoved, + }); + } + + const beforeArticles = indexArticles(before?.articles); + const afterArticles = indexArticles(after?.articles); + + for (const [key, article] of afterArticles) { + const previous = beforeArticles.get(key); + if (!previous) { + changes.push({ + kind: 'ARTICLE_ADDED', + articleId: article.id, + title: article.title, + }); + continue; + } + + if (previous.title !== article.title) { + changes.push({ + kind: 'ARTICLE_RENAMED', + articleId: article.id, + title: article.title, + fromTitle: previous.title, + }); + } + if (previous.body !== article.body) { + changes.push({ + kind: 'ARTICLE_BODY_CHANGED', + articleId: article.id, + title: article.title, + }); + } + if (previous.order !== article.order) { + changes.push({ + kind: 'ARTICLE_REORDERED', + articleId: article.id, + title: article.title, + fromOrder: previous.order, + toOrder: article.order, + }); + } + } + + for (const [key, article] of beforeArticles) { + if (afterArticles.has(key)) continue; + changes.push({ + kind: 'ARTICLE_REMOVED', + articleId: article.id, + title: article.title, + }); + } + + return changes; +} + +/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ +export function summarizeChanges(changes: ContractDocumentChange[]): string { + if (changes.length === 0) return 'No changes'; + + const articleVerbs: Record = { + ARTICLE_ADDED: 'added', + ARTICLE_REMOVED: 'removed', + ARTICLE_RENAMED: 'renamed', + ARTICLE_BODY_CHANGED: 'edited', + ARTICLE_REORDERED: 'reordered', + }; + + const counts = new Map(); + const parts: string[] = []; + + for (const change of changes) { + const verb = articleVerbs[change.kind]; + if (verb) { + counts.set(verb, (counts.get(verb) ?? 0) + 1); + } else if (change.kind === 'DOCUMENT_TITLE_CHANGED') { + parts.push('document title changed'); + } else if (change.kind === 'WHEREAS_CHANGED') { + parts.push('recitals changed'); + } + } + + const articleParts = [...counts.entries()].map( + ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, + ); + + return [...articleParts, ...parts].join(', '); +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts new file mode 100644 index 000000000..2808ea6cf --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import type { ContractDocumentSnapshot } from './entities/contract.entity'; + +export interface RecordRevisionInput { + contractId: string; + before: ContractDocumentSnapshot | null; + after: ContractDocumentSnapshot | null; + actorId?: string | null; + actorRole?: string | null; + stepId?: string | null; +} + +@Injectable() +export class ContractDocumentHistoryService { + private readonly logger = new Logger(ContractDocumentHistoryService.name); + + constructor( + @InjectRepository(ContractDocumentRevision) + private readonly revisionRepo: Repository, + ) {} + + /** + * Append a revision describing what an edit changed. Best-effort: recording + * history must never break the edit that triggered it, so failures are logged + * and swallowed. A no-op edit records nothing. + */ + async record(input: RecordRevisionInput): Promise { + try { + const changes = diffSnapshots(input.before, input.after); + if (changes.length === 0) return; + + await this.revisionRepo.save( + this.revisionRepo.create({ + contractId: input.contractId, + actorId: input.actorId ?? null, + actorRole: input.actorRole ?? null, + stepId: input.stepId ?? null, + summary: summarizeChanges(changes), + changes, + }), + ); + } catch (err) { + this.logger.error( + `Failed to record document revision for contract ${input.contractId}: ${String(err)}`, + ); + } + } + + /** Revision history for a contract, newest first. */ + list(contractId: string): Promise { + return this.revisionRepo.find({ + where: { contractId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 3834b55f0..fd81083fc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { @@ -136,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 286cd9a01..149643441 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -10,11 +10,16 @@ import { Contract } from './entities/contract.entity'; export interface ContractUnitRateLineItem { code: string; label: string; - unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; + unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; unitPrice: number; containerSize?: string | null; conditionalOn?: string | null; cargoTypeCode?: string | null; + /** + * Customs clearance service fee — billed separately in advance (before the + * clearance document step), never part of shipment booking totals. + */ + isClearance?: boolean; } /** The contract `pricing_breakdown` shape (doc §9.1). */ @@ -32,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { return 'per_ton'; case 'PER_KM': return 'per_km'; - case 'PER_CONTAINER': case 'PER_WAGON': + return 'per_wagon'; + case 'PER_CONTAINER': return 'per_container'; default: return 'flat'; @@ -183,6 +189,166 @@ export class ContractPricingService { }); } } + // Lashing / cargo securing — BULK only, shown when the contract's commodity + // needs lashing (cargoType.hasLashing). The commodity-scoped rate for the + // contract's direction wins over the commodity-wide catch-all; billed at + // booking on the live rate (per ton / per wagon), this line is display. + if (contract.freightType === 'BULK') { + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + if (scope?.cargoType?.hasLashing) { + const onDirection = liveRates.filter( + (r) => + r.trigger === 'LASHING' && + r.currency === 'USD' && + !r.containerTypeId && + r.tradeDirection === contract.tradeDirection, + ); + const lashing = + onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ?? + onDirection.find((r) => !r.cargoTypeId); + if (lashing && Number(lashing.rateValue) > 0) { + lineItems.push({ + code: 'LASHING', + label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`, + unit: toContractUnit(lashing.rateUnit), + unitPrice: convert(Number(lashing.rateValue)), + cargoTypeCode: scope.cargoType.code ?? null, + conditionalOn: 'has_lashing', + }); + } + } + } + + // Empty-container return service — container contracts only, toggled on the + // contract like hazard/reefer. Billed at booking per WITH_RETURN container. + if ( + contract.freightType === 'CONTAINER' && + contract.equipmentReturn === 'WITH_RETURN' + ) { + // Return is sold per direction + route + container type (import-only) — + // one display line per contract size that has a configured rate. A size + // with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'RETURN_SURCHARGE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, + }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'RETURN_SURCHARGE', + label: `Empty container return (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'with_return', + }); + } + } + } + + // Customs clearance service fee (Path B) — billed on the booking invoice + // together with the freight. Sold per direction + route + cargo kind: + // container contracts freeze one fee line per contract size (each size's + // own container-type rate), bulk contracts freeze the route's bulk fee. + // A customs contract may not proceed without the fee(s) configured. + if (contract.customsClearingEnabled) { + // Strict, no route-less fallback. + // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, + }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = onLeg.find( + (r) => r.containerTypeId && matchedIds.has(r.containerTypeId), + ); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`, + ); + } + lineItems.push({ + // Distinct code per size so the frozen snapshots don't collide — + // booking pricing looks each size up by CUSTOMS_CLEARANCE_FT. + code: `CUSTOMS_CLEARANCE_${sizeFt}FT`, + label: `Customs clearance service (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + isClearance: true, + }); + } + } else { + // Bulk fee — the rate scoped to the contract's commodity wins; a + // commodity-less rate (legacy) is the catch-all fallback. + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + const rate = + (scope?.cargoTypeId + ? onLeg.find( + (r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId, + ) + : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.', + ); + } + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + cargoTypeCode: scope?.cargoType?.code ?? null, + isClearance: true, + }); + } + } return { displayMode: 'UNIT_RATES', @@ -229,6 +395,7 @@ export class ContractPricingService { containerSize: line.containerSize ?? null, isSurcharge: !!line.conditionalOn, conditionalOn: line.conditionalOn ?? null, + isClearance: !!line.isClearance, }); } } 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 4da2677bd..54158c383 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 @@ -3,7 +3,11 @@ import { ConflictException, Injectable, Logger, + ServiceUnavailableException, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -14,23 +18,56 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { + assertCanApproveContractStep, + assertFreightPermission, + canEditContractStep, +} from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + forFreightType, +} from '../../seed/freight-permissions.registry'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; +import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; import { contractClearanceSettingCode } from './contract-clearance.util'; -import { Contract } from './entities/contract.entity'; +import { + Contract, + ContractDocumentArticle, + ContractDocumentSnapshot, + ContractDocumentSnapshotInput, +} from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; +/** The editable contract-document draft returned for the accept/edit dialog. */ +export interface ContractDocumentDraft { + documentTitle: string | null; + whereasClauses: string[]; + articles: ContractDocumentArticle[]; + code: string | null; + name: string | null; + /** True when THIS caller may not edit — the inverse of `editableByMe`. */ + locked: boolean; + /** Whether the requesting user is the approver whose turn it is to edit. */ + editableByMe: boolean; + /** Role holding editing rights right now, for "locked because…" messaging. */ + nextApproverRole: string | null; + generatedAt: Date | null; + status: string; +} + /** * Dropdown-settings code holding the admin-configured contract validity options * (each option's `value` is a day count). The staff accept dialog reads the same @@ -39,6 +76,60 @@ import { SignContractDto } from './dto/sign-contract.dto'; */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; +/** + * Approval chains are configured in IAM position types, so a step's role no + * longer maps onto the contract's fixed approver columns. These sets keep those + * legacy columns populated for the roles that still correspond to one — both the + * original role strings on historical rows and the position types that replaced + * them. Steps outside these sets are recorded only in `contract_approval_steps`, + * which is the source of truth. + */ +const LEGACY_STAFF_ROLES = new Set([ + 'LINE_STAFF', + 'employee', + 'teamLeader', + 'officeHead', + 'recordOfficer', +]); +const LEGACY_DIRECTOR_ROLES = new Set([ + 'DIRECTOR', + 'director', + 'operation-director', +]); +const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']); + +/** + * Mask a phone for display — keep the last 4 digits, star the rest + * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing + * code went without echoing the company's full registered number back to the UI. + */ +function maskPhone(phone: string): string { + const trimmed = phone.trim(); + if (trimmed.length <= 4) return trimmed; + return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`; +} + +/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */ +function maskEmail(email: string): string { + const [local, domain] = email.trim().split('@'); + if (!domain) return email.trim(); + return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`; +} + +/** + * Where the signing code went, for the "we sent a code to …" line in the UI. + * Both contacts are listed when both were used — a signer who only watches their + * handset otherwise has no idea the email carries the same code. + */ +function maskSignerContacts(contacts: { phone?: string; email?: string }): string { + return [ + contacts.email ? maskEmail(contacts.email) : null, + contacts.phone ? maskPhone(contacts.phone) : null, + ] + .filter(Boolean) + .join(' and '); +} + /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { @@ -53,6 +144,7 @@ export class ContractTransitionService { private readonly logger = new Logger(ContractTransitionService.name); constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, @@ -68,8 +160,47 @@ export class ContractTransitionService { private readonly minioService: MinioService, private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, + private readonly contractTemplates: ContractTemplatesService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The contacts the signing OTP is sent to and verified against: the signer's + * own IAM account phone AND email. One code goes to both and either delivery + * verifies it, so a signer whose SMS is delayed can still complete from their + * inbox instead of abandoning a ready contract. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — caller-supplied contacts would let an attacker point the code + * at their own phone or mailbox. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerContacts( + signerUserId?: string, + ): Promise<{ phone?: string; email?: string }> { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another account if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null; email: string | null }> = + await this.dataSource.query( + `SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + const email = rows[0]?.email?.trim(); + if (!phone && !email) { + throw new BadRequestException( + 'Your account has no registered phone number or email. Add one in Settings → Account before signing.', + ); + } + return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) }; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -110,8 +241,16 @@ export class ContractTransitionService { contractId: string, actorId: string, validityDays: number, + documentSnapshot?: ContractDocumentSnapshotInput | null, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + // The route guard passes on either arm; the contract's freight type decides + // which one is actually required (accept bulk ≠ accept container). + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { @@ -128,6 +267,12 @@ export class ContractTransitionService { await this.instantiateApprovalSteps(contract); + // Freeze the contract document for THIS contract only. Staff may have edited + // the articles in the accept dialog; otherwise the live template is captured + // as-is so later template edits never change an in-flight contract. The + // shared six templates are never written here. + const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); + await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, @@ -135,12 +280,200 @@ export class ContractTransitionService { contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, + documentSnapshot: snapshot, } as never); const updated = await this.contractsService.findById(contractId); this.notifier.accepted(updated); return updated; } + // ── Per-contract document snapshot (US: edit articles for one contract) ───── + + /** + * The editable document draft for the accept/edit dialog: the frozen snapshot + * if one exists, else the live active template resolved for this contract's + * direction/freight pair. `locked` flips true once the document may no longer + * be edited (an approver has acted, or the contract has left the pre-approval + * window). + */ + async getContractDocumentDraft( + contractId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + const snapshot = + (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? + (await this.resolveDocumentSnapshot(contract)); + const editableByMe = await this.documentIsEditableBy(contract, user); + return { + documentTitle: snapshot?.documentTitle ?? null, + whereasClauses: snapshot?.whereasClauses ?? [], + articles: snapshot?.articles ?? [], + code: snapshot?.code ?? null, + name: snapshot?.name ?? null, + locked: !editableByMe, + editableByMe, + nextApproverRole: await this.nextApproverRole(contract), + generatedAt: contract.contractGeneratedAt ?? null, + status: contract.status, + }; + } + + /** + * Replace this contract's document articles from the editor. Per-contract + * only — it writes the contract's own snapshot and never the shared templates. + * Allowed while the document is still editable (PENDING_APPROVAL, no approver + * has acted). + */ + async updateContractDocument( + contractId: string, + input: ContractDocumentSnapshotInput, + user?: TCurrentUser | null, + actorId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL']); + await this.assertDocumentEditable(contract, user); + + const current = + (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? + (await this.resolveDocumentSnapshot(contract)); + const merged: ContractDocumentSnapshotInput = { + code: current?.code ?? null, + name: input.name ?? current?.name ?? null, + documentTitle: input.documentTitle ?? current?.documentTitle ?? null, + whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [], + articles: input.articles ?? current?.articles ?? [], + }; + const next = this.normalizeSnapshot(merged); + await this.contractsRepository.update(contractId, { + documentSnapshot: next, + } as never); + + // Audit the edit after it lands. Recording history must never break the + // edit itself, so the history service swallows its own failures. + const step = await this.contractsRepository.findNextPendingApprovalStep( + contractId, + ); + await this.documentHistory.record({ + contractId, + before: current, + after: next, + actorId: actorId ?? null, + actorRole: step?.requiredRole ?? null, + stepId: step?.id ?? null, + }); + + return this.contractsService.findById(contractId); + } + + /** + * Build the per-contract document snapshot. Prefer the staff's edited articles + * from the dialog; otherwise freeze the active template matching the + * contract's direction/freight. Returns null when no active template exists + * (the renderer then falls back to the built-in generic layout at render time). + */ + private async resolveDocumentSnapshot( + contract: Contract, + provided?: ContractDocumentSnapshotInput | null, + ): Promise { + if (provided && (provided.articles?.length ?? 0) > 0) { + return this.normalizeSnapshot(provided); + } + const active = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + if (!active) return null; + return { + code: active.code, + name: active.name, + documentTitle: active.documentTitle, + whereasClauses: active.whereasClauses ?? [], + articles: this.normalizeArticles(active.articles ?? []), + }; + } + + private normalizeSnapshot( + input: ContractDocumentSnapshotInput, + ): ContractDocumentSnapshot { + return { + code: input.code ?? null, + name: input.name ?? null, + documentTitle: input.documentTitle ?? null, + whereasClauses: Array.isArray(input.whereasClauses) + ? input.whereasClauses + .map((c) => String(c)) + .filter((c) => c.trim().length > 0) + : [], + articles: this.normalizeArticles(input.articles ?? []), + }; + } + + /** Re-key ids and renumber order sequentially, dropping empty-title rows. */ + private normalizeArticles( + articles: Array<{ id?: string; title?: string; body?: string; order?: number }>, + ): ContractDocumentArticle[] { + return articles + .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0) + .map((a, index) => ({ + id: a.id ?? randomUUID(), + title: (a.title ?? '').trim(), + body: a.body ?? '', + order: index + 1, + })); + } + + /** + * The contract document stays editable for the whole approval chain, but only + * by the approver whose turn it is: whoever can action the next pending step. + * Approving therefore hands editing rights to the next approver in the chain. + * + * Edits never reset approvals already given — earlier approvers stay approved. + */ + private async documentIsEditableBy( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { + if (contract.status === 'SUBMITTED') return true; + if (contract.status !== 'PENDING_APPROVAL') return false; + + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + if (!next) return false; + if (!user) return false; + + // Strict match: ONLY the approver whose turn it is (the next pending step's + // role) may edit. Using the looser approve gate here let any approver who + // held a contract-approve permission keep the edit button after acting — + // approval must hand edit rights to the next approver, not share them. + return canEditContractStep(user, next.requiredRole); + } + + /** The role that currently holds editing rights, for UI messaging. */ + private async nextApproverRole(contract: Contract): Promise { + if (contract.status !== 'PENDING_APPROVAL') return null; + const next = await this.contractsRepository.findNextPendingApprovalStep( + contract.id, + ); + return next?.requiredRole ?? null; + } + + private async assertDocumentEditable( + contract: Contract, + user?: TCurrentUser | null, + ): Promise { + if (await this.documentIsEditableBy(contract, user)) return; + + const role = await this.nextApproverRole(contract); + throw new ConflictException( + role + ? `The contract document can only be edited by the current approver (${role}).` + : 'The contract document is locked — the contract has advanced beyond approval.', + ); + } + /** * Ensure the chosen validity (days) is one of the admin-configured options in * the `contract_validity_periods` dropdown setting. If the setting is missing @@ -212,8 +545,13 @@ export class ContractTransitionService { contractId: string, note: string, actorId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( @@ -231,8 +569,17 @@ export class ContractTransitionService { return updated; } - async reject(contractId: string, reason: string, actorId: string): Promise { + async reject( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( @@ -252,17 +599,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -270,6 +624,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -288,18 +656,74 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: the contract is still alive and there + * is no customer-facing REJECTION note — the trail is a staff note plus a + * backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, stepId: string, actorId: string, - requiredRole: string, authUser?: TCurrentUser, ): Promise { - if (authUser) { - assertCanApproveBookingStep(authUser, requiredRole); - } - const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -312,31 +736,34 @@ export class ContractTransitionService { if (!next || next.id !== step.id) { throw new BadRequestException('Approval steps must be completed in order'); } - if (step.requiredRole !== requiredRole) { - throw new BadRequestException( - `Step requires role ${step.requiredRole}, not ${requiredRole}`, - ); - } - if (step.blocksRole && step.blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + + // The role is the step's own — never the caller's claim about themselves. + const requiredRole = step.requiredRole; + if (authUser) { + assertCanApproveContractStep(authUser, requiredRole); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); // Record who acted on this step, but DO NOT advance the contract status here — - // approving one step (e.g. LINE_STAFF) must not finalize the chain while later - // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once - // every step in the chain is complete; until then the contract stays in - // PENDING_APPROVAL so the next required role can act. + // approving one step must not finalize the chain while later steps are still + // pending. Status only moves to APPROVED once every step in the chain is + // complete; until then the contract stays in PENDING_APPROVAL so the next + // required approver can act. + // + // `contract_approval_steps` is the source of truth for who approved what — a + // chain is an arbitrary sequence of position types and cannot be represented + // by fixed columns. The legacy columns below are still stamped, best-effort, + // for the three roles that map onto them so older readers keep working. const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { + if (LEGACY_STAFF_ROLES.has(requiredRole)) { updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (LEGACY_CEO_ROLES.has(requiredRole)) { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } @@ -350,15 +777,19 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); - // Final approval step also generates the contract document from the - // template matching the contract's direction/freight pair. Best-effort: - // a rendering hiccup must not roll back the approval — the document can - // still be generated manually or lazily on view/download. + // Final approval is what produces the contract PDF — until now there was + // only a live preview. The approval steps are already committed, so a + // render failure must not roll them back; surface it instead of swallowing + // it, since an APPROVED contract with no document needs operator action. try { - return await this.generateContract(contractId); + return await this.finalizeApprovedContract(contractId); } catch (err) { - this.logger.warn( - `Auto contract generation after final approval failed for ${updated.reference}: ${err}`, + this.logger.error( + `Contract PDF generation failed after final approval for ${updated.reference}: ${err}`, + ); + throw new ServiceUnavailableException( + 'All approvals were recorded, but generating the contract PDF failed. ' + + 'Retry generation from the contract page.', ); } } @@ -366,30 +797,60 @@ export class ContractTransitionService { } /** - * Render the contract PDF from the Contract aggregate, store it via FilesService, - * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/ - * Chromium) is best-effort and must NOT block the contract from becoming ready — - * the document is (re)rendered lazily on view/download once Chromium is available. + * Retry path for a contract that finished approval but whose PDF failed to + * render (Chromium unavailable, etc.). The normal flow generates the document + * automatically on the final approval — there is no manual generate step + * before that, only the live preview. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); + await this.renderContractDocument(contract); + await this.contractsRepository.update(contractId, { + status: 'CONTRACT_READY', + } as never); + return this.contractsService.findById(contractId); + } - const { view } = await this.documentViewModelBuilder.build(contractId); - + /** + * Render the contract PDF from the Contract aggregate (snapshot-driven), store + * it via FilesService, and stamp the template key + generated timestamp. Never + * changes status. Rendering is best-effort — a Chromium hiccup defers the file + * (it re-renders on view/download) but the timestamp is still stamped. + */ + private async renderContractDocument( + contract: Contract, + options: { strict?: boolean } = {}, + ): Promise { + const { view } = await this.documentViewModelBuilder.build(contract.id); try { - await this.upsertContractPdf(contractId, contract.reference, view); + await this.upsertContractPdf(contract.id, contract.reference, view); } catch (err) { + // Strict callers (final approval) need to know the PDF is missing — it is + // the artifact of the completed chain, not a cache that can refill later. + if (options.strict) throw err; this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); } - - await this.contractsRepository.update(contractId, { - status: 'CONTRACT_READY', + await this.contractsRepository.update(contract.id, { contractTemplateKey: view.templateKey, contractGeneratedAt: new Date(), } as never); + } + + /** + * Every approval step landed → generate the contract PDF, then CONTRACT_READY. + * This is the only point at which the document is produced: approvers review a + * live preview, and the final approval is what turns it into a PDF. Renders + * unconditionally so the file reflects every edit made during the chain. + */ + private async finalizeApprovedContract(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + await this.renderContractDocument(contract, { strict: true }); + await this.contractsRepository.update(contractId, { + status: 'CONTRACT_READY', + } as never); return this.contractsService.findById(contractId); } @@ -574,6 +1035,31 @@ export class ContractTransitionService { } } + /** + * Send the sudo-mode signing OTP to the SIGNER's own registered phone and + * email — the same contacts {@link sign} verifies against. The client never + * picks them (that is the H12(b) trust property): it only asks us to send, and + * we resolve them from the authenticated user id. Returns a masked hint so the + * UI can say where the code went without exposing the full values. + */ + async sendSigningOtp( + contractId: string, + options: { signerUserId?: string }, + ): Promise<{ sentTo: string }> { + const contract = await this.contractsService.findById(contractId); + // Same ownership gate as signing — only the owning company's customer may + // trigger a code for this contract. + await this.contractsService.assertCustomerCanAccessContract( + options.signerUserId, + contract, + ); + assertContractStatus(contract, ['CONTRACT_READY']); + + const signerContacts = await this.resolveSignerContacts(options.signerUserId); + await this.otpService.sendOtp(signerContacts); + return { sentTo: maskSignerContacts(signerContacts) }; + } + /** Customer signs the ready contract → SIGNED_CUSTOMER. */ async sign( contractId: string, @@ -583,17 +1069,32 @@ export class ContractTransitionService { const contract = await this.contractsService.findById(contractId); if (dto.role === 'CUSTOMER') { + // H12(a): only the owning company's customer may sign — assert ownership + // before anything else (hidden as NotFound otherwise). A signing customer + // has no permission key, so this is the gate that binds the sign to the + // contract's company. + await this.contractsService.assertCustomerCanAccessContract( + options.signerUserId, + contract, + ); assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); if (existing) { throw new BadRequestException('Customer has already signed this contract'); } - // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) - // must be verified before the signature is applied. - if (!dto.otpPhone || !dto.otp) { + // Sudo-mode gate: a fresh, single-use OTP must be verified before the + // signature is applied. H12(b): verify against the SIGNER's own registered + // contacts, resolved server-side from the authenticated user id — never + // caller-supplied ones, which an attacker could point at their own phone + // or mailbox. Ownership is already asserted above, so this proves the + // specific person holding the account is present, not merely that someone + // reached a shared company line. Must resolve identically to + // sendSigningOtp, or send and verify would target different contacts. + const signerContacts = await this.resolveSignerContacts(options.signerUserId); + if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); + await this.otpService.verifyOtpForAction(signerContacts, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', @@ -635,8 +1136,8 @@ export class ContractTransitionService { }; // A clearance gate applies whenever a clearance doc set resolves — Path B - // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC - // resolves to null on both paths and skips straight to executed. + // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the + // intercity document set (DOMESTIC, ops-reviewed like Path A). const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, @@ -658,6 +1159,9 @@ export class ContractTransitionService { const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); + // No prepay gate: the customs clearance service fee (Path B) is billed on + // the booking invoice together with the freight, so the document step + // opens immediately. updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 2b79d3274..ba80cc035 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -8,6 +8,7 @@ import { ParseUUIDPipe, Patch, Post, + Put, Query, Res, UnauthorizedException, @@ -30,8 +31,14 @@ import { ApiTags, } from '@nestjs/swagger'; +import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; +import { + FREIGHT_PERMS, + bothFreightTypes, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, @@ -57,8 +64,8 @@ import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; +import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { - ApproveStepDto, RejectContractDto, RejectStepDto, RequestChangesDto, @@ -88,6 +95,7 @@ import { @ApiBearerAuth() export class ContractsController { constructor( + private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, @@ -180,7 +188,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { - assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), + ); } return this.contractsService.create(dto, files ?? [], user?.id); } @@ -192,7 +203,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. - if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { return this.contractsService.findAll(filter); } const userId = user?.id; @@ -269,7 +283,8 @@ export class ContractsController { if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } @@ -329,64 +344,127 @@ export class ContractsController { } @Post(':id/staff/accept') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + // One-of guard; the service then requires the arm matching the contract's freight type. + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, + dto.documentSnapshot, + user, + ); + } + + // Readable by anyone who may view the contract: the draft carries + // `editableByMe`, and the approval chain's approvers (identified by position + // type, not by staff_accept) must be able to fetch it to learn it is their + // turn. Gating this on staff_accept hid the edit dialog from every approver. + @Get(':id/document/draft') + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) + @ApiOperation({ + summary: + 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', + }) + getContractDocumentDraft( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Editability depends on WHO is asking — only the approver whose turn it is + // may edit — so the caller is part of the draft lookup. + return this.transitionService.getContractDocumentDraft(id, user); + } + + @Get(':id/document/revisions') + @BookingStaff(FREIGHT_PERMS.contracts.view) + @ApiOperation({ + summary: 'Audit trail of edits to this contract\'s document (newest first)', + }) + getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) { + return this.documentHistory.list(id); + } + + // Coarse gate only. WHO may actually edit is turn-based, not a static + // permission, so `updateContractDocument` -> `assertDocumentEditable` is the + // real boundary: it admits only the approver whose step is currently pending + // (edit rights hand off down the chain on each approval). + @Put(':id/document/articles') + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) + @ApiOperation({ + summary: + 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', + }) + updateContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateContractDocumentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.updateContractDocument( + id, + dto, + user, + resolveAuthUserId(user), ); } @Post(':id/staff/request-changes') - @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), + user, ); } @Post(':id/staff/reject') - @BookingStaff(FREIGHT_PERMS.contracts.reject) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + return this.transitionService.reject( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); } @Post(':id/approval-steps/:stepId/approve') - @BookingStaff([ - FREIGHT_PERMS.contracts.approveLineStaff, - FREIGHT_PERMS.contracts.approveDirector, - FREIGHT_PERMS.contracts.approveCeo, - ]) + @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, - @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { + // Whether this caller may approve depends on the step's own required role + // (an IAM position type), so the service resolves the step and authorizes + // against it — the client never declares its own role. return this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), - dto.requiredRole, user, ); } @@ -397,7 +475,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -409,6 +490,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } @@ -426,7 +508,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = @@ -461,7 +546,10 @@ export class ContractsController { @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); @@ -473,6 +561,19 @@ export class ContractsController { stream.pipe(res); } + @Post(':id/contract/send-signing-otp') + @UseGuards(JwtGuard) + @ApiOperation({ + summary: + "Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", + }) + sendSigningOtp( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id }); + } + @Post(':id/contract/sign') @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) @@ -498,12 +599,21 @@ export class ContractsController { @Post(':id/renew') @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) - renew( + async renew( @Param('id', ParseUUIDPipe) id: string, @Body() _dto: RenewContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.renew(id, user?.id ?? user?.sub); + // H12(c): a customer may only renew a contract their company owns. Staff + // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.renew(id, resolveAuthUserId(user)); } // ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── @@ -518,10 +628,20 @@ export class ContractsController { @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) - uploadClearanceDocuments( + async uploadClearanceDocuments( @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, @UploadedFiles() files: Express.Multer.File[], ) { + // H12(c): only the owning company's customer may upload clearance docs. + // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } return this.clearanceService.uploadDocuments(id, files ?? []); } @@ -916,13 +1036,16 @@ export class ContractsController { assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.milestoneService.assignRisk( bookingId, dto.riskLevel, resolveAuthUserId(user), dto.note, + // Risk history is read by people, so resolve the name now — the id alone + // would render as a UUID in the trail. + actorLabel(user), ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 96bdf22b1..bb12648a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -40,6 +40,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; import { ContractSignature } from './entities/contract-signature.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractReviewNote } from './entities/contract-review-note.entity'; +import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ContractDocumentReview } from './entities/contract-document-review.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; @@ -63,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractSignature, ContractApprovalStep, ContractReviewNote, + ContractDocumentRevision, ContractClearanceCycle, ContractDocumentReview, ClearanceMilestone, @@ -105,6 +108,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractNotifierService, ContractTransitionService, + ContractDocumentHistoryService, ContractClearanceService, ClearanceWorkflowService, BookingClearanceService, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 3c9c7db14..67a3bd101 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository { // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); await this.attachClearancePhases(items); + await this.attachRejectionNotes(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -204,17 +205,52 @@ export class ContractsRepository extends BaseRepository { private async attachClearancePhases(contracts: Contract[]): Promise { if (contracts.length === 0) return; const ids = contracts.map((c) => c.id); - const rows: Array<{ contract_id: string; current_phase: string | null }> = + const rows: Array<{ + contract_id: string; + current_phase: string | null; + booking_id: string | null; + booking_status: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ON (ccc.contract_id) + ccc.contract_id, ccc.current_phase, + b.id AS booking_id, b.status AS booking_status + FROM freight.contract_clearance_cycles ccc + LEFT JOIN freight.bookings b ON b.id = ccc.booking_id + WHERE ccc.contract_id = ANY($1) + ORDER BY ccc.contract_id, ccc.cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r])); + for (const contract of contracts) { + const row = byContract.get(contract.id); + contract.clearancePhase = row?.current_phase ?? null; + contract.latestCycleBookingId = row?.booking_id ?? null; + contract.latestCycleBookingStatus = row?.booking_status ?? null; + } + } + + /** + * Attach the latest REJECTION review-note body to each REJECTED contract so + * list consumers (portal rows, backoffice queues) can show why without a + * per-contract detail fetch. One query per page, like `attachContractFiles`. + */ + private async attachRejectionNotes(contracts: Contract[]): Promise { + const rejected = contracts.filter((c) => c.status === 'REJECTED'); + if (rejected.length === 0) return; + const ids = rejected.map((c) => c.id); + const rows: Array<{ contract_id: string; body: string }> = await this.dataSource.query( - `SELECT DISTINCT ON (contract_id) contract_id, current_phase - FROM freight.contract_clearance_cycles + `SELECT DISTINCT ON (contract_id) contract_id, body + FROM freight.contract_review_notes WHERE contract_id = ANY($1) - ORDER BY contract_id, cycle_number DESC`, + AND note_type = 'REJECTION' + AND deleted_at IS NULL + ORDER BY contract_id, created_at DESC`, [ids], ); - const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); - for (const contract of contracts) { - contract.clearancePhase = byContract.get(contract.id) ?? null; + const byContract = new Map(rows.map((r) => [r.contract_id, r.body])); + for (const contract of rejected) { + contract.latestRejectionNote = byContract.get(contract.id) ?? null; } } @@ -358,6 +394,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 224745cc4..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; -import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; @@ -181,11 +179,7 @@ export class ContractsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create contracts yet.", - ); - } + this.companiesService.assertCompanyActiveFor(company, 'contracts'); companyId = company.id; } @@ -193,31 +187,31 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); - // Stamp the operational profile (importer/exporter) for portal scoping. + // Stamp the operational profile for portal scoping. A forwarder contract + // pins its profile explicitly (trade direction can't tell it apart from a + // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } - companyProfileId = - await this.companiesService.resolveCompanyProfileIdForBooking( - companyId, - dto.tradeDirection, - fallbackType, - ); + if (dto.companyProfileId) { + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else { + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + ); - const customerSelfBooking = !dto.companyId && !!userId; - if (customerSelfBooking && companyProfileId) { - await this.companiesService.assertCompanyProfileApprovedForBooking( - companyProfileId, - ); + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } } @@ -374,23 +368,18 @@ export class ContractsService { if (companyProfileId) { // Business-license files are FileRecords (resource "company_profiles"); // carry the live ones by reference. Staged/pending uploads are excluded by - // code. Codes are slugged from each document name so they group under - // "Profile documents" on the contract detail page. + // code. The `business_license` prefix is preserved so the portal groups + // them under "Business license" instead of the clearance catch-all — the + // index suffix keeps multiple licences distinct. const records = await this.filesService.findByResource( companyProfileId, 'company_profiles', ); - const slug = (name: string) => - name - .toLowerCase() - .replace(/\.[a-z0-9]+$/, '') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'profile_document'; records .filter((r) => r.code === 'business_license') .forEach((r, i) => { - const code = `${slug(r.name)}_${i + 1}`; + const code = `business_license_${i + 1}`; if (existingCodes.has(code)) return; docs.push({ code, @@ -647,6 +636,47 @@ export class ContractsService { } } + // Surface the rejection reason. The approval-step note is wiped on + // send-back resets, so the review-note trail is the only durable source. + if (contract.status === 'REJECTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'REJECTION', + ); + contract.latestRejectionNote = note?.body ?? null; + } catch { + contract.latestRejectionNote = null; + } + } + + // Surface the send-back reason to the returned-to approver, but only while + // it is still actionable: once any step acts after the send-back the note + // is stale and stays out of the response (the trail keeps it in the DB). + if (contract.status === 'PENDING_APPROVAL') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'STAFF_NOTE', + ); + // Stale when any step acted after it (send-back resolved) or when the + // chain itself is newer than the note (fresh cycle after a resubmit). + const staleAfter = Math.max( + 0, + ...(contract.approvalSteps ?? []).flatMap((s) => [ + s.actedAt ? new Date(s.actedAt).getTime() : 0, + s.createdAt ? new Date(s.createdAt).getTime() : 0, + ]), + ); + contract.latestSendBackNote = + note && new Date(note.createdAt).getTime() > staleAfter + ? note.body + : null; + } catch { + contract.latestSendBackNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts index 86e1260c4..d3eaa73a3 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -1,5 +1,8 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator'; + +import { UpdateContractDocumentDto } from './contract-document.dto'; export class AcceptContractDto { @ApiProperty({ @@ -14,4 +17,16 @@ export class AcceptContractDto { @Min(1) @Max(3650) validityDays!: number; + + /** + * Optional per-contract document override edited by staff in the accept + * dialog. When present its articles are frozen onto THIS contract; when + * omitted the live template is snapshotted as-is. Never edits the shared + * six templates. + */ + @ApiPropertyOptional({ type: UpdateContractDocumentDto }) + @IsOptional() + @ValidateNested() + @Type(() => UpdateContractDocumentDto) + documentSnapshot?: UpdateContractDocumentDto; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts new file mode 100644 index 000000000..7fdb8477e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsInt, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +/** One article of a per-contract document override sent from the editor. */ +export class ContractDocumentArticleDto { + @ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty() + @IsString() + title!: string; + + @ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' }) + @IsString() + body!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + order?: number; +} + +/** + * The per-contract document override sent from the accept/edit editor. It edits + * ONLY this contract's frozen snapshot — it is never written back to the shared + * six {@link ContractTemplate} rows. + */ +export class UpdateContractDocumentDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + code?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + documentTitle?: string | null; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiProperty({ type: [ContractDocumentArticleDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContractDocumentArticleDto) + articles!: ContractDocumentArticleDto[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 870817365..93a0e9e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -50,6 +50,15 @@ export class CreateContainerUnitDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'This container ships back empty (equipment return).', + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReturn?: boolean; } export class CreateBookingContainerLineDto { @@ -77,6 +86,18 @@ export class CreateBookingContainerLineDto { @Transform(({ value }) => Number(value)) reeferQuantity?: number; + @ApiPropertyOptional({ + minimum: 0, + description: + 'How many units of this line ship with empty-container return (≤ quantity). ' + + 'Only allowed when the contract was created WITH_RETURN (container freight).', + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + returnQuantity?: number; + @ApiProperty({ type: [CreateContainerUnitDto] }) @IsArray() @ValidateNested({ each: true }) @@ -161,6 +182,13 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + description: 'What the containers carry — captured per booking (container freight).', + }) + @IsOptional() + @IsString() + cargoFreeText?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index b20b99575..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; -const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const; +// Canonical UPPERCASE — everything downstream (booking gating, pricing +// surcharge, GL/portal booking forms) compares contract.equipmentReturn +// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized. +const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; export { CONTRACT_KINDS, @@ -121,6 +124,16 @@ export class CreateContractDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile to stamp the contract to (a forwarder contract); ' + + 'commercial contracts otherwise auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; @@ -161,6 +174,9 @@ export class CreateContractDto { @ApiPropertyOptional({ enum: EQUIPMENT_RETURNS }) @IsOptional() + @Transform(({ value }) => + typeof value === 'string' ? value.toUpperCase() : value, + ) @IsIn([...EQUIPMENT_RETURNS]) equipmentReturn?: string; diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts index 6d2afce3c..2ece44f12 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [ 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', + 'OTHER', ] as const; export type IncidentType = (typeof INCIDENT_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index d4676b8cd..14e3b86dd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number]; export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const; export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; +/** + * One customs risk decision. Risk stays correctable until duty is advised off + * it, and the level is customer-visible, so every assignment is kept rather than + * overwritten — a disputed level needs to show what was set, by whom, and when. + */ +export interface RiskAssignmentRecord { + level: CustomsRiskLevel; + /** The level this replaced; absent on the first assignment. */ + previousLevel?: CustomsRiskLevel; + assignedAt: string; + assignedByUserId?: string | null; + /** Display name resolved at assignment time, so the trail never shows a UUID. */ + assignedBy?: string | null; + note?: string | null; +} + /** * Structured payload some milestones carry beyond a plain note (doc §11.3): - * - RISK_ASSIGNED → `riskLevel` + * - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment) * - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial` * Stored on the milestone so the timeline can render the value inline. */ export interface MilestoneMetadata { riskLevel?: CustomsRiskLevel; + /** + * Append-only, oldest first. `riskLevel` is the current value and always + * equals the last entry's `level`. + */ + riskHistory?: RiskAssignmentRecord[]; dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts index 3c8c7fd5f..0a47dd3e1 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts @@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity { @Column({ name: 'step_order', type: 'smallint', default: 0 }) stepOrder!: number; - @Column({ name: 'required_role', type: 'varchar', length: 40 }) + @Column({ name: 'required_role', type: 'varchar', length: 64 }) requiredRole!: string; - @Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true }) + @Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true }) blocksRole?: string | null; @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts new file mode 100644 index 000000000..bc7e12e3e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import type { ContractDocumentChange } from '../contract-document-diff.util'; +import { Contract } from './contract.entity'; + +/** + * Append-only audit of contract document edits. The document stays editable + * through the whole approval chain, so this records who changed which article + * and when — the contract itself only ever holds the current snapshot. + */ +@Entity({ schema: 'freight', name: 'contract_document_revisions' }) +@Index(['contractId']) +export class ContractDocumentRevision extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId?: string | null; + + /** The approval step's required role at the time of the edit. */ + @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) + actorRole?: string | null; + + @Column({ name: 'step_id', type: 'uuid', nullable: true }) + stepId?: string | null; + + @Column({ name: 'summary', type: 'varchar', length: 255, nullable: true }) + summary?: string | null; + + @Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` }) + changes!: ContractDocumentChange[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index eb0a607cc..244a2816d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity { /** is_hazardous | is_reefer when this is a conditional surcharge. */ @Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true }) conditionalOn?: string | null; + + /** + * Customs clearance service fee line — billed on the booking invoice + * together with the freight (no separate prepaid clearance invoice). + */ + @Column({ name: 'is_clearance', type: 'boolean', default: false }) + isClearance!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0b0fab41b..b8635199d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [ export type ContractStatus = (typeof CONTRACT_STATUSES)[number]; +/** One article on a per-contract document snapshot (mirrors the template shape). */ +export interface ContractDocumentArticle { + id: string; + title: string; + body: string; + order: number; +} + +/** + * A per-contract copy of the resolved contract-document template, frozen when + * staff accept the contract for approval. Staff may edit these articles for a + * single contract in the accept/edit dialog — editing NEVER writes back to the + * shared six {@link ContractTemplate} rows. The PDF is rendered from this + * snapshot when present; a null snapshot renders from the live template. + */ +export interface ContractDocumentSnapshot { + code?: string | null; + name?: string | null; + documentTitle?: string | null; + whereasClauses: string[]; + articles: ContractDocumentArticle[]; +} + +/** Loose inbound shape (article ids/order optional) — normalized before store. */ +export interface ContractDocumentSnapshotInput { + code?: string | null; + name?: string | null; + documentTitle?: string | null; + whereasClauses?: string[]; + articles?: Array<{ + id?: string; + title?: string; + body?: string; + order?: number; + }>; +} + export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const; export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; @@ -193,6 +230,14 @@ export class Contract extends BaseEntity { @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) contractGeneratedAt?: Date | null; + /** + * Per-contract frozen copy of the document template (articles + WHEREAS), + * captured at staff accept. Editing it affects only this contract, never the + * shared six templates. Null → the PDF renders from the live template. + */ + @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true }) + documentSnapshot?: ContractDocumentSnapshot | null; + @Column({ name: 'contract_summary', type: 'text', nullable: true }) contractSummary?: string | null; @@ -261,10 +306,33 @@ export class Contract extends BaseEntity { */ clearancePhase?: string | null; + /** + * Latest clearance cycle's linked booking (id + status), attached alongside + * clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart + * from a live one so it can offer a rebook. Not columns. + */ + latestCycleBookingId?: string | null; + latestCycleBookingStatus?: string | null; + /** * Body of the most recent CHANGES_REQUESTED review note, attached by * ContractsService.findById so the portal can show the customer what staff * asked them to fix. Lives in contract_review_notes, not a column here. */ latestChangeRequestNote?: string | null; + + /** + * Body of the most recent REJECTION review note, attached by + * ContractsService.findById when status is REJECTED so both backoffice and + * portal can show why. Lives in contract_review_notes, not a column here. + */ + latestRejectionNote?: string | null; + + /** + * Body of the most recent send-back STAFF_NOTE, attached by + * ContractsService.findById while the contract is PENDING_APPROVAL and no + * approval step has acted since the send-back. Lives in + * contract_review_notes, not a column here. + */ + latestSendBackNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index aa1c956e0..f155a27be 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal contracts stay on the list — the clearance hub is GL's history of + // everything that passed through, not just the live work queue. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ @@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'ROAD_DISPATCH_PENDING', + // Payment phase — the booking is selected/awaiting the customer's payment. + 'SELECTED_FOR_BATCH', + 'PNR_GENERATED', + 'AWAITING_PAYMENT', + 'PAYMENT_VERIFICATION_IN_PROGRESS', 'IN_TRANSIT', 'ARRIVED', 'PAID', 'COMPLETED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal bookings stay on the list — EXPIRED especially: GL rebooks it + // from here, and the hub doubles as clearance history. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index d86ee823a..e5b6ae146 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -72,7 +73,30 @@ export class DriversController { @Post(':id/documents') @BookingStaff(FREIGHT_PERMS.drivers.update) @ApiConsumes('multipart/form-data') - @UseInterceptors(AnyFilesInterceptor()) + // Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits + // AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads. + @UseInterceptors( + AnyFilesInterceptor({ + limits: { fileSize: 10 * 1024 * 1024, files: 20 }, + fileFilter: (_req, file, cb) => { + const allowed = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + ]; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new BadRequestException(`Unsupported file type: ${file.mimetype}`), + false, + ); + } + }, + }), + ) @ApiOperation({ summary: 'Upload driver documents (code driver_docs)' }) uploadDocuments( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 221b7c29b..1fbd1459f 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -1,6 +1,16 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; +/** + * Reviewer verdict on a single stored document. + * + * `null` (the default) means "not reviewed" — the state every file starts in and + * the only state the customer is not blocked by. `change_requested` is raised by + * a backoffice reviewer against one specific document and is what the customer + * must clear by re-uploading; `approved` records an explicit sign-off. + */ +export type FileReviewStatus = "change_requested" | "approved"; + @Entity({ schema: "freight", name: "files" }) export class FileRecord extends BaseEntity { @Column({ name: "resource_id", type: "uuid" }) @@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity { @Column({ name: "mime_type", type: "varchar", length: 255 }) mimeType!: string; + + /** Reviewer verdict, or `null` while the document has never been reviewed. */ + @Column({ + name: "review_status", + type: "varchar", + length: 32, + nullable: true, + default: null, + }) + reviewStatus!: FileReviewStatus | null; + + /** Why a change was requested — shown verbatim to the customer. */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote!: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy!: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt!: Date | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index e0d876176..6978ad446 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -1,33 +1,45 @@ +import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types"; import { Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, Query, Res, } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; -import { Public } from "@edr/api-common"; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; import { Response } from "express"; import { FilesService } from "./files.service"; @ApiTags("files") +@ApiBearerAuth() @Controller("files") export class FilesController { constructor(private readonly filesService: FilesService) {} @Get(":fileId") - // Public so the browser can load the bytes directly via /