mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
add migration file when the app is starting
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal file
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal 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();
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user