import { MigrationInterface, QueryRunner } from 'typeorm'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD5325VerificationEventHistory1788890400000 implements MigrationInterface { name = 'PhaseD5325VerificationEventHistory1788890400000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE inspection_finding_verification_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), finding_id uuid NOT NULL, verification_visit_id uuid NULL, event_type varchar(40) NOT NULL, occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, target_control_on date NULL, previous_control_on date NULL, next_control_on date NULL, outcome varchar(24) NULL, notes text NULL, actor_user_id uuid NULL, actor_username varchar(80) NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_inspection_finding_verification_event_type CHECK ( event_type IN ( 'CONTROL_DATE_DEFINED', 'CONTROL_DATE_CHANGED', 'CONTROL_DATE_CLEARED', 'VISIT_PLANNED', 'RESULT_RECORDED' ) ), CONSTRAINT chk_inspection_finding_verification_event_outcome CHECK ( outcome IS NULL OR outcome IN ('RESOLVED', 'NOT_RESOLVED', 'REQUIRES_NEW_DATE') ), CONSTRAINT fk_inspection_finding_verification_event_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_verification_event_visit FOREIGN KEY (verification_visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_verification_event_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_verification_events_finding ON inspection_finding_verification_events (finding_id, occurred_at DESC, created_at DESC) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_verification_events_visit ON inspection_finding_verification_events (verification_visit_id, occurred_at DESC) `); await queryRunner.query(` WITH ordered AS ( SELECT version.finding_id, version.event, version.created_at, version.actor_user_id, version.actor_username, NULLIF(version.snapshot ->> 'nextControlOn', '')::date AS next_control_on, NULLIF(LAG(version.snapshot ->> 'nextControlOn') OVER ( PARTITION BY version.finding_id ORDER BY version.version_number ), '')::date AS previous_control_on FROM inspection_finding_versions version ) INSERT INTO inspection_finding_verification_events ( finding_id, event_type, occurred_at, target_control_on, previous_control_on, next_control_on, notes, actor_user_id, actor_username ) SELECT ordered.finding_id, CASE WHEN ordered.next_control_on IS NULL THEN 'CONTROL_DATE_CLEARED' WHEN ordered.previous_control_on IS NULL THEN 'CONTROL_DATE_DEFINED' ELSE 'CONTROL_DATE_CHANGED' END, ordered.created_at, ordered.next_control_on, ordered.previous_control_on, ordered.next_control_on, 'Reconstruido desde historial versionado de seguimiento', ordered.actor_user_id, ordered.actor_username FROM ordered WHERE ordered.event = 'FOLLOW_UP_UPDATED' AND ordered.next_control_on IS DISTINCT FROM ordered.previous_control_on `); await queryRunner.query(` INSERT INTO inspection_finding_verification_events ( finding_id, verification_visit_id, event_type, occurred_at, target_control_on, next_control_on, notes, actor_user_id ) SELECT verification_link.finding_id, verification_link.visit_id, 'VISIT_PLANNED', verification_link.created_at, verification_link.target_control_on, verification_link.target_control_on, 'Visita de verificación reconstruida desde vínculo histórico', verification_link.linked_by FROM inspection_finding_verification_visits verification_link `); await queryRunner.query(` INSERT INTO inspection_finding_verification_events ( finding_id, verification_visit_id, event_type, occurred_at, target_control_on, previous_control_on, next_control_on, outcome, notes, actor_user_id ) SELECT verification_link.finding_id, verification_link.visit_id, 'RESULT_RECORDED', COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at), verification_link.target_control_on, verification_link.target_control_on, verification_link.rescheduled_control_on, verification_link.outcome, verification_link.result_notes, verification_link.result_recorded_by FROM inspection_finding_verification_visits verification_link WHERE verification_link.outcome IS NOT NULL `); await queryRunner.query(` INSERT INTO inspection_finding_verification_events ( finding_id, verification_visit_id, event_type, occurred_at, target_control_on, previous_control_on, next_control_on, notes, actor_user_id ) SELECT verification_link.finding_id, verification_link.visit_id, CASE WHEN verification_link.rescheduled_control_on IS NULL THEN 'CONTROL_DATE_CLEARED' ELSE 'CONTROL_DATE_CHANGED' END, COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at), verification_link.rescheduled_control_on, verification_link.target_control_on, verification_link.rescheduled_control_on, CASE WHEN verification_link.rescheduled_control_on IS NULL THEN 'La verificación consumió la fecha prevista sin fijar un nuevo control' ELSE 'Nueva fecha definida como resultado de la verificación' END, verification_link.result_recorded_by FROM inspection_finding_verification_visits verification_link WHERE verification_link.outcome IS NOT NULL `); await queryRunner.query(` CREATE OR REPLACE FUNCTION protect_inspection_verification_result_immutability() RETURNS trigger AS $$ BEGIN IF OLD.result_recorded_at IS NOT NULL AND ( NEW.outcome IS DISTINCT FROM OLD.outcome OR NEW.result_notes IS DISTINCT FROM OLD.result_notes OR NEW.verified_at IS DISTINCT FROM OLD.verified_at OR NEW.result_recorded_at IS DISTINCT FROM OLD.result_recorded_at OR NEW.result_recorded_by IS DISTINCT FROM OLD.result_recorded_by OR NEW.rescheduled_control_on IS DISTINCT FROM OLD.rescheduled_control_on ) THEN RAISE EXCEPTION 'El resultado de verificación ya registrado es inmutable'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql `); await queryRunner.query(` CREATE TRIGGER trg_inspection_verification_result_immutable BEFORE UPDATE ON inspection_finding_verification_visits FOR EACH ROW EXECUTE FUNCTION protect_inspection_verification_result_immutability() `); 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_finding_verification_events TO ${applicationRole}`); await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_events FROM ${applicationRole}`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP TRIGGER IF EXISTS trg_inspection_verification_result_immutable ON inspection_finding_verification_visits'); await queryRunner.query('DROP FUNCTION IF EXISTS protect_inspection_verification_result_immutability()'); await queryRunner.query('DROP TABLE inspection_finding_verification_events'); } }