import { MigrationInterface, QueryRunner } from 'typeorm'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD55WebPlanningChecklist1789063200000 implements MigrationInterface { name = 'PhaseD55WebPlanningChecklist1789063200000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE inspection_visits ADD COLUMN operational_area_id uuid, ADD COLUMN operator_company_id uuid, ADD COLUMN checklist_generation integer NOT NULL DEFAULT 0, ADD COLUMN checklist_generated_at timestamptz, ADD CONSTRAINT fk_inspection_visits_operational_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT, ADD CONSTRAINT fk_inspection_visits_operator_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT, ADD CONSTRAINT chk_inspection_visits_checklist_generation CHECK (checklist_generation >= 0), ADD CONSTRAINT chk_inspection_visits_operational_context_pair CHECK ( (operational_area_id IS NULL AND operator_company_id IS NULL) OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL) ) `); await queryRunner.query(`CREATE INDEX idx_inspection_visits_operational_context ON inspection_visits (operational_area_id, operator_company_id)`); await queryRunner.query(` WITH context AS ( SELECT visit.id, COALESCE( (MAX(scope.id::text) FILTER (WHERE scope_type.operational_role = 'AREA'))::uuid, MIN(asset.operational_area_id::text)::uuid ) AS area_id, CASE WHEN COUNT(DISTINCT asset.operator_company_id) FILTER (WHERE asset.operator_company_id IS NOT NULL) = 1 THEN MIN(asset.operator_company_id::text)::uuid ELSE NULL END AS company_id FROM inspection_visits visit LEFT JOIN assets scope ON scope.id = visit.scope_asset_id LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id LEFT JOIN inspection_visit_assets link ON link.visit_id = visit.id AND link.included = true LEFT JOIN assets asset ON asset.id = link.asset_id GROUP BY visit.id ) UPDATE inspection_visits visit SET operational_area_id = context.area_id, operator_company_id = context.company_id FROM context WHERE context.id = visit.id AND context.area_id IS NOT NULL AND context.company_id IS NOT NULL `); await queryRunner.query(` ALTER TABLE inspection_visit_assets ADD COLUMN planning_source varchar(24) NOT NULL DEFAULT 'LEGACY', ADD COLUMN exclusion_reason text, ADD COLUMN excluded_by uuid, ADD COLUMN excluded_at timestamptz, ADD CONSTRAINT fk_inspection_visit_assets_excluded_by FOREIGN KEY (excluded_by) REFERENCES users(id) ON DELETE SET NULL, ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION') ) `); await queryRunner.query(` UPDATE inspection_visit_assets link SET planning_source = 'VERIFICATION' WHERE EXISTS ( SELECT 1 FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id WHERE verification_link.visit_id = link.visit_id AND finding.asset_id = link.asset_id ) `); await queryRunner.query(` UPDATE inspection_visit_assets SET exclusion_reason = 'Exclusión histórica previa a D5.5', excluded_by = added_by, excluded_at = updated_at WHERE included = false AND exclusion_reason IS NULL `); await queryRunner.query(` ALTER TABLE inspection_visit_assets ADD CONSTRAINT chk_inspection_visit_assets_exclusion_state CHECK ( (included = true AND exclusion_reason IS NULL AND excluded_at IS NULL) OR (included = false AND LENGTH(TRIM(COALESCE(exclusion_reason, ''))) >= 10 AND excluded_at IS NOT NULL) ) `); await queryRunner.query(` CREATE TABLE inspection_visit_checklist_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visit_id uuid NOT NULL, generation_number integer NOT NULL, finding_id uuid NOT NULL, asset_id uuid NOT NULL, item_kind varchar(32) NOT NULL, reference_on date, finding_status varchar(20) NOT NULL, finding_code varchar(40) NOT NULL, finding_title varchar(500) NOT NULL, severity smallint, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_inspection_visit_checklist_visit FOREIGN KEY (visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_visit_checklist_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_visit_checklist_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT uq_inspection_visit_checklist_generation_finding UNIQUE (visit_id, generation_number, finding_id), CONSTRAINT chk_inspection_visit_checklist_generation CHECK (generation_number > 0), CONSTRAINT chk_inspection_visit_checklist_kind CHECK ( item_kind IN ('ANTECEDENT','COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') ), CONSTRAINT chk_inspection_visit_checklist_severity CHECK (severity IS NULL OR severity BETWEEN 1 AND 10) ) `); await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_current ON inspection_visit_checklist_items (visit_id, generation_number, item_kind)`); await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_asset ON inspection_visit_checklist_items (visit_id, generation_number, asset_id)`); await queryRunner.query(` CREATE TABLE inspection_visit_asset_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visit_id uuid NOT NULL, asset_id uuid NOT NULL, event_type varchar(32) NOT NULL, reason text, actor_user_id uuid, occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, metadata jsonb, CONSTRAINT fk_inspection_visit_asset_events_visit FOREIGN KEY (visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_visit_asset_events_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_visit_asset_events_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED') ), CONSTRAINT chk_inspection_visit_asset_events_exclusion_reason CHECK ( event_type <> 'EXCLUDED' OR LENGTH(TRIM(COALESCE(reason, ''))) >= 10 ) ) `); await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_visit ON inspection_visit_asset_events (visit_id, occurred_at, id)`); await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_asset ON inspection_visit_asset_events (asset_id, occurred_at, id)`); await queryRunner.query(` INSERT INTO inspection_visit_asset_events ( visit_id, asset_id, event_type, actor_user_id, occurred_at, metadata ) SELECT link.visit_id, link.asset_id, CASE WHEN link.planning_source = 'VERIFICATION' THEN 'VERIFICATION_INCLUDED' ELSE 'LEGACY_INCLUDED' END, link.added_by, link.created_at, JSONB_BUILD_OBJECT('backfill', true, 'planningSource', link.planning_source) FROM inspection_visit_assets link `); await queryRunner.query(` INSERT INTO inspection_visit_asset_events ( visit_id, asset_id, event_type, reason, actor_user_id, occurred_at, metadata ) SELECT link.visit_id, link.asset_id, 'EXCLUDED', link.exclusion_reason, link.excluded_by, COALESCE(link.excluded_at, link.updated_at), JSONB_BUILD_OBJECT('backfill', true) FROM inspection_visit_assets link WHERE link.included = false `); const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(` GRANT SELECT, INSERT ON TABLE inspection_visit_checklist_items, inspection_visit_asset_events TO ${applicationRole} `); await queryRunner.query(` REVOKE UPDATE, DELETE ON TABLE inspection_visit_checklist_items, inspection_visit_asset_events FROM ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE inspection_visit_asset_events`); await queryRunner.query(`DROP TABLE inspection_visit_checklist_items`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_exclusion_state`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT fk_inspection_visit_assets_excluded_by`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_at`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_by`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN exclusion_reason`); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN planning_source`); await queryRunner.query(`DROP INDEX idx_inspection_visits_operational_context`); await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_operational_context_pair`); await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_checklist_generation`); await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operator_company`); await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operational_area`); await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generated_at`); await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generation`); await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operator_company_id`); await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operational_area_id`); } }