mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into update-freight-migrations
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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
19
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
41
apps/edr-freight-api/Dockerfile.base
Normal file
41
apps/edr-freight-api/Dockerfile.base
Normal 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
|
||||
457
apps/edr-freight-api/FREIGHT_QA_REPORT.md
Normal file
457
apps/edr-freight-api/FREIGHT_QA_REPORT.md
Normal 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 10–14 prove the "public" endpoints are genuinely reachable without a token (protected routes return `401`; these return `400`/`404`/`200` because they hit the handler).
|
||||
|
||||
> **Boot-time noise (not app bugs):** SMS + Email services fail on RabbitMQ `ACCESS_REFUSED` (broker creds), and Swagger warns about 3 duplicate DTO names (`UpdateProfileDto`, `RequestChangesDto`, `SignContractDto`) and a legacy `/api/*` route. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 3. CRITICAL — money & authorization
|
||||
|
||||
### C1. Paying an invoice settles it instantly without any money moving
|
||||
**File:** `edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts:973-986`
|
||||
A shipped "DEMO" shortcut fakes a `payment.succeeded` callback the moment a payment is initiated:
|
||||
```ts
|
||||
// DEMO: manually fire the gateway 'payment.succeeded' callback here …
|
||||
if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", … }) }
|
||||
```
|
||||
Real gateways return `REQUIRES_ACTION` (redirect), so `immediateSuccess` is false for essentially every payment → the invoice is marked **PAID**, the booking advances to `PAID`/batch allocation, and the clearance-fee gate opens. **A customer clicks "Pay", closes the page, pays nothing, and the freight ships.** If they *do* pay, the later real webhook is a no-op and money is collected against an already-settled invoice with no reconciliation.
|
||||
**Fix:** delete the `if (!result.immediateSuccess)` block. Settle only from `settleByPaymentId` on a verified provider signal, and check `intent.amountMinor` against the invoice balance at settle time.
|
||||
|
||||
### C2. Unauthenticated endpoints can settle any invoice by ID *(live-confirmed)*
|
||||
**Files:** `billing/payment.controller.ts:55-104` (`GET /payments/checkout`, `@Public()`), `payment/internal-payment.controller.ts:22-39` (`POST /internal/payments/mark-paid`, `@Public()` — its own comment: *"anyone who can reach the API can mark payments as paid"*), `billing/payment.controller.ts:39-53` (`POST /payments/initiate`, no guard, no ownership check).
|
||||
**Repro (live):**
|
||||
```
|
||||
GET /api/payments/checkout?invoiceId=…&method=TELEBIRR → 200 (no token)
|
||||
POST /api/internal/payments/mark-paid → 400 (no token, reached handler)
|
||||
GET /api/locomotives → 401 (control)
|
||||
```
|
||||
Combined with **C1**, anyone with an invoice UUID marks it paid unauthenticated and ships the freight. Anyone can POST a forged `payment.succeeded` (only a booking id is needed) to `mark-paid`.
|
||||
**Fix:** shared-secret / `x-service-token` guard on `mark-paid` (the payment API already sends one — `http-payment-event-publisher.ts:44-45`); require auth + ownership on `initiate`; make `checkout` a signed, expiring URL.
|
||||
|
||||
### C3. Currency-unit chaos — providers disagree by 100× on the same charge
|
||||
**Files:** `billing.service.ts:958` sends `amountMinor: Math.round(Number(invoice.balanceAmount))` (invoice amounts are **major** units, `numeric(14,2)`). Providers then split: `cbe-birr.provider.ts:57`, `ebirr.provider.ts:64`, `card.provider.ts:60`, `cac-bank.provider.ts:307` all do `amountMinor / 100`; but `telebirr.provider.ts:201`, `dmoney.provider.ts:200`, `waafi.provider.ts:282` treat it as major (no `/100`).
|
||||
**Impact:** a 50,000 ETB invoice paid via CBE_BIRR/EBIRR/CARD/CAC charges **500.00** — while the freight side still marks it fully PAID (see C4). `Math.round` also drops cents.
|
||||
**Fix:** pick one convention (`Math.round(balance*100)` true-minor everywhere, fix the 3 major-unit providers), then enforce `confirmedAmountMinor === intent.amountMinor` at settlement.
|
||||
|
||||
### C4. Settlement never verifies the amount charged
|
||||
**Files:** `billing.service.ts:527-552` (`markInvoiceAsPaid` sets `paidAmount = totalAmount, balanceAmount = 0` purely from the invoice); `edr-payment-api/.../intents/intents.service.ts:408-415` (an amount mismatch is only `logger.error`'d — the intent still finalizes SUCCEEDED, and no freight handler even populates `confirmedAmountMinor`).
|
||||
**Impact:** any wrong-amount success (C3's 1/100, a partial card capture, a reused stale intent) still marks the full invoice PAID.
|
||||
**Fix:** treat a `confirmedAmountMinor` mismatch as a failure/hold; in `settleByPaymentId` refuse (or record a partial) when the confirmed amount doesn't cover `balanceAmount`.
|
||||
|
||||
### C5. Telebirr & D-Money webhook signatures are disabled
|
||||
**Files:** `edr-payment-api/.../webhooks/handlers/telebirr-webhook.service.ts:16-18` (`const signatureValid = true;` + `// TODO: re-enable`), `dmoney-webhook.service.ts:16-17` (same). The pipeline only rejects when `signatureValid` is false, so these two are trusted unconditionally on the public `/webhooks/*` surface.
|
||||
**Impact:** an attacker who guesses a `merch_order_id` POSTs a fake success payload → intent SUCCEEDED → freight marks the invoice PAID and ships.
|
||||
**Fix:** implement `verifyWebhookSignature` for both; until keys exist, re-query the provider (`queryStatus`) before honoring SUCCEEDED.
|
||||
|
||||
### C6. Customers can create/act-on bookings billed to any company
|
||||
**Files:** `bookings/bookings.service.ts:594-629` (`create` only resolves+verifies the caller's own company when `dto.companyId` is *absent*; a supplied `companyId` is used verbatim), plus a batch of booking mutations with **no ownership check** in `bookings.controller.ts`: `PATCH /:id` (205), `DELETE /:id` (585), `POST /:id/generate-price` (611), `/submit` (622), `/confirm-submit` (633), `/reject` (643), `/clearance/documents` (682), `/clearance/proceed` (699), `GET /:id/clearance` (672). Reads (`GET /:id`) *do* call `assertCustomerCanAccessBooking`; writes don't.
|
||||
**Impact:** Company A's user sets `companyId` to Company B and books/invoices under B; or calls `DELETE /bookings/{B's id}` / submits / uploads clearance docs on B's booking. UUIDs appear in list payloads, so they're discoverable within a session.
|
||||
**Fix:** require a staff permission to pass `companyId`; otherwise force it from the resolved user company. Add `assertCustomerCanAccessBooking` to every mutating booking route.
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGH — Train / Locomotive / Wagon consist integrity
|
||||
|
||||
> **★ Structural root cause (fix this first).** The invariant *"a consist (train + its locomotives + its wagons) moves and locks as one unit"* is enforced **only inside** `trains/train-builder.service.ts`. Every *legacy / master-data* endpoint around it — `PATCH /locomotives/:id`, `PATCH /wagons/:id`, `POST /wagons/:id/assign-train`, `wagons/bulk-status`, `wagons/bulk-transfer`, `DELETE /trains/:id`, `DELETE /wagons/:id` — mutates the same rows with **no coupling guard**. Findings H1, H2, H5, H6, H7 are all the same missing check. **The one fix that kills the family:** make `TrainLocomotive`/`wagon.trainId` membership a guard that every locomotive/wagon mutation consults (reject or redirect to the builder endpoints).
|
||||
|
||||
### H1. ★ Locomotive yard/status freely editable while coupled to a built train — *your example, confirmed live*
|
||||
**File:** `locomotives/locomotives.service.ts:87-119` (`update`).
|
||||
`update()` applies `status` and `currentYardId` with **no check of the `train_locomotives` link table**:
|
||||
```ts
|
||||
currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null),
|
||||
```
|
||||
The builder keeps train + locomotives in the same yard (`train-builder.service.ts:389-429 setYard`, `:656-660 validateAndLockLocomotives`), but `PATCH /locomotives/:id` bypasses it. **The frontend triggers it by accident:** the locomotive edit form always includes the `status` and `currentYardId` selects (`.../pages/fleet/config/resources.ts:168-169`) and PATCHes the *whole form object* on every save (`FleetResourcePage.tsx:301`). So editing a coupled locomotive's *name* re-sends its yard → the train and its locomotive end up in different yards.
|
||||
**Repro (live):**
|
||||
```
|
||||
Build TR-00002 in KALITY with LOCO-004 (+LOCO-023)
|
||||
PATCH /api/locomotives/LOCO-004 {"currentYardId": MOJO} → 200
|
||||
GET train → yard: KALITY ; LOCO-004 → yard: MOJO ← diverged, no error
|
||||
```
|
||||
**Fix (exactly what you described — reject with a clear error, don't silently move):**
|
||||
```ts
|
||||
const link = await this.ds.getRepository(TrainLocomotive)
|
||||
.findOne({ where: { locomotiveId: id }, relations: { train: true } });
|
||||
if (link) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId)
|
||||
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; move the train instead`);
|
||||
if (dto.status !== undefined && dto.status !== loco.status)
|
||||
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; detach it before changing status`);
|
||||
}
|
||||
```
|
||||
Frontend: send only dirty fields, and disable the yard/status selects (show the modal error) when the locomotive is in a built train.
|
||||
|
||||
### H2. Wagon PATCH is a free-for-all — yard, `trainId`, `sequenceNumber`, `status` all unguarded *(live-confirmed)*
|
||||
**File:** `wagons/wagons.service.ts:90-101` — `Object.assign(wagon, dto)` with zero invariant checks; `UpdateWagonDto` (PartialType of Create) exposes `trainId`, `sequenceNumber`, `status`, `currentYardId`.
|
||||
**Repro (live):** `PATCH /api/wagons/:id {"currentYardId": MOJO}` on a wagon `ASSIGNED` to a KALITY train returned `200` and moved it. Setting `trainId` directly attaches to any train bypassing every builder rule; flipping `ASSIGNED→AVAILABLE` while `trainId` is set makes the wagon grabbable by transfer requests and the legacy assign flow.
|
||||
**Fix:** reject `trainId`/`sequenceNumber` in update; when `wagon.trainId != null`, reject `currentYardId`/`status` changes (409 → point at train-builder endpoints).
|
||||
|
||||
### H3. `DELETE /wagons/:id` hard-deletes with no guard — destroyed real data during this audit *(live-confirmed)*
|
||||
**File:** `wagons/wagons.service.ts:136-139` — `this.wagonRepo.remove(wagon)` is a **hard** delete (repo standard is soft-delete via `BaseEntity.deletedAt`). No check for `trainId`, live-schedule pinning, or containers.
|
||||
**Repro (live):** I called `DELETE /api/wagons/{BW1-0009}` while it was `ASSIGNED` to my test train → `200`. Raw SQL then showed **the row physically gone** (`SELECT … WHERE id=… → 0 rows`). On delete, `train_set_wagons.physical_wagon_id` is `SET NULL` (a dispatched schedule silently loses its physical wagon) and `wagon_movements` is `CASCADE` (the audit ledger is destroyed).
|
||||
> **I recreated BW1-0009 via the API** (`POST /api/wagons`, new UUID `d9b578ed-…`, AVAILABLE @ KALITY) so the dev data count is whole again. The original UUID `905cad62-…` is unrecoverable (hard delete). See §10.
|
||||
**Fix:** block deletion when `trainId IS NOT NULL` or the wagon is pinned to a DRAFT/SCHEDULED/DISPATCHED schedule (reuse `train-builder`'s `isWagonPinnedToLiveSchedule`); switch to `softRemove` (add a partial unique index on `wagon_number WHERE deleted_at IS NULL`).
|
||||
|
||||
### H4. `DELETE /trains/:id` hard-deletes a built train and permanently strands its wagons
|
||||
**File:** `trains/trains.service.ts:57-60` — `trainRepo.remove(train)`, no active-schedule check (contrast `train-builder.service.ts:536-563 disband`, which blocks on live schedules and releases resources). Wagons' FK is `SET NULL`, so they keep `status = ASSIGNED` with `trainId = null` → **unusable forever** (attach requires `status === Available`). The fleet Trains CRUD page (`resources.ts:204`) points at this endpoint.
|
||||
**Fix:** delegate `TrainsService.remove` to `TrainBuilderService.disband` (block live schedules, reset wagons to Available, delete locomotive links), and use `softRemove`.
|
||||
|
||||
### H5. Legacy wagon assign: maintenance wagons assignable, silent theft, duplicate sequences
|
||||
**File:** `wagons/wagons.service.ts:141-164` (`POST /wagons/:id/assign-train`). Only guard is `status === Assigned`. Holes vs the builder's `attachWagons`: a `MAINTENANCE`/`DETAINED` wagon is accepted and flipped to ASSIGNED (erasing the flag); a wagon with `trainId` set but status≠ASSIGNED has its `trainId` overwritten (**steals it from another train's consist**, never resequenced); no wagon-yard vs train-yard check; no `train.status !== IN_SERVICE` check; caller `sequenceNumber` isn't collision-checked (no unique index on `(train_id, sequence_number)`).
|
||||
**Frontend echo:** `AssignWagonDialog.tsx:22-24` filters `w.status === Available || !w.trainId` — the `||` should be `&&`; today it offers maintenance/detained wagons.
|
||||
**Fix:** re-implement on builder rules (`Available && trainId === null && sameYard && train not InService`, sequence = max+1), or delete the endpoint and use `POST /train-builder/:id/wagons`.
|
||||
|
||||
### H6. `bulk-status` / yard-workspace "Free up" corrupts wagons physically in a train
|
||||
**File:** `wagons/wagons.service.ts:241-269` (`bulkSetStatus`, no `trainId` guard); UI `WagonYardWorkspaceModal.tsx:187,270-287,545-580` picks an arbitrary slice of ASSIGNED wagons and flips them AVAILABLE — including consist wagons (whose ASSIGNED means "coupled"). After the flip they still have `trainId` set but read AVAILABLE → transfer requests hand them out, `bulk-transfer` moves them, legacy assign re-homes them, while the builder consist still lists them.
|
||||
**Fix:** refuse to change status of wagons with `trainId IS NOT NULL`; exclude them from the modal's flip pools.
|
||||
|
||||
### H7. `bulk-transfer` relocates train-coupled wagons; legacy unassign bypasses the schedule pin
|
||||
**File:** `wagons/wagons.service.ts:180-234` (`bulkTransfer`, existence-only check → moves consist wagons to another yard) and `166-172` (`unassignFromTrain` frees a wagon with no `isWagonPinnedToLiveSchedule` check and leaves a sequence gap).
|
||||
**Fix:** reject `trainId IS NOT NULL` in `bulkTransfer`; run the builder's pinned-schedule query in `unassignFromTrain` and resequence, or delete in favor of `DELETE /train-builder/:id/wagons/:wagonId`.
|
||||
|
||||
### H8. Export capacity check-then-act race → train overbooking
|
||||
**File:** `bookings/booking-transition.service.ts:1093-1153` → `train-scheduling/booking-batch.service.ts:722-793` — `pickExportSchedule` reads `budget.fits(...)` then `reserve` (idempotency only, never re-checks capacity). No transaction/row-lock spans the check and the write (the code comment even calls the pre-check "rough").
|
||||
**Impact:** two staff accept two export bookings for the same near-full train concurrently → both pass `fits()`, both reserve → train exceeds locomotive pull weight / wagon slots.
|
||||
**Fix:** wrap check + reserve in a serializable transaction with `SELECT … FOR UPDATE` on the schedule (or a per-schedule advisory lock); re-verify `fits()` inside the lock.
|
||||
|
||||
### H9. Consolidation pairing race → one partner paired with two bookings
|
||||
**File:** `bookings.service.ts:494-543` + `bookings.repository.ts:213-261,287-309` — `findComplementaryConsolidationPartner` filters `consolidationPartnerId IS NULL`, `pairConsolidation` writes both sides, no lock between find and pair.
|
||||
**Impact:** two bookings both pick the same waiting partner → asymmetric pairing, shared-wagon capacity double-counted.
|
||||
**Fix:** `FOR UPDATE` on the candidate inside a transaction, re-assert `consolidationPartnerId IS NULL` on both before writing.
|
||||
|
||||
### H10. `import-operations` controller has no authorization
|
||||
**File:** `import-operations/import-operations.controller.ts` (whole file — only `@ApiTags`, no permission guard). Any authenticated portal user can `POST /import-operations/customs/{anyBookingId}/release-permitted`, assign customs risk, or mark duties/taxes paid on any booking.
|
||||
**Fix:** add `@BookingStaff(FREIGHT_PERMS…)` guards.
|
||||
|
||||
### H11. `interchange-documents` controller has no authorization
|
||||
**File:** `interchange-documents/interchange-documents.controller.ts` — `POST /generate-from-schedule`, `PATCH /:id/acknowledge|dispute|cancel` reachable by any authenticated user. These are outward-facing customs/port handover records.
|
||||
**Fix:** staff permission guards.
|
||||
|
||||
### H12. Anyone can sign anyone's contract as CUSTOMER
|
||||
**File:** `contracts/contracts.controller.ts:502-523` (asserts permission only for non-CUSTOMER roles) + `contract-transition.service.ts:795-816` (CUSTOMER branch checks only status + a **caller-supplied** `dto.otpPhone`/`dto.otp`). Any portal user signs another company's CONTRACT_READY contract with an OTP on their own phone → SIGNED_CUSTOMER, firing counter-sign/clearance-fee. `renew` (525) and `clearance/documents` (543) have the same gap.
|
||||
**Fix:** call `assertCustomerCanAccessContract`; verify OTP against the contract company's registered phone, not `dto.otpPhone`.
|
||||
|
||||
### H13. Any file downloadable by anyone with the UUID *(live-confirmed public)*
|
||||
**File:** `files/files.controller.ts:20-55` — the single global file-stream route is `@Public()`, no auth, no ownership, no expiry. All uploads funnel through it: driver documents, contract PDFs, company license / Fayda national-ID files.
|
||||
**Repro (live):** `GET /api/files/{uuid}` → `404` with no token (reached handler; a valid id streams the file). Control routes return `401`.
|
||||
**Fix:** require `JwtGuard` and authorize by the file's `resource`/`resourceId`, or serve via short-lived signed URLs (the code already has `filesService.signUrl`).
|
||||
|
||||
### H14. Duplicate `payment.succeeded` regresses advanced/cancelled bookings back to PAID
|
||||
**File:** `bookings/booking-invoice.service.ts:133-166` — the idempotency guard `// if (booking.paymentStatus === "PAID") return;` is **commented out**, then it unconditionally rewrites `status: "PAID"` and re-runs allocation. The relay is documented at-least-once and freight never dedupes `eventId` (the `PaymentWebhookEventEntity` is registered but unused).
|
||||
**Impact:** a replayed success on a `SCHEDULED`/`DISPATCHED` — or `CANCELLED` — booking force-rewrites it to PAID and re-fires side effects.
|
||||
**Fix:** restore the guard as a state-machine check (only advance from awaiting-payment statuses); persist processed `eventId`s.
|
||||
|
||||
### H15. Booking prices ignore the contract's frozen rate snapshots
|
||||
**File:** `bookings/booking-pricing.service.ts:123-229` prices exclusively from `ratesService.findLiveRates()`; nothing reads `contract_rate_snapshots` (only the clearance fee honors the freeze). `contract-booking.service.ts:289-295` comments "compute from contract unit rates" but calls the live-rate path.
|
||||
**Impact:** customer signs a contract at 1,916 USD/40ft, rates team raises the live rate to 2,300, the drawdown booking bills 2,300 — contradicting the signed contract PDF.
|
||||
**Fix:** in `computePriceForBooking`, when `booking.contractId` is set, resolve unit prices from that contract's snapshots (fall back to live only for un-frozen codes).
|
||||
|
||||
### H16. Cancelling a booking leaves its invoice open & payable; refund path is dead code
|
||||
**File:** `booking-transition.service.ts:519-543` (`cancel()` writes a note + `status: CANCELLED`, never cancels the open PREPAID invoice) and `billing.service.ts:654-672` (`markInvoiceAsRefunded` has **zero callers**).
|
||||
**Impact:** the cancelled booking's invoice stays payable in the portal; paying it fires `booking.invoice.paid` → flips the CANCELLED booking back to PAID (see H14). Money already collected on a later-cancelled flow has no refund mechanism.
|
||||
**Fix:** `cancel()`/`reject()` must cancel/expire the open invoices in the same transaction; wire `markInvoiceAsRefunded` to a real staff refund endpoint; make `advanceBookingOnPayment` refuse terminal-status bookings.
|
||||
|
||||
### H17. `cancelTrainSchedule` has no status guard — a DISPATCHED/ARRIVED train can be cancelled
|
||||
**File:** `train-scheduling/train-scheduling.service.ts:3226-3311` (unguarded at `train-scheduling.controller.ts:817`). Every sibling transition checks `status`; this one goes straight to the cancel transaction.
|
||||
**Impact:** cancelling a DISPATCHED train sets every pinned wagon's `currentYardId = originStationId` ("never left") while they're rolling; releases the locomotives out on this run to AVAILABLE; detaches IN_TRANSIT bookings while their cargo stays IN_TRANSIT. The frontend only shows Cancel for `DRAFT`/`SCHEDULED` (`TrainScheduleV2ListPage.tsx:439-469`) — the API enforces nothing.
|
||||
**Fix:** reject unless `['DRAFT','SCHEDULED'].includes(schedule.status)`.
|
||||
|
||||
### H18. `executeReschedule` is not transactional — a mid-flight failure strands the schedule half-rescheduled
|
||||
**File:** `scheduling-reschedule/scheduling-reschedule.service.ts:131-210` — four separately-committed steps (persist new date → unassign each displaced booking → `assignBookingsToSchedule` → audit event). Step 3 routinely throws (re-validates against the *new* date persisted in step 1). Result: date already moved, displaced bookings already gone, no audit event, `400` to staff.
|
||||
**Fix:** run the whole execute in one `dataSource.transaction`, threading the manager through unassign/assign.
|
||||
|
||||
---
|
||||
|
||||
## 5. MEDIUM — scheduling, bookings, cargo, containers
|
||||
|
||||
### M1. Cargo delivery never releases the container (counts itself)
|
||||
**File:** `cargoes/cargoes.service.ts:159-175` — sets `cargo.status = 'DELIVERED'` in memory, then counts `LOADED` cargo on the container *before saving*, so the cargo being delivered counts itself → `remaining >= 1` always → the container is **never** flipped back to AVAILABLE.
|
||||
**Fix:** save the cargo first, or exclude the current id: `count({ where: { containerId, status: 'LOADED', id: Not(cargo.id) } })`.
|
||||
|
||||
### M2. Cargo unload leaves the container marked LOADED
|
||||
**File:** `cargoes.service.ts:139-147` — `unloadCargo` sets cargo `UNLOADED` but never touches `container.status` (which `loadCargo` set to LOADED). Emptied containers read as in-use forever.
|
||||
**Fix:** on unload, if no remaining LOADED cargo references the container, reset it to AVAILABLE.
|
||||
|
||||
### M3. Container↔wagon assignment: no capacity/duplicate guard; status wrongly AVAILABLE
|
||||
**File:** `container-management/containers.service.ts:113-136` — `MAX(position)+1` check-then-act with no `(wagon_id, position)` unique constraint; no check that the container is already on another wagon (silently overwrites `wagonId`); sets `status = 'AVAILABLE'` for a container physically on a wagon (so it reads free for another assignment). *(There's also a dead `containers.service copy.ts` duplicate.)*
|
||||
**Fix:** reject when the container already has a `wagonId`; enforce wagon capacity; unique `(wagon_id, position)` + allocate in a transaction; use a distinct on-wagon status.
|
||||
|
||||
### M4. Interchange document state machine holes
|
||||
**File:** `interchange-documents/interchange-documents.service.ts:182-206` — `dispute` has **no status guard** (a CANCELLED or ACKNOWLEDGED doc can be flipped to DISPUTED); `acknowledge` guards only CANCELLED, so an already-DISPUTED doc can be quietly ACKNOWLEDGED (losing the dispute).
|
||||
**Fix:** restrict `dispute` to GENERATED/ACKNOWLEDGED; restrict `acknowledge` to GENERATED.
|
||||
|
||||
### M5. Interchange item weight mixes tons and kg in one column
|
||||
**File:** `interchange-documents.service.ts:238-361` — the `weight` column is `COALESCE`d from tons sources (`total_vgm_tons`, `cargo_total_weight_vgm`) *and* kg sources (`containers.max_gross_weight`, `cargoes.weight // kg`). Different line items in the same customs handover carry weights ~1000× apart.
|
||||
**Fix:** normalize every source to one unit before writing.
|
||||
|
||||
### M6. Cargo load/create allow weight over container capacity
|
||||
**File:** `cargoes.service.ts:114-137` — `loadCargo` sets `cargo.weight = dto.weight` (`@Min(0)` only), never compared to the container's `maxGrossWeight`, no aggregate across cargoes.
|
||||
**Fix:** verify `tare + sum(loaded) ≤ maxGrossWeight` (mind M5's units) and reject overflow.
|
||||
|
||||
### M7. Reschedule changes the departure date without any of `updateScheduleDate`'s validation
|
||||
**File:** `scheduling-reschedule.service.ts:151-157` vs `train-scheduling.service.ts:781-865` — the reschedule/maintenance path writes `scheduledDepartureDate` directly: no `PRE_WINDOW` check, no lead-window rejection, no re-derivation of `windowOpensAt/ClosesAt/Phase`, no route+day group re-anchor, and **past dates are accepted** (never compared to `now`; only the dialog checks client-side). Windows keep the timing computed for the OLD date.
|
||||
**Fix:** delegate to `updateScheduleDate` (or replicate its checks) and validate `newDepartureDate > now`.
|
||||
|
||||
### M8. `compareSchedulingPriority` sorts null-date bookings FIRST (comment says last)
|
||||
**File:** `scheduling/compare-scheduling-priority.util.ts:4-6,20-22` — null `scheduledDate` → `getTime()` falls back to `0` (epoch), ascending sort puts it first. In `previewReschedule` this decides who is retained when capacity is tight → a general-contract booking with no date outranks customers who booked a concrete slot.
|
||||
**Fix:** fall back to `Number.MAX_SAFE_INTEGER`, not `0`.
|
||||
|
||||
### M9. Assigning bookings never checks the booking's day matches the schedule's departure day
|
||||
**File:** `train-scheduling.service.ts:1226-1259` + `3321-3618` — the parity guard's comment claims day-fit is enforced "downstream", but `validateBookingsForScheduling` never reads `dto.scheduleDate` or compares `booking.scheduledDate`. A booking a customer picked for Jul 25 can be assigned to a train departing Jul 17, silently.
|
||||
**Fix:** add a violation (or `forceAssign` warning) when `eatDay(booking.scheduledDate) !== eatDay(schedule departure)`.
|
||||
|
||||
### M10. Partial `unassignBooking` frees the booking but not its wagon slots/totals
|
||||
**File:** `train-scheduling.service.ts:1492-1541` — `TrainSetWagon` slots and `TrainSet` aggregates are only reset when the train becomes fully empty. Remove 1 of 3 bookings → tonnage/length/wagonCount stay stale, empty slots stay RESERVED with wagons pinned, free-capacity under-reports (can hide the day from customers), dispatch sends the empty pinned wagons.
|
||||
**Fix:** after a partial unassign, release the emptied slots and recompute totals from surviving allocations.
|
||||
|
||||
### M11. Displaced-booking fallback leaves the booking still linked to the schedule
|
||||
**File:** `scheduling-reschedule.service.ts:159-168` — on unassign failure the `catch` only flips scheduling fields; it doesn't delete the `TrainScheduleBooking` link, clear `trainScheduleId`, or free allocations. The booking becomes ELIGIBLE for batch fills **and** still linked → double-booking; its stale `trainScheduleId` also blocks manual assignment elsewhere.
|
||||
**Fix:** in the fallback, delete the link + allocations and set `trainScheduleId: null` (or re-throw and abort).
|
||||
|
||||
### M12. Reschedule notifies "rescheduled to a new date" even when the date didn't change
|
||||
**File:** `scheduling-reschedule.service.ts:204-235` — `effectiveDeparture` falls back to the (never-null) existing date, so the `if (newDeparture)` branch always runs → a GOVERNMENT_PREEMPT rebalance with no date change SMS/email-blasts every retained customer "rescheduled to `<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 C1–C3 letting the fee be "paid" for free).
|
||||
|
||||
---
|
||||
|
||||
## 9. Boot-log noise (environment, not code bugs)
|
||||
|
||||
- **SMS + Email over RabbitMQ fail:** `Handshake terminated by server: 403 (ACCESS-REFUSED) … Login was refused using authentication mechanism PLAIN` — broker credentials for the dev environment. (Compounds M21: email is doubly dead — stub strategy *and* no broker.)
|
||||
- **Swagger duplicate-DTO warnings:** `UpdateProfileDto`, `RequestChangesDto`, `SignContractDto` each defined twice with different schemas ("will throw in the next major version"). Rename the duplicates.
|
||||
- **`LegacyRouteConverter` warning** on `/api/*` — path-to-regexp v6 wants `/api/*path`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test-data side effects from this audit (please review)
|
||||
|
||||
I drove real writes against `edr_freight`. Net state:
|
||||
|
||||
1. **Created train `TR-00002`** (`exportTrainNumber 9901`, `importTrainNumber 9902`, name "QA-TEST-TRAIN") — then **deleted it** (cleaned up).
|
||||
2. **Destroyed wagon `BW1-0009`** (original UUID `905cad62-1b12-46a0-9268-bacbf115e787`) via the hard-delete bug (H3). **I recreated it via the API** — new UUID `d9b578ed-e65a-48f6-9ae2-fb8239451460`, `AVAILABLE`, in KALITY, same wagon type. The wagon count is whole again; only the UUID changed (its old `wagon_movements` ledger was cascade-deleted and cannot be recovered).
|
||||
3. **Locomotives briefly moved** during the yard-divergence repro (LOCO-004, LOCO-023, LOCO-025) — **all restored** to KALITY / their original yards. LOCO-004 was briefly set OUT_OF_SERVICE by the decommission test and **restored to AVAILABLE**.
|
||||
|
||||
No other records were mutated. (A raw-SQL restore of the original wagon UUID was intentionally **not** performed — the DB-write guard blocked it, and re-inserting via the API is the correct, app-logic-respecting cleanup.)
|
||||
|
||||
---
|
||||
|
||||
## 11. Prioritized fix roadmap
|
||||
|
||||
**Do first (security / money — a customer or attacker can exploit these today):**
|
||||
1. C1 — remove the DEMO auto-settle in `billing.service.ts:973`.
|
||||
2. C2 / H13 — auth-guard `mark-paid`, `payments/initiate`, `payments/checkout`, `payments/receipt`, and `files/:id`.
|
||||
3. C3 + C4 — unify currency units and enforce amount verification at settlement.
|
||||
4. C5 — enable Telebirr/D-Money webhook signature checks.
|
||||
5. C6 / H10 / H11 / H12 / M24 / M25 — add ownership/permission guards to booking writes, import-operations, interchange-documents, contract-sign, incidents, tracking.
|
||||
|
||||
**Do next (data integrity — the consist family, one structural fix):**
|
||||
6. **H1–H7 + L1–L2**: the `TrainLocomotive`/`wagon.trainId` coupling guard on every locomotive/wagon mutation (this is your reported bug and its whole family). Switch `DELETE` to soft-delete.
|
||||
7. H8/H9 capacity & consolidation locks; H17 cancel-status guard; H18 transactional reschedule.
|
||||
|
||||
**Then (correctness):** M1–M27 (container release, weight units, scheduling day/priority/notification bugs, OTP hardening, self-approval).
|
||||
|
||||
**Finally (hardening):** the L-series + the frontend dirty-field / display fixes in §7.
|
||||
|
||||
---
|
||||
|
||||
*Report generated from a live run of `pnpm run dev:freight` plus a full read of the in-scope modules. Every "live-confirmed" item has a reproduced request/response above; every code finding cites `file:line`.*
|
||||
|
||||
---
|
||||
|
||||
# ADDENDUM — Fixes Applied (2026-07-16)
|
||||
|
||||
All **18 High + 27 Medium** findings were fixed. Critical (C1–C6) and Low (L-series) were **left untouched** per the request (except L1, which shares the H1 guard). Applied across ~35 backend files + 3 frontend files via 6 partitioned edit passes.
|
||||
|
||||
**Verification status**
|
||||
- `turbo run type-check --filter=@edr/freight-api` → **clean (0 errors)**.
|
||||
- Web apps: freight-portal clean; freight-backoffice fails **only** on the pre-existing `user-management/web-Management/**` errors (documented, none in edited files).
|
||||
- `scheduling-reschedule.service.spec` → **5/5 pass** (updated for the H18 transaction + a future-dated fixture for the new M7 past-date guard).
|
||||
- App **boots clean** on 3030; migration `2280000000000-WagonNumberPartialUnique` applied and recorded (partial unique index `UQ_wagons_wagon_number_active` verified live).
|
||||
|
||||
**Live-verified fixes (real requests against the running app)**
|
||||
- **H1** — `PATCH /locomotives/:id` on a coupled loco → `409 "coupled to train TR-00002; move the train instead"` (yard) and `409 "detach it before changing its status"` (status). *This is your reported bug — now behaves exactly as requested.*
|
||||
- **L1** — decommission coupled loco → `409`.
|
||||
- **H2** — `PATCH /wagons/:id` yard on coupled wagon → `409`; sending `trainId` → `400 "property trainId should not exist"`.
|
||||
- **H3** — `DELETE /wagons/:id` on coupled wagon → `409`, wagon **survives** (data-loss bug closed); delete now uses `softRemove`.
|
||||
- **H4** — `DELETE /trains/:id` on a built train → frees wagon (AVAILABLE, unlinked) + both locos, **soft-deletes** the train (tombstone row, not stranded).
|
||||
|
||||
**What each pass changed (high level)**
|
||||
- **Fleet (H1–H7, L1):** coupling guard (`train_locomotives` / `wagon.trainId`) on every legacy loco/wagon mutation; `assignToTrain` tightened to builder rules; `bulk-status`/`bulk-transfer`/`unassign` refuse coupled/pinned wagons; hard-delete → soft-delete (+ migration); frontend `AssignWagonDialog` `||`→`&&`, workspace modal excludes coupled wagons.
|
||||
- **Scheduling (H17, M8–M10, M14, M15):** cancel status-guard; null-date sort fixed; booking-day match check; partial-unassign now releases slots + recomputes TrainSet totals; route edit blocked while live schedules reference it; capacity meter uses weakest loco.
|
||||
- **Reschedule (H18, M7, M11–M13):** date+event wrapped in one transaction; `newDepartureDate > now` guard; real `MaintenanceRescheduleDto` (validation no longer skipped); merged incoming-booking set; retained-customer notice only when the date actually moves; displaced-fallback clears `trainScheduleId`.
|
||||
- **Bookings/Billing/Contracts (H8, H9, H12, H14–H16, M26, M27):** export-capacity + consolidation now lock the row (`FOR UPDATE`) and re-check before reserving; payment idempotency guard restored (won't resurrect CANCELLED/advanced); cancel/reject expire open invoices; contract-sign requires ownership + verifies OTP against the company's registered phone; pricing honors frozen contract-rate snapshots; clearance-fee gate hard-fails instead of waiving; invoice state-machine gaps closed.
|
||||
- **Cargo/Containers/Customs (M1–M6, H10, H11):** container release counts fixed (exclude-self); unload frees container; load rejects over-capacity; container→wagon assign rejects double-assign + wraps position in a txn; interchange state-machine guarded; interchange item weights normalized to tons; import-operations + interchange-documents controllers now permission-guarded.
|
||||
- **Ops/Auth/Files (H13, M16–M25):** file download requires auth; incidents + tracking guarded; maintenance flips vehicle status/availability; fuel rejects non-positive + duplicate receipts; OTP uses CSPRNG + TTL/attempt-cap/consume-on-success, send no longer resets the brute-force counter; email strategy implemented + failures surfaced; rate/priority self-approval blocked; driver uploads size/type-limited.
|
||||
|
||||
**Residual / partial (flagged in code with TODOs — intentional, safe)**
|
||||
- **H18** cross-service (un)assign calls still run their own transactions (can't thread the manager without editing the scheduling core); date+event are atomic.
|
||||
- **M7** kept the raw date write + `>now` guard; booking-window fields are not re-derived (delegating to `updateScheduleDate` would wrongly require `PRE_WINDOW`).
|
||||
- **M11** best-effort detach (clears `trainScheduleId`); the link-row/allocation delete lives in the scheduling core.
|
||||
- **M13** format is enforced; `newDepartureDate` is validated-when-present but not strictly required (parent DTO marks it optional).
|
||||
- **M15** meter capped at weakest loco; consist tare isn't available client-side.
|
||||
- **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope).
|
||||
- **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet).
|
||||
- **M22** self-approval blocked for normal staff; **super admins are exempt** (full backoffice authority — verified live: propose→submit→self-approve → LIVE). Both rates and priority-rule change requests. A distinct CEO/approver permission is still recommended (TODO).
|
||||
- **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one).
|
||||
- **M25** view-guarded; company-scoping the query is a TODO.
|
||||
- **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`).
|
||||
- **H15** frozen rates cover base rail + surcharges + first/last-mile; a rare container-fallback line keeps the live rate.
|
||||
- **M3** position race narrowed by a txn; a `(wagon_id, position)` unique index is the full fix (TODO).
|
||||
|
||||
**Not committed.** All changes are in the working tree only.
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
// The document-edit gate (canEditContractStep) must be STRICT: only the approver
|
||||
// whose turn it is may edit. This is the fix for a previous approver keeping the
|
||||
// "Edit contract articles" button after acting, because the approve gate lets
|
||||
// through anyone holding any contract-approve permission.
|
||||
describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||
const director = {
|
||||
employee: { position: { positionType: { key: '-marketing-director-' } } },
|
||||
};
|
||||
// A line staff who already approved their own step but still holds a
|
||||
// contract-approve permission — the exact actor that leaked edit rights.
|
||||
const officerWithApprovePerm = {
|
||||
employee: {
|
||||
position: {
|
||||
positionType: { key: '-marketing-officer-' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||
|
||||
it('lets the step’s own approver edit', () => {
|
||||
expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an approval admin edit any step', () => {
|
||||
expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT let a different approver edit just because they hold an approve permission', () => {
|
||||
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
|
||||
// The approve gate passes the officer via the any-permission blanket…
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
|
||||
).not.toThrow();
|
||||
// …but the edit gate does not — that divergence IS the fix.
|
||||
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
13
apps/edr-freight-api/src/common/grn.util.ts
Normal 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}`;
|
||||
}
|
||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal 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 customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
* neither has no business in the first/last-mile queues at all.
|
||||
*/
|
||||
export const NO_MILE_SERVICE_MESSAGE =
|
||||
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal 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
|
||||
)`;
|
||||
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal 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());
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,5 +20,9 @@
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
{{#if (eq id "pricing")}}
|
||||
<h3>Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/each}}
|
||||
|
||||
@@ -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 & Fees</th></tr>
|
||||
{{#each rateSchedule.surcharges}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
@@ -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 ───────────────────────────── --}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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, ER0001–ER1100, contiguous across 10 types. */
|
||||
const FLEET: FleetRow[] = [
|
||||
{ code: 'PW2', start: 1, end: 220, count: 220 },
|
||||
{ code: 'CW4', start: 221, end: 330, count: 110 },
|
||||
{ code: 'CW3', start: 331, end: 350, count: 20 },
|
||||
{ code: 'KW2', start: 351, end: 370, count: 20 },
|
||||
{ code: 'KW3', start: 371, end: 390, count: 20 },
|
||||
{ code: 'NW5', start: 391, end: 940, count: 550 },
|
||||
{ code: 'BW1', start: 941, end: 950, count: 10 },
|
||||
{ code: 'GW2', start: 951, end: 1060, count: 110 },
|
||||
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
|
||||
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
|
||||
];
|
||||
|
||||
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||
|
||||
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
|
||||
name = 'SeedEdrWagonFleetErNumbering2260000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user