Merge branch 'dev' into update-freight-migrations

This commit is contained in:
Yonas Tewabe
2026-07-24 14:04:11 +03:00
committed by GitHub
1114 changed files with 89749 additions and 15412 deletions

View File

@@ -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

19
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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:<tag>
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 \

View File

@@ -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

View File

@@ -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 1014 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 `<same date>`".
**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 C1C3 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. **H1H7 + L1L2**: 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):** M1M27 (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 (C1C6) 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 (H1H7, 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, M8M10, 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, M11M13):** 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, H14H16, 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 (M1M6, 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, M16M25):** 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.

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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<void> {
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.',
);
}
}

View File

@@ -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 steps 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,
);
});
});

View File

@@ -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<string>();
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<string, string[]> = {
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
DIRECTOR: ['director', 'operation-director'],
CEO: ['chief', 'deputy'],
};
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
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,

View File

@@ -0,0 +1,13 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
*
* 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}`;
}

View File

@@ -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<void> {
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;
}
}

View File

@@ -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<Parameters<typeof usesEdrMileService>[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);
});
});

View File

@@ -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 customers 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.';

View File

@@ -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)])),
);

View File

@@ -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
)`;

View File

@@ -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();
});
});

View File

@@ -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<string[]> {
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());
}

View File

@@ -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<ReturnType<typeof errorsFor>>) =>
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');
});
});

View File

@@ -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,
});
};
}

View File

@@ -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('');
});
});

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;

View File

@@ -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>): 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);
});
});

View File

@@ -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<string, string> = {
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<Record<Rate['appliesTo'], string>> = {
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<Record<Rate['trigger'], string>> = {
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<RateSchedule> {
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());
}
}

View File

@@ -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,

View File

@@ -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)),
}));

View File

@@ -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,

View File

@@ -25,25 +25,9 @@
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.unitRates}}
<h3>Unit Rate Schedule</h3>
<p>
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.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#unless rateSchedule.isEmpty}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{else}}
<h3>Charges</h3>
<table class="schedule">
@@ -76,7 +60,7 @@
</tr>
</tbody>
</table>
{{/if}}
{{/unless}}
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in

View File

@@ -20,5 +20,9 @@
{{/each}}
</ol>
{{/if}}
{{#if (eq id "pricing")}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{/if}}
</section>
{{/each}}

View File

@@ -0,0 +1,55 @@
{{#if rateSchedule.isEmpty}}
<p class="muted-note">
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.
</p>
{{else}}
<p>
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.
</p>
<table class="schedule">
<thead>
<tr>
<th>Route / Service</th>
<th>Cargo / Equipment</th>
<th>Unit price</th>
</tr>
</thead>
<tbody>
{{#if rateSchedule.freightLanes.length}}
<tr><th colspan="3">Railway Freight — Origin → Destination</th></tr>
{{#each rateSchedule.freightLanes}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.additionalServices.length}}
<tr><th colspan="3">Additional Services</th></tr>
{{#each rateSchedule.additionalServices}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.surcharges.length}}
<tr><th colspan="3">Surcharges, Demurrage &amp; Fees</th></tr>
{{#each rateSchedule.surcharges}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
</tbody>
</table>
{{/if}}

View File

@@ -134,26 +134,8 @@
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
<h3>Published Rate Schedule</h3>
{{> rate_schedule}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}

View File

@@ -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

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`);
}
}

View File

@@ -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<void> {
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<void> {
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;`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS reopen_delay_minutes;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
`);
}
}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<void> {
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<void> {
// 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.
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP COLUMN IF EXISTS document_snapshot;
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS reason text NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS reason
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
);
}
}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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 `<CODE>-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, ER0001ER1100, 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<void> {
// 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<void> {
await queryRunner.query(
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP COLUMN IF EXISTS return_quantity;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS export_train_number,
DROP COLUMN IF EXISTS import_train_number;
`);
}
}

View File

@@ -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<string, string[]> = {
'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<string, string> = {
'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<void> {
// 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<string>();
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<void> {
await queryRunner.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
}
}

View File

@@ -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<void> {
// 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<void> {
// 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.
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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;
`);
}
}

View File

@@ -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<void> {
// 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<void> {
// Back to the state SeedEdrWagonFleetErNumbering leaves them in.
await queryRunner.query(`
UPDATE freight.wagons
SET current_yard_id = NULL
WHERE train_id IS NULL;
`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
}
}

View File

@@ -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<void> {
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<void> {
// No-op: dropping the tables would discard tracker history on environments
// where this migration was the one that created them. AddGpsTracking owns
// the teardown.
}
}

View File

@@ -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<void> {
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<void> {
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`,
);
}
}

View File

@@ -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<void> {
// ── 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<void> {
// 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;
`);
}
}

View File

@@ -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 = <message 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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS handles_container,
DROP COLUMN IF EXISTS handles_bulk
`);
}
}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP COLUMN IF EXISTS has_lashing;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS reverse_wagon_order;
`);
}
}

View File

@@ -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<void> {
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<void> {
// No-op: the refreshed pricing prose is the correct forward state; reverting
// to hardcoded figures would reintroduce the rate-schedule contradiction.
}
}

View File

@@ -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<void> {
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<void> {
// intentionally empty
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`,
);
}
}

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
);
}
public async down(): Promise<void> {
// Enum values cannot be removed in Postgres; leaving the label is harmless.
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard soft-delete now appends `@<epoch-ms>` 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<void> {
await queryRunner.query(
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
);
}
public async down(): Promise<void> {
// Narrowing would fail on suffixed codes; keep 40.
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
// 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<void> {
// 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.
}
}

View File

@@ -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<void> {
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<void> {
// intentionally empty
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
// 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<void> {
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.
}
}

View File

@@ -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<void> {
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<void> {
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;`,
);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS active_profile_type;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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;
`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
name = "AddCacBankPaymentMethod2460000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// PostgreSQL does not support removing enum values directly.
// To roll back, recreate the type without the added value and update the column.
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
ADD COLUMN IF NOT EXISTS item_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
DROP COLUMN IF EXISTS item_name
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`);
}
}

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
);
}
}

View File

@@ -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<void> {
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<void> {
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;`,
);
}
}

View File

@@ -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<void> {
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<void> {
// 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
);
`);
}
}

View File

@@ -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<void> {
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<void> {
// 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
);
`);
}
}

View File

@@ -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<void> {
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<void> {
// 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;`,
);
}
}

View File

@@ -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<void> {
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<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -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<void> {
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<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -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<void> {
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<void> {
// Retired rates stay retired — re-enter per-direction bulk rates instead.
}
}

View File

@@ -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);
}
}

View File

@@ -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<User>,
@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<TCurrentTokenUser> = 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<TCurrentTokenUser>,
): Promise<void> {
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<void> {
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",
);
}
}

View File

@@ -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<CustomerResetTarget> {
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;
}
}

View File

@@ -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<ExternalProfile>,
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<CustomerResetTarget | null> {
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<string | null> {
): Promise<SentResetLink | null> {
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<string>("app.portalBaseUrl");
return `${base}/reset-password?uid=${encodeURIComponent(
userId,
)}&token=${encodeURIComponent(token)}`;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More