mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge branch 'dev' into freight/nati-2
# Conflicts: # apps/edr-freight-api/src/app.module.ts # apps/edr-freight-api/src/seed/freight-permissions.registry.ts # apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx # apps/edr-freight-web/backoffice/src/constants/URLS.ts # apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table controlling whether Finance may settle invoices by hand,
|
||||
* per currency (see ManualPaymentSettingsService). Defaults preserve the
|
||||
* pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual
|
||||
* settlement is the new capability and must be switched on deliberately (OFF).
|
||||
*/
|
||||
export class ManualPaymentSettings3560000000000 implements MigrationInterface {
|
||||
name = "ManualPaymentSettings3560000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.manual_payment_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
etb_enabled boolean NOT NULL DEFAULT false,
|
||||
usd_enabled boolean NOT NULL DEFAULT true,
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled)
|
||||
SELECT false, true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.manual_payment_settings;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Which desks work at which yard — the input to yard access scoping.
|
||||
*
|
||||
* Many-to-many: a position (what the user-management tree calls a department)
|
||||
* can cover several yards, and a yard is staffed by several positions. The
|
||||
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||
*
|
||||
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||
* soft-deleted position silently drops out of scope rather than granting it.
|
||||
*
|
||||
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||
* same pair later.
|
||||
*/
|
||||
export class YardPositions3560000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||
position_id uuid NOT NULL,
|
||||
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 ux_yard_positions_pair
|
||||
ON freight.yard_positions (yard_id, position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||
ON freight.yard_positions (position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Approval gate for consolidated (shared-wagon) bookings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A CONSOLIDATED booking does not: it shares one physical
|
||||
* wagon with another customer's booking, which means two customers' cargo, two
|
||||
* invoices and two liabilities riding the same wagon. That pairing is a
|
||||
* commercial decision, so it is reviewed by a person before Operations sees it.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves (booking_id +
|
||||
* partner_booking_id) so an approver can never approve one side of a shared
|
||||
* wagon and leave the other pending. Rows are never deleted; decided rows are
|
||||
* the audit trail of who approved which pairing and when.
|
||||
*
|
||||
* One PENDING row per booking at a time (partial unique index on each side of
|
||||
* the pair): a second request while one is undecided is a coordination failure,
|
||||
* not a workflow.
|
||||
*/
|
||||
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.consolidation_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
-- Who put the pairing up for review (the GL user who completed it) and
|
||||
-- who decided it. Both are recorded: the point of the gate is that they
|
||||
-- are different people.
|
||||
requested_by uuid,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
-- Snapshot of what was approved, so the audit trail still reads
|
||||
-- correctly after the bookings themselves move on.
|
||||
scheduled_date timestamptz,
|
||||
booking_reference varchar(50),
|
||||
partner_booking_reference varchar(50),
|
||||
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_consolidation_approvals_booking_status
|
||||
ON freight.consolidation_approvals (booking_id, status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
|
||||
ON freight.consolidation_approvals (status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per booking — on EITHER side of the pair, so the same
|
||||
// wagon can never collect two pending requests from its two halves.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
|
||||
ON freight.consolidation_approvals (booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
|
||||
ON freight.consolidation_approvals (partner_booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||
* scoping.
|
||||
*
|
||||
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||
* database. A migration is the one path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||
* the freight application row is absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT gen_random_uuid(),
|
||||
$1::varchar,
|
||||
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||
* delete trips the position/role permission foreign keys — and a half-removed
|
||||
* permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
YardViewAllPermission3570000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
|
||||
*
|
||||
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
|
||||
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
|
||||
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
|
||||
* batch (webhook never arrived) can be found and reconciled by conversation id.
|
||||
*/
|
||||
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
|
||||
name = "EimsBulkRegistration3580000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.eims_system_state
|
||||
DROP COLUMN IF EXISTS in_flight_conversation_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_bulk_conversation_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
|
||||
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
|
||||
* currency and its own payable invoice.
|
||||
*/
|
||||
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
|
||||
name = 'BookingClearanceCharge3590000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"type" character varying(20) NOT NULL,
|
||||
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
|
||||
"file_record_id" uuid,
|
||||
"amount" numeric(14,2),
|
||||
"currency" character varying(8),
|
||||
"invoice_id" uuid,
|
||||
"uploaded_by_staff_id" uuid,
|
||||
"uploaded_at" timestamptz,
|
||||
"billed_by_staff_id" uuid,
|
||||
"billed_at" timestamptz,
|
||||
"paid_at" timestamptz,
|
||||
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
|
||||
ON "freight"."booking_clearance_charge" ("booking_id", "type")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Per-booking clearance action history — drives the History tab. */
|
||||
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
|
||||
name = 'BookingClearanceEvent3600000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"action" character varying(64) NOT NULL,
|
||||
"label" character varying(500) NOT NULL,
|
||||
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
|
||||
"actor_id" uuid,
|
||||
"actor_name" character varying(150),
|
||||
"metadata" jsonb,
|
||||
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
|
||||
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
|
||||
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user