import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = [ 'inspection_closure.read', 'inspection_closure.prepare', 'inspection_closure.sign', 'inspection_closure.close', ] as const; const rolePermissionValues = ` ('admin', 'inspection_closure.read'), ('admin', 'inspection_closure.prepare'), ('admin', 'inspection_closure.sign'), ('admin', 'inspection_closure.close'), ('director', 'inspection_closure.read'), ('director', 'inspection_closure.close'), ('supervisor', 'inspection_closure.read'), ('supervisor', 'inspection_closure.prepare'), ('supervisor', 'inspection_closure.sign'), ('supervisor', 'inspection_closure.close'), ('inspector', 'inspection_closure.read'), ('inspector', 'inspection_closure.prepare'), ('inspector', 'inspection_closure.sign'), ('inspector', 'inspection_closure.close'), ('auditor', 'inspection_closure.read') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD5ActClosingSignatures1787328000000 implements MigrationInterface { name = 'PhaseD5ActClosingSignatures1787328000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE inspection_acts ADD COLUMN closed_at timestamptz, ADD COLUMN closed_by uuid, ADD COLUMN closure_sha256 char(64), ADD CONSTRAINT fk_inspection_acts_closed_by FOREIGN KEY (closed_by) REFERENCES users(id) ON DELETE SET NULL, ADD CONSTRAINT chk_inspection_acts_closure CHECK ( status <> 'CLOSED' OR ( closed_at IS NOT NULL AND closed_by IS NOT NULL AND closure_sha256 ~ '^[0-9a-f]{64}$' ) ) `); await queryRunner.query(` CREATE INDEX idx_inspection_acts_closure_sha256 ON inspection_acts (closure_sha256) WHERE closure_sha256 IS NOT NULL `); await queryRunner.query(` ALTER TABLE inspection_act_versions DROP CONSTRAINT chk_inspection_act_versions_event `); await queryRunner.query(` ALTER TABLE inspection_act_versions ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( event IN ('CREATED', 'UPDATED', 'READY', 'REOPENED', 'CLOSED', 'CANCELLED') ) `); await queryRunner.query(` CREATE TABLE inspection_act_responsibles ( act_id uuid PRIMARY KEY, attendance_status varchar(20) NOT NULL, full_name varchar(200), document_type varchar(20), document_number varchar(40), position varchar(200), email varchar(320), phone varchar(50), absence_reason text, updated_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_inspection_act_responsibles_attendance CHECK ( attendance_status IN ('PRESENT', 'ABSENT') ), CONSTRAINT chk_inspection_act_responsibles_document_type CHECK ( document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER') ), CONSTRAINT chk_inspection_act_responsibles_details CHECK ( ( attendance_status = 'PRESENT' AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0 AND document_type IS NOT NULL AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0 AND LENGTH(TRIM(COALESCE(position, ''))) > 0 AND absence_reason IS NULL ) OR ( attendance_status = 'ABSENT' AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10 ) ), CONSTRAINT fk_inspection_act_responsibles_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_act_responsibles_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE TABLE inspection_act_closures ( act_id uuid PRIMARY KEY, schema_version varchar(40) NOT NULL, prepared_snapshot jsonb NOT NULL, prepared_sha256 char(64) NOT NULL, prepared_at timestamptz NOT NULL, prepared_by uuid NOT NULL, final_snapshot jsonb, final_sha256 char(64), device_closed_at timestamptz, server_closed_at timestamptz, upload_mode varchar(20), closed_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_inspection_act_closures_schema CHECK ( LENGTH(TRIM(schema_version)) > 0 ), CONSTRAINT chk_inspection_act_closures_prepared_sha CHECK ( prepared_sha256 ~ '^[0-9a-f]{64}$' ), CONSTRAINT chk_inspection_act_closures_final_sha CHECK ( final_sha256 IS NULL OR final_sha256 ~ '^[0-9a-f]{64}$' ), CONSTRAINT chk_inspection_act_closures_upload_mode CHECK ( upload_mode IS NULL OR upload_mode IN ('IMMEDIATE', 'DEFERRED') ), CONSTRAINT chk_inspection_act_closures_final CHECK ( ( final_snapshot IS NULL AND final_sha256 IS NULL AND device_closed_at IS NULL AND server_closed_at IS NULL AND upload_mode IS NULL AND closed_by IS NULL ) OR ( final_snapshot IS NOT NULL AND final_sha256 IS NOT NULL AND device_closed_at IS NOT NULL AND server_closed_at IS NOT NULL AND upload_mode IS NOT NULL AND closed_by IS NOT NULL ) ), CONSTRAINT fk_inspection_act_closures_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_act_closures_prepared_by FOREIGN KEY (prepared_by) REFERENCES users(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_act_closures_closed_by FOREIGN KEY (closed_by) REFERENCES users(id) ON DELETE RESTRICT ) `); await queryRunner.query(` CREATE INDEX idx_inspection_act_closures_prepared_sha ON inspection_act_closures (prepared_sha256) `); await queryRunner.query(` CREATE UNIQUE INDEX uq_inspection_act_closures_final_sha ON inspection_act_closures (final_sha256) WHERE final_sha256 IS NOT NULL `); await queryRunner.query(` CREATE TABLE inspection_act_signatures ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), act_id uuid NOT NULL, signer_type varchar(32) NOT NULL, signer_user_id uuid, signer_name varchar(200) NOT NULL, document_type varchar(20), document_number varchar(40), position varchar(200), status varchar(20) NOT NULL, reason text, original_name varchar(255), stored_name varchar(80), mime_type varchar(100), size_bytes bigint, image_sha256 char(64), consent_text text, consent_version varchar(20), consent_accepted_at timestamptz, client_signed_at timestamptz, signed_at timestamptz, latitude numeric(9, 6), longitude numeric(9, 6), accuracy_m numeric(12, 3), device_label varchar(200), source varchar(20) NOT NULL, prepared_sha256 char(64) NOT NULL, signature_payload_sha256 char(64) NOT NULL, uploaded_by uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_inspection_act_signatures_stored_name UNIQUE (stored_name), CONSTRAINT chk_inspection_act_signatures_signer_type CHECK ( signer_type IN ('INSPECTOR', 'COMPANY_RESPONSIBLE') ), CONSTRAINT chk_inspection_act_signatures_status CHECK ( status IN ('SIGNED', 'REFUSED', 'ABSENT') ), CONSTRAINT chk_inspection_act_signatures_document_type CHECK ( document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER') ), CONSTRAINT chk_inspection_act_signatures_source CHECK ( source IN ('WEB', 'ANDROID') ), CONSTRAINT chk_inspection_act_signatures_sha CHECK ( prepared_sha256 ~ '^[0-9a-f]{64}$' AND signature_payload_sha256 ~ '^[0-9a-f]{64}$' AND (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$') ), CONSTRAINT chk_inspection_act_signatures_coordinates CHECK ( (latitude IS NULL AND longitude IS NULL) OR ( latitude IS NOT NULL AND longitude IS NOT NULL AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180 ) ), CONSTRAINT chk_inspection_act_signatures_accuracy CHECK ( accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL) ), CONSTRAINT chk_inspection_act_signatures_inspector CHECK ( signer_type <> 'INSPECTOR' OR (signer_user_id IS NOT NULL AND status = 'SIGNED') ), CONSTRAINT chk_inspection_act_signatures_company CHECK ( signer_type <> 'COMPANY_RESPONSIBLE' OR signer_user_id IS NULL ), CONSTRAINT chk_inspection_act_signatures_signed CHECK ( ( status = 'SIGNED' AND reason IS NULL AND stored_name IS NOT NULL AND mime_type = 'image/png' AND size_bytes BETWEEN 1 AND 1048576 AND image_sha256 IS NOT NULL AND LENGTH(TRIM(COALESCE(consent_text, ''))) > 0 AND consent_version IS NOT NULL AND consent_accepted_at IS NOT NULL AND signed_at IS NOT NULL ) OR ( status IN ('REFUSED', 'ABSENT') AND signer_type = 'COMPANY_RESPONSIBLE' AND LENGTH(TRIM(COALESCE(reason, ''))) >= 10 AND stored_name IS NULL AND mime_type IS NULL AND size_bytes IS NULL AND image_sha256 IS NULL AND consent_text IS NULL AND consent_version IS NULL AND consent_accepted_at IS NULL AND signed_at IS NULL ) ), CONSTRAINT fk_inspection_act_signatures_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_act_signatures_signer_user FOREIGN KEY (signer_user_id) REFERENCES users(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_act_signatures_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE RESTRICT ) `); await queryRunner.query(` CREATE INDEX idx_inspection_act_signatures_act_created ON inspection_act_signatures (act_id, created_at) `); await queryRunner.query(` CREATE INDEX idx_inspection_act_signatures_sha256 ON inspection_act_signatures (signature_payload_sha256) `); await queryRunner.query(` CREATE UNIQUE INDEX uq_inspection_act_inspector_signature ON inspection_act_signatures (act_id, signer_user_id) WHERE signer_type = 'INSPECTOR' `); await queryRunner.query(` CREATE UNIQUE INDEX uq_inspection_act_company_outcome ON inspection_act_signatures (act_id) WHERE signer_type = 'COMPANY_RESPONSIBLE' `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_act_responsible_draft() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE act_status varchar(24); BEGIN SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id; IF act_status <> 'DRAFT' THEN RAISE EXCEPTION 'El responsable sólo puede modificarse con el acta en borrador'; END IF; NEW.updated_at := CURRENT_TIMESTAMP; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_act_responsibles_draft BEFORE INSERT OR UPDATE ON inspection_act_responsibles FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_responsible_draft() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE current_status varchar(24); DECLARE current_sha char(64); BEGIN SELECT act.status, closure.prepared_sha256 INTO current_status, current_sha FROM inspection_acts act INNER JOIN inspection_act_closures closure ON closure.act_id = act.id WHERE act.id = NEW.act_id; IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_act_signatures_ready BEFORE INSERT ON inspection_act_signatures FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_signature_ready() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.status IN ('CLOSED', 'RECTIFIED') THEN RAISE EXCEPTION 'El acta cerrada es inmutable'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_acts_closed_immutable BEFORE UPDATE ON inspection_acts FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_act_immutable() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_closed_closure_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.server_closed_at IS NOT NULL THEN RAISE EXCEPTION 'El cierre sellado es inmutable'; END IF; NEW.updated_at := CURRENT_TIMESTAMP; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_act_closures_immutable BEFORE UPDATE ON inspection_act_closures FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_closure_immutable() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_act_asset_draft() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE act_status varchar(24); BEGIN SELECT status INTO act_status FROM inspection_acts WHERE id = COALESCE(NEW.act_id, OLD.act_id); IF act_status <> 'DRAFT' THEN RAISE EXCEPTION 'Los activos del acta están congelados'; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_act_assets_draft BEFORE INSERT OR UPDATE OR DELETE ON inspection_act_assets FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_asset_draft() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_finding_inspection_fields() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE act_status varchar(24); BEGIN IF TG_OP = 'INSERT' THEN SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id; IF act_status <> 'DRAFT' THEN RAISE EXCEPTION 'No pueden agregarse hallazgos al acta congelada'; END IF; RETURN NEW; END IF; SELECT status INTO act_status FROM inspection_acts WHERE id = OLD.act_id; IF act_status <> 'DRAFT' AND ( NEW.act_id IS DISTINCT FROM OLD.act_id OR NEW.asset_id IS DISTINCT FROM OLD.asset_id OR NEW.catalog_item_id IS DISTINCT FROM OLD.catalog_item_id OR NEW.finding_number IS DISTINCT FROM OLD.finding_number OR NEW.code IS DISTINCT FROM OLD.code OR NEW.title IS DISTINCT FROM OLD.title OR NEW.description IS DISTINCT FROM OLD.description OR NEW.legal_basis IS DISTINCT FROM OLD.legal_basis OR NEW.glossary IS DISTINCT FROM OLD.glossary OR NEW.catalog_revision IS DISTINCT FROM OLD.catalog_revision ) THEN RAISE EXCEPTION 'El contenido constatado del hallazgo está congelado'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_findings_frozen_fields BEFORE INSERT OR UPDATE ON inspection_findings FOR EACH ROW EXECUTE FUNCTION dhv2_guard_finding_inspection_fields() `); await queryRunner.query(` CREATE OR REPLACE FUNCTION dhv2_guard_observation_evidence_draft() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE act_status varchar(24); BEGIN IF NEW.purpose = 'OBSERVATION' THEN SELECT act.status INTO act_status FROM inspection_findings finding INNER JOIN inspection_acts act ON act.id = finding.act_id WHERE finding.id = NEW.finding_id; IF act_status <> 'DRAFT' THEN RAISE EXCEPTION 'La evidencia de constatación pertenece a un acta congelada'; END IF; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_evidence_observation_draft BEFORE INSERT ON inspection_finding_evidence FOR EACH ROW EXECUTE FUNCTION dhv2_guard_observation_evidence_draft() `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('inspection_closure.read', 'Consultar responsable, firmas y cierre del acta'), ('inspection_closure.prepare', 'Identificar responsable y preparar el acta para firmas'), ('inspection_closure.sign', 'Registrar firmas o resultado de recepción del acta'), ('inspection_closure.close', 'Cerrar y sellar el acta y la visita') ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description `); await queryRunner.query(` WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) INSERT INTO role_permissions (role_id, permission_id) SELECT role.id, permission.id FROM mapping INNER JOIN roles role ON role.code = mapping.role_code INNER JOIN permissions permission ON permission.code = mapping.permission_code ON CONFLICT (role_id, permission_id) DO NOTHING `); 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, UPDATE ON TABLE inspection_act_responsibles, inspection_act_closures TO ${applicationRole} `); await queryRunner.query(` GRANT SELECT, INSERT ON TABLE inspection_act_signatures TO ${applicationRole} `); await queryRunner.query(` REVOKE DELETE ON TABLE inspection_act_responsibles, inspection_act_closures, inspection_act_signatures FROM ${applicationRole} `); await queryRunner.query(` REVOKE UPDATE ON TABLE inspection_act_signatures FROM ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP TRIGGER trg_inspection_evidence_observation_draft ON inspection_finding_evidence'); await queryRunner.query('DROP FUNCTION dhv2_guard_observation_evidence_draft()'); await queryRunner.query('DROP TRIGGER trg_inspection_findings_frozen_fields ON inspection_findings'); await queryRunner.query('DROP FUNCTION dhv2_guard_finding_inspection_fields()'); await queryRunner.query('DROP TRIGGER trg_inspection_act_assets_draft ON inspection_act_assets'); await queryRunner.query('DROP FUNCTION dhv2_guard_act_asset_draft()'); await queryRunner.query('DROP TRIGGER trg_inspection_act_closures_immutable ON inspection_act_closures'); await queryRunner.query('DROP FUNCTION dhv2_guard_closed_closure_immutable()'); await queryRunner.query('DROP TRIGGER trg_inspection_acts_closed_immutable ON inspection_acts'); await queryRunner.query('DROP FUNCTION dhv2_guard_closed_act_immutable()'); await queryRunner.query('DROP TRIGGER trg_inspection_act_signatures_ready ON inspection_act_signatures'); await queryRunner.query('DROP FUNCTION dhv2_guard_act_signature_ready()'); await queryRunner.query('DROP TRIGGER trg_inspection_act_responsibles_draft ON inspection_act_responsibles'); await queryRunner.query('DROP FUNCTION dhv2_guard_act_responsible_draft()'); await queryRunner.query(` WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) DELETE FROM role_permissions role_permission USING roles role, permissions permission, mapping WHERE role_permission.role_id = role.id AND role_permission.permission_id = permission.id AND role.code = mapping.role_code AND permission.code = mapping.permission_code `); await queryRunner.query( `DELETE FROM permissions WHERE code = ANY($1::varchar[]) AND NOT EXISTS ( SELECT 1 FROM role_permissions WHERE permission_id = permissions.id )`, [newPermissions], ); await queryRunner.query('DROP TABLE inspection_act_signatures'); await queryRunner.query('DROP TABLE inspection_act_closures'); await queryRunner.query('DROP TABLE inspection_act_responsibles'); await queryRunner.query(` ALTER TABLE inspection_act_versions DROP CONSTRAINT chk_inspection_act_versions_event `); await queryRunner.query(` ALTER TABLE inspection_act_versions ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( event IN ('CREATED', 'UPDATED', 'CANCELLED') ) `); await queryRunner.query(` ALTER TABLE inspection_acts DROP CONSTRAINT chk_inspection_acts_closure, DROP CONSTRAINT fk_inspection_acts_closed_by, DROP COLUMN closure_sha256, DROP COLUMN closed_by, DROP COLUMN closed_at `); } }