Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshalyordanos
2026-09-06 10:32:09 +03:00
217 changed files with 8668 additions and 804 deletions

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Public document library for the freight portal (PDFs, Markdown write-ups,
* PowerPoint decks about the platform), managed from the backoffice. Each row
* is one whole file stored in MinIO under `publications/` — a re-upload
* replaces the object and the row's file columns, there is no per-version
* history table like `support_documents` has.
*/
export class Publications3850000000000 implements MigrationInterface {
name = 'Publications3850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.publications (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
title varchar(200) NOT NULL,
description text,
category varchar(60),
file_key varchar(512) NOT NULL,
file_name varchar(255) NOT NULL,
file_mime_type varchar(120) NOT NULL,
file_size_bytes bigint NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
published boolean NOT NULL DEFAULT true,
published_at timestamptz,
uploaded_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// Serves the public list: published rows in display order.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
ON freight.publications (published, sort_order)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds DJF to `freight.payments_currency_enum` — the only currency column in
* the schema backed by a real Postgres enum (every other currency column is
* a plain varchar and needed no migration).
*
* This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD
* VALUE` cannot be used within the same transaction that added it (Postgres
* restriction, still true on PG 12+), and migrations here run one-per-
* transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert
* that writes 'DJF' into `payments.currency` to this file.
*/
export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface {
name = 'AddDjfPaymentsCurrency3860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`);
}
public async down(): Promise<void> {
// Postgres cannot drop a single enum value. Reverting would require
// recreating the type and every dependent column/constraint — out of
// scope for a currency addition; leave it in place.
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the DJF toggle to `manual_payment_settings`, alongside the existing
* `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF
* invoices are bank-transfer-settleable from day one.
*/
export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface {
name = 'AddDjfManualPaymentSetting3870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings
ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled;
`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `exchange_settings` was a single-row table holding the USD→ETB fallback
* only. Restructures it to one row per currency so DJF (and any future
* currency) gets its own fallback rate, source and sync timestamp instead of
* a parallel column per currency.
*/
export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface {
name = 'ExchangeSettingsPerCurrency3880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5);
`);
// The single pre-existing row was always the USD→ETB fallback.
await queryRunner.query(`
UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency
ON freight.exchange_settings (currency) WHERE deleted_at IS NULL;
`);
// Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so
// pricing has a usable fallback before the first successful CBE fetch.
await queryRunner.query(`
INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at)
SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF');
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`);
await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empty container import is base rail freight for equipment carrying no cargo,
* so it is sold per lane exactly like laden container freight.
*
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
* Drop-and-recreate is the established shape for this constraint — see
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
*/
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
name = 'EmptyContainerRateScope3890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
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> {
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', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Whether a booking moves cargo or bare equipment.
*
* EMPTY is container freight carrying nothing — the box itself is the shipment,
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
* separate column rather than a third `freight_type`: an empty booking is still
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
* places whose else-arm means "container".
*
* Every existing row is LADEN, which the default supplies — no backfill needed.
*/
export class BookingCargoCondition3900000000000 implements MigrationInterface {
name = 'BookingCargoCondition3900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
`);
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
// freight. Enforced here so no API path can file the combination.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
// The booking queues filter empties out of (and into) the laden lists.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
ON freight.bookings (cargo_condition)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Contract paper for empty container import.
*
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
* contract can commit to moving bare equipment.
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
* resolves for those contracts. It carries no customs variant: an empty box
* has no declaration to clear, the same reason intercity is unsuffixed.
*/
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
name = 'EmptyContainerContractTemplate3910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
);
}
}