add migration file when the app is starting

This commit is contained in:
marshal
2026-06-02 16:43:37 +03:00
parent e4859b3eeb
commit f93a502247
9 changed files with 316 additions and 54 deletions

View File

@@ -1,6 +1,8 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -34,6 +36,14 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) {
throw new Error("Missing TypeORM DataSource options");
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
},
}),
SharedAuthModule,
IamModule.forRoot(),

View File

@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
DefaultUnit,
@@ -44,6 +45,7 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
const iamEntities = [
DefaultPosition,
@@ -90,26 +92,37 @@ const iamEntities = [
NotificationTemplate,
];
const iamMigrationsGlob = join(
dirname(require.resolve("@tria-plc/iamapi-common/package.json")),
"dist/db/migrations/*.js",
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
(): TypeOrmModuleOptions => {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight migrations
__dirname +
"/../../node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.js",
__dirname + "/../migrations/*.js",
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
}),
};
},
);

View File

@@ -0,0 +1,50 @@
import { DataSource, DataSourceOptions } from "typeorm";
/** Schemas required before TypeORM migrations and entity access. */
export const APPLICATION_SCHEMAS = [
"public",
"iam",
"freight",
"audit",
] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* TypeORM creates the migrations table before any migration runs. If `public` was
* dropped, current_schema() is null and CREATE TABLE migrations fails.
* IAM/freight migrations assume their schemas already exist.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(
`GRANT CREATE ON SCHEMA "${schema}" TO public`,
);
}
}
await bootstrap.query(
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
);
await bootstrap.destroy();
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
*/
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
name = 'CreateFreightLegacyBaseline1748550000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.train_status AS ENUM (
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.trains (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(32) NOT NULL UNIQUE,
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.bookings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reference VARCHAR(64) NOT NULL UNIQUE,
customer_id UUID NOT NULL,
train_id UUID,
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
previous_contract_id UUID,
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
first_mile_pickup_address TEXT,
last_mile_delivery_address TEXT,
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
is_hazardous BOOLEAN NOT NULL DEFAULT false,
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
start_date DATE,
end_date DATE,
financial_terms TEXT,
version_number INT NOT NULL DEFAULT 1,
approved_by_staff_id UUID,
approved_by_staff_at TIMESTAMPTZ,
signed_by_director_id UUID,
signed_by_director_at TIMESTAMPTZ,
signed_by_ceo_id UUID,
signed_by_ceo_at TIMESTAMPTZ,
priority_score INT NOT NULL DEFAULT 0,
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
consolidation_partner_id UUID,
origin_station VARCHAR(255),
destination_station VARCHAR(255),
service_type VARCHAR(100),
freight_type VARCHAR(100),
freight_subtype VARCHAR(255),
containers JSONB,
first_mile_enabled BOOLEAN DEFAULT false,
last_mile_enabled BOOLEAN DEFAULT false,
is_refrigerated BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
}
}

View File

@@ -4,6 +4,14 @@ export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationIn
name = 'AddBookingsRemainingForeignKeys1748800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const publicCustomersExists = await queryRunner.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) AS exists
`);
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
await queryRunner.query(`
UPDATE freight.bookings b
@@ -24,49 +32,51 @@ export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationIn
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
`);
// Remove bookings with no matching public.customers row
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
if (hasPublicCustomers) {
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
// ── freight.bookings FKs ────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
name = 'CreateFreightFilesTable1749100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource_id UUID NOT NULL,
resource VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
url TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type VARCHAR(255) 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_freight_files_resource"
ON freight.files (resource_id, resource);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
ON freight.files (resource_id, resource, code);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
}
}

View File

@@ -14,6 +14,7 @@ import type {
} from "@/types/rule-engine";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
@@ -52,6 +53,36 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
export const useContainerTypeOptions = (enabled = true) =>
useQuery({
queryKey: [
...QUERY_KEYS.RULE_ENGINE.list("container-types"),
"select-options",
],
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("container-types", {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
}),
enabled,
select: (result) => {
const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
const options = (result.data ?? []).map((row) => {
const label = String(row.label ?? "").trim();
const code = String(row.code ?? "").trim();
const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
const parts = [label || code || String(row.id), size].filter(Boolean);
return {
label: parts.join(" - "),
value: String(row.id),
};
});
return [noneOption, ...options];
},
});
export const useApprovalChain = (enabled: boolean) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.chain,

View File

@@ -25,6 +25,7 @@ import {
import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -104,16 +105,31 @@ const RuleEngineResourcePage = () => {
const editingId = editing?.id ? String(editing.id) : undefined;
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(config?.slug === "rates");
const formFields = useMemo(() => {
if (!config || config.slug !== "cargo-types") return config?.formFields ?? [];
const fallback = [{ label: "None", value: RULE_ENGINE_SELECT_NONE }];
if (!config) return [];
return config.formFields.map((field) =>
field.name === "parentGroupId"
? { ...field, options: cargoParentOptions ?? fallback }
: field,
config.slug === "cargo-types" && field.name === "parentGroupId"
? {
...field,
options:
cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
}
: config.slug === "rates" && field.name === "containerTypeId"
? {
...field,
options:
containerTypeOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
}
: field,
);
}, [config, cargoParentOptions]);
}, [config, cargoParentOptions, containerTypeOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -320,7 +336,8 @@ const RuleEngineResourcePage = () => {
initialRecord={editing}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={
config.slug === "cargo-types" && cargoParentOptionsLoading
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(config.slug === "rates" && containerTypeOptionsLoading)
}
onSubmit={handleFormSubmit}
/>

View File

@@ -333,7 +333,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
{ name: "containerTypeId", label: "Container type ID", type: "text", placeholder: "Optional UUID" },
{
name: "containerTypeId",
label: "Container type",
type: "select",
optional: true,
placeholder: "Select container type (optional)",
},
{
name: "tradeDirection",
label: "Trade direction",