mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
Merge pull request #1453 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Give a transit agent a portal login.
|
||||
*
|
||||
* Every column is NULLABLE and nothing is backfilled: production already holds
|
||||
* transit agents that exist only as a GL-assignable roster entry, and they must
|
||||
* keep working untouched. An agent gains an account when staff invite it — at
|
||||
* which point `user_id` is filled in — so "has a login" is exactly
|
||||
* `user_id IS NOT NULL`, and the assignment flow never has to care.
|
||||
*
|
||||
* The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres
|
||||
* treats NULLs as distinct in a plain unique index only per-row; being explicit
|
||||
* documents that many account-less agents are expected to coexist.
|
||||
*/
|
||||
export class TransitAgentAccount3790000000000 implements MigrationInterface {
|
||||
name = "TransitAgentAccount3790000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS email varchar(150),
|
||||
ADD COLUMN IF NOT EXISTS phone_number varchar(30)`,
|
||||
);
|
||||
// One IAM account can back at most one transit agent — otherwise a single
|
||||
// login would resolve to two agents in `findByUserId`.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id
|
||||
ON freight.transit_agents (user_id)
|
||||
WHERE user_id IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
// Case-insensitive, matching how the repository checks for duplicates.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email
|
||||
ON freight.transit_agents (lower(email))
|
||||
WHERE email IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_email`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS phone_number,
|
||||
DROP COLUMN IF EXISTS email,
|
||||
DROP COLUMN IF EXISTS user_id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE
|
||||
* scheduling field — unassign clears it to NULL — so a paid booking pulled off
|
||||
* a train had nothing left to price a cancellation fee or credit against
|
||||
* ("This booking has no wagon requirement to cancel from."). This column is
|
||||
* stamped once, at first allocation, and never cleared: cancellation reads it
|
||||
* (falling back to a computed count for bookings never allocated).
|
||||
*/
|
||||
export class BookingCancellationWagons3800000000000 implements MigrationInterface {
|
||||
name = 'BookingCancellationWagons3800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2)
|
||||
`);
|
||||
// Backfill the bookings that still carry a live stamp.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET cancellation_wagons = wagons_required
|
||||
WHERE cancellation_wagons IS NULL
|
||||
AND wagons_required IS NOT NULL
|
||||
AND wagons_required > 0
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS cancellation_wagons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Transit assignments — one row per (booking × transit agent), so an agent
|
||||
* handles many bookings.
|
||||
*
|
||||
* Deliberately NOT the existing transit-assignee handshake on bookings
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
|
||||
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
|
||||
* and GL Djibouti about WHO will handle customs. This is the work record —
|
||||
* status, timings and documents — and nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no duration column on purpose. The time taken after the train
|
||||
* arrives is `finished_at − bookings.arrived_at`, and both halves already
|
||||
* exist; storing the difference would be a third source of truth that goes
|
||||
* stale the moment either timestamp is corrected. It is computed on read.
|
||||
*
|
||||
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
|
||||
* and `resource_id = transit_assignments.id`. That table already carries the
|
||||
* MinIO object, the upload time (`created_at`), the uploader, the edit time
|
||||
* (`updated_at`) and the supersede history, so no file table is added here.
|
||||
*/
|
||||
export class TransitAssignments3810000000000 implements MigrationInterface {
|
||||
name = "TransitAssignments3810000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL,
|
||||
transit_agent_id uuid NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
assigned_by_user_id uuid,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
|
||||
CONSTRAINT fk_transit_assignments_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
|
||||
CONSTRAINT fk_transit_assignments_agent
|
||||
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// One live assignment per (booking, agent). Partial so a soft-deleted row
|
||||
// never blocks re-assigning the same agent to the same booking later.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
|
||||
ON freight.transit_assignments (booking_id, transit_agent_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// The two list directions: a booking's assignments, and an agent's workload.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
|
||||
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
|
||||
ON freight.transit_assignments (transit_agent_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user