import { MigrationInterface, QueryRunner } from 'typeorm'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD54ContextualFindingCatalog1788976800000 implements MigrationInterface { name = 'PhaseD54ContextualFindingCatalog1788976800000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE finding_catalog_items ADD COLUMN suggested_severity smallint, ADD CONSTRAINT chk_finding_catalog_items_suggested_severity CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10) `); await queryRunner.query(` ALTER TABLE inspection_findings ADD COLUMN suggested_severity smallint, ADD COLUMN severity smallint, ADD CONSTRAINT chk_inspection_findings_suggested_severity CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10), ADD CONSTRAINT chk_inspection_findings_severity CHECK (severity IS NULL OR severity BETWEEN 1 AND 10) `); await queryRunner.query(` CREATE TABLE finding_catalog_asset_type_profiles ( asset_type_id uuid PRIMARY KEY, reason text NOT NULL, updated_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_finding_catalog_asset_type_profiles_reason CHECK (char_length(btrim(reason)) >= 5), CONSTRAINT fk_finding_catalog_asset_type_profiles_type FOREIGN KEY (asset_type_id) REFERENCES asset_types(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_asset_type_profiles_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_asset_type_profiles_updated ON finding_catalog_asset_type_profiles (updated_at DESC) `); await queryRunner.query(` CREATE TABLE finding_catalog_item_asset_types ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), catalog_item_id uuid NOT NULL, asset_type_id uuid NOT NULL, created_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_catalog_item_asset_types_pair UNIQUE (catalog_item_id, asset_type_id), CONSTRAINT fk_finding_catalog_item_asset_types_item FOREIGN KEY (catalog_item_id) REFERENCES finding_catalog_items(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_item_asset_types_type FOREIGN KEY (asset_type_id) REFERENCES asset_types(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_item_asset_types_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_item_asset_types_type ON finding_catalog_item_asset_types (asset_type_id, catalog_item_id) `); await queryRunner.query(` CREATE TABLE finding_catalog_asset_overrides ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, catalog_item_id uuid NOT NULL, is_enabled boolean NOT NULL, reason text NOT NULL, created_by uuid, updated_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_catalog_asset_overrides_pair UNIQUE (asset_id, catalog_item_id), CONSTRAINT chk_finding_catalog_asset_overrides_reason CHECK (char_length(btrim(reason)) >= 5), CONSTRAINT fk_finding_catalog_asset_overrides_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_asset_overrides_item FOREIGN KEY (catalog_item_id) REFERENCES finding_catalog_items(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_asset_overrides_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_finding_catalog_asset_overrides_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_asset_overrides_asset ON finding_catalog_asset_overrides (asset_id) `); await queryRunner.query(` CREATE TABLE finding_catalog_proposals ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), finding_id uuid NOT NULL, asset_id uuid NOT NULL, asset_type_id uuid NOT NULL, proposed_title varchar(500) NOT NULL, proposed_legal_basis text, proposed_severity smallint, description text NOT NULL, status varchar(20) NOT NULL DEFAULT 'PENDING', resolved_catalog_item_id uuid, office_notes text, reviewed_by uuid, reviewed_at timestamptz, created_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_catalog_proposals_finding UNIQUE (finding_id), CONSTRAINT chk_finding_catalog_proposals_status CHECK (status IN ('PENDING', 'MATCHED', 'REJECTED')), CONSTRAINT chk_finding_catalog_proposals_severity CHECK (proposed_severity IS NULL OR proposed_severity BETWEEN 1 AND 10), CONSTRAINT fk_finding_catalog_proposals_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_finding_catalog_proposals_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_finding_catalog_proposals_asset_type FOREIGN KEY (asset_type_id) REFERENCES asset_types(id) ON DELETE RESTRICT, CONSTRAINT fk_finding_catalog_proposals_resolved_item FOREIGN KEY (resolved_catalog_item_id) REFERENCES finding_catalog_items(id) ON DELETE RESTRICT, CONSTRAINT fk_finding_catalog_proposals_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_finding_catalog_proposals_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_proposals_status ON finding_catalog_proposals (status, created_at DESC) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_proposals_asset_type ON finding_catalog_proposals (asset_type_id, status, created_at DESC) `); await queryRunner.query(` INSERT INTO finding_catalog_proposals ( finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis, proposed_severity, description, status, created_by, created_at, updated_at ) SELECT finding.id, finding.asset_id, asset.asset_type_id, finding.title, finding.legal_basis, finding.severity, finding.description, 'PENDING', finding.created_by, finding.created_at, finding.updated_at FROM inspection_findings finding INNER JOIN assets asset ON asset.id = finding.asset_id WHERE finding.catalog_item_id IS NULL ON CONFLICT (finding_id) DO NOTHING `); const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const roleRows = (await queryRunner.query( 'SELECT 1 FROM pg_roles WHERE rolname = $1', [appRole], )) as unknown[]; if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(` GRANT SELECT, INSERT, UPDATE ON TABLE finding_catalog_asset_type_profiles TO ${applicationRole} `); await queryRunner.query(` GRANT SELECT, INSERT, DELETE ON TABLE finding_catalog_item_asset_types TO ${applicationRole} `); await queryRunner.query(` GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE finding_catalog_asset_overrides TO ${applicationRole} `); await queryRunner.query(` GRANT SELECT, INSERT, UPDATE ON TABLE finding_catalog_proposals TO ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP TABLE finding_catalog_proposals'); await queryRunner.query('DROP TABLE finding_catalog_asset_overrides'); await queryRunner.query('DROP TABLE finding_catalog_item_asset_types'); await queryRunner.query('DROP TABLE finding_catalog_asset_type_profiles'); await queryRunner.query(` ALTER TABLE inspection_findings DROP CONSTRAINT chk_inspection_findings_severity, DROP CONSTRAINT chk_inspection_findings_suggested_severity, DROP COLUMN severity, DROP COLUMN suggested_severity `); await queryRunner.query(` ALTER TABLE finding_catalog_items DROP CONSTRAINT chk_finding_catalog_items_suggested_severity, DROP COLUMN suggested_severity `); } }