diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 49f32be76..0fcc7a546 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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"; @@ -38,6 +40,14 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => config.get("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({ diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index a97adf263..bbac17e7a 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -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", - }), + }; + }, ); diff --git a/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts new file mode 100644 index 000000000..99fbcab61 --- /dev/null +++ b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts @@ -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 { + 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(); +} diff --git a/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts new file mode 100644 index 000000000..2455727bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts index 72a751f4b..50d052a24 100644 --- a/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts +++ b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts @@ -4,6 +4,14 @@ export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationIn name = 'AddBookingsRemainingForeignKeys1748800000000'; public async up(queryRunner: QueryRunner): Promise { + 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 diff --git a/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts new file mode 100644 index 000000000..ebb194833 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFreightFilesTable1749100000000 implements MigrationInterface { + name = 'CreateFreightFilesTable1749100000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`); + } +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 6db9b446f..1d6360209 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -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("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, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index cf24cdfbf..7f8f6cfa4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -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} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 23814f1b2..3fe1b59cb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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",