import { MigrationInterface, QueryRunner } from 'typeorm'; import { PHASE_D3_FINDING_CATALOG_ITEMS, PHASE_D3_FINDING_CATEGORIES, } from '../seeds/phase-d3-finding-catalog'; const newPermissions = [ 'finding_catalog.read', 'finding_catalog.manage', 'inspection_findings.read', 'inspection_findings.create', 'inspection_findings.update', 'inspection_findings.follow_up', ] as const; const rolePermissionValues = ` ('admin', 'finding_catalog.read'), ('admin', 'finding_catalog.manage'), ('admin', 'inspection_findings.read'), ('admin', 'inspection_findings.create'), ('admin', 'inspection_findings.update'), ('admin', 'inspection_findings.follow_up'), ('director', 'finding_catalog.read'), ('director', 'inspection_findings.read'), ('director', 'inspection_findings.follow_up'), ('supervisor', 'finding_catalog.read'), ('supervisor', 'finding_catalog.manage'), ('supervisor', 'inspection_findings.read'), ('supervisor', 'inspection_findings.create'), ('supervisor', 'inspection_findings.update'), ('supervisor', 'inspection_findings.follow_up'), ('inspector', 'finding_catalog.read'), ('inspector', 'inspection_findings.read'), ('inspector', 'inspection_findings.create'), ('inspector', 'inspection_findings.update'), ('inspector', 'inspection_findings.follow_up'), ('auditor', 'finding_catalog.read'), ('auditor', 'inspection_findings.read') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD3InspectionFindings1787155200000 implements MigrationInterface { name = 'PhaseD3InspectionFindings1787155200000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE finding_categories ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(80) NOT NULL, name varchar(200) NOT NULL, sort_order integer NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_categories_code UNIQUE (code), CONSTRAINT chk_finding_categories_sort_order CHECK (sort_order >= 0), CONSTRAINT chk_finding_categories_name CHECK (LENGTH(TRIM(name)) > 0) ) `); await queryRunner.query(` CREATE INDEX idx_finding_categories_active_order ON finding_categories (is_active, sort_order) `); await queryRunner.query(` CREATE TABLE finding_catalog_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), category_id uuid NOT NULL, code varchar(120) NOT NULL, source_number integer NOT NULL, title varchar(500) NOT NULL, legal_basis text, glossary text, import_note text, revision integer NOT NULL DEFAULT 1, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_catalog_items_code UNIQUE (code), CONSTRAINT uq_finding_catalog_items_source UNIQUE (category_id, source_number), CONSTRAINT chk_finding_catalog_items_source CHECK (source_number > 0), CONSTRAINT chk_finding_catalog_items_revision CHECK (revision > 0), CONSTRAINT chk_finding_catalog_items_title CHECK (LENGTH(TRIM(title)) > 0), CONSTRAINT fk_finding_catalog_items_category FOREIGN KEY (category_id) REFERENCES finding_categories(id) ON DELETE RESTRICT ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_items_active ON finding_catalog_items (category_id, is_active) `); await queryRunner.query(` CREATE TABLE inspection_findings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), act_id uuid NOT NULL, asset_id uuid NOT NULL, catalog_item_id uuid, finding_number integer NOT NULL, code varchar(40) NOT NULL, status varchar(20) NOT NULL DEFAULT 'OPEN', title varchar(500) NOT NULL, description text NOT NULL, legal_basis text, glossary text, catalog_revision integer, correction_due_on date, company_response text, company_response_received_on date, company_committed_correction_on date, next_control_on date, current_version integer NOT NULL DEFAULT 0, closed_at timestamptz, closed_by uuid, closure_notes text, created_by uuid, updated_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_inspection_findings_number UNIQUE (act_id, finding_number), CONSTRAINT uq_inspection_findings_code UNIQUE (code), CONSTRAINT chk_inspection_findings_number CHECK ( finding_number BETWEEN 1 AND 999 ), CONSTRAINT chk_inspection_findings_code CHECK ( code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$' ), CONSTRAINT chk_inspection_findings_status CHECK ( status IN ('OPEN', 'CLOSED', 'VOIDED') ), CONSTRAINT chk_inspection_findings_content CHECK ( LENGTH(TRIM(title)) > 0 AND LENGTH(TRIM(description)) > 0 ), CONSTRAINT chk_inspection_findings_version CHECK (current_version >= 0), CONSTRAINT chk_inspection_findings_catalog_revision CHECK ( catalog_revision IS NULL OR catalog_revision > 0 ), CONSTRAINT chk_inspection_findings_response CHECK ( (company_response IS NULL AND company_response_received_on IS NULL) OR ( LENGTH(TRIM(COALESCE(company_response, ''))) > 0 AND company_response_received_on IS NOT NULL ) ), CONSTRAINT chk_inspection_findings_commitment CHECK ( company_committed_correction_on IS NULL OR company_response_received_on IS NOT NULL ), CONSTRAINT chk_inspection_findings_closure CHECK ( status <> 'CLOSED' OR (closed_at IS NOT NULL AND closed_by IS NOT NULL AND LENGTH(TRIM(COALESCE(closure_notes, ''))) >= 10) ), CONSTRAINT fk_inspection_findings_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_findings_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_findings_catalog_item FOREIGN KEY (catalog_item_id) REFERENCES finding_catalog_items(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_findings_closed_by FOREIGN KEY (closed_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_inspection_findings_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_inspection_findings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_inspection_findings_status_control ON inspection_findings (status, next_control_on) `); await queryRunner.query(` CREATE INDEX idx_inspection_findings_asset_status ON inspection_findings (asset_id, status) `); await queryRunner.query(` CREATE INDEX idx_inspection_findings_catalog_item ON inspection_findings (catalog_item_id) `); await queryRunner.query(` CREATE TABLE inspection_finding_versions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), finding_id uuid NOT NULL, version_number integer NOT NULL, event varchar(32) NOT NULL, snapshot jsonb NOT NULL, actor_user_id uuid, actor_username varchar(80), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_inspection_finding_versions_number UNIQUE (finding_id, version_number), CONSTRAINT chk_inspection_finding_versions_number CHECK (version_number > 0), CONSTRAINT chk_inspection_finding_versions_event CHECK ( event IN ('CREATED', 'UPDATED', 'FOLLOW_UP_UPDATED') ), CONSTRAINT fk_inspection_finding_versions_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_versions_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_versions_created_at ON inspection_finding_versions (created_at) `); for (const category of PHASE_D3_FINDING_CATEGORIES) { await queryRunner.query(` INSERT INTO finding_categories (code, name, sort_order) VALUES ($1, $2, $3) `, [category.code, category.name, category.sortOrder]); } for (const item of PHASE_D3_FINDING_CATALOG_ITEMS) { await queryRunner.query(` INSERT INTO finding_catalog_items ( category_id, code, source_number, title, legal_basis, glossary, import_note ) SELECT id, $2, $3, $4, $5, $6, $7 FROM finding_categories WHERE code = $1 `, [ item.categoryCode, item.code, item.sourceNumber, item.title, item.legalBasis, item.glossary, item.importNote, ]); } await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('finding_catalog.read', 'Consultar el catálogo de hallazgos'), ('finding_catalog.manage', 'Administrar el catálogo de hallazgos'), ('inspection_findings.read', 'Consultar hallazgos y seguimiento'), ('inspection_findings.create', 'Crear hallazgos dentro de actas'), ('inspection_findings.update', 'Actualizar hallazgos en borrador'), ('inspection_findings.follow_up', 'Registrar respuestas y próximos controles') 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 finding_categories, finding_catalog_items, inspection_findings TO ${applicationRole} `); await queryRunner.query(` GRANT SELECT, INSERT ON TABLE inspection_finding_versions TO ${applicationRole} `); await queryRunner.query(` REVOKE DELETE ON TABLE finding_categories, finding_catalog_items, inspection_findings, inspection_finding_versions FROM ${applicationRole} `); await queryRunner.query(` REVOKE UPDATE ON TABLE inspection_finding_versions FROM ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { 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_finding_versions`); await queryRunner.query(`DROP TABLE inspection_findings`); await queryRunner.query(`DROP TABLE finding_catalog_items`); await queryRunner.query(`DROP TABLE finding_categories`); } }