import { MigrationInterface, QueryRunner } from 'typeorm'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD5324AssetContextHistory1788804000000 implements MigrationInterface { name = 'PhaseD5324AssetContextHistory1788804000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE asset_context_history ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, parent_id uuid, operational_area_id uuid, operator_company_id uuid, valid_from timestamptz NOT NULL, valid_until timestamptz, change_reason text NOT NULL, end_reason text, asset_version_number integer NOT NULL, source varchar(32) NOT NULL DEFAULT 'WEB', request_id varchar(128), created_by uuid, ended_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at timestamptz, CONSTRAINT fk_asset_context_history_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_context_history_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_context_history_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_context_history_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_context_history_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_asset_context_history_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_asset_context_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), CONSTRAINT chk_asset_context_history_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) ), CONSTRAINT chk_asset_context_history_reason CHECK (length(btrim(change_reason)) >= 5), CONSTRAINT chk_asset_context_history_version CHECK (asset_version_number >= 1) ) `); await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_context_history_active ON asset_context_history (asset_id) WHERE valid_until IS NULL`); await queryRunner.query(`CREATE INDEX idx_asset_context_history_asset_period ON asset_context_history (asset_id, valid_from DESC, valid_until)`); await queryRunner.query(`CREATE INDEX idx_asset_context_history_area_period ON asset_context_history (operational_area_id, valid_from DESC) WHERE operational_area_id IS NOT NULL`); await queryRunner.query(`CREATE INDEX idx_asset_context_history_company_period ON asset_context_history (operator_company_id, valid_from DESC) WHERE operator_company_id IS NOT NULL`); await queryRunner.query(` WITH ordered AS ( SELECT version.asset_id, version.version_number, version.occurred_at, NULLIF(version.snapshot #>> '{parent,id}', '')::uuid AS parent_id, NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid AS operational_area_id, NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid AS operator_company_id, version.actor_user_id, version.source, version.request_id, ROW_NUMBER() OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS row_number, LAG(NULLIF(version.snapshot #>> '{parent,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_parent_id, LAG(NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operational_area_id, LAG(NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operator_company_id FROM asset_versions version ), changes AS ( SELECT * FROM ordered WHERE row_number = 1 OR parent_id IS DISTINCT FROM previous_parent_id OR operational_area_id IS DISTINCT FROM previous_operational_area_id OR operator_company_id IS DISTINCT FROM previous_operator_company_id ), bounded AS ( SELECT changes.*, LEAD(changes.occurred_at) OVER (PARTITION BY changes.asset_id ORDER BY changes.occurred_at, changes.version_number) AS valid_until FROM changes ) INSERT INTO asset_context_history ( asset_id, parent_id, operational_area_id, operator_company_id, valid_from, valid_until, change_reason, asset_version_number, source, request_id, created_by ) SELECT bounded.asset_id, bounded.parent_id, bounded.operational_area_id, bounded.operator_company_id, bounded.occurred_at, bounded.valid_until, CASE WHEN bounded.row_number = 1 THEN 'Contexto inicial reconstruido desde el historial versionado' ELSE 'Cambio de contexto reconstruido desde el historial versionado' END, bounded.version_number, bounded.source, bounded.request_id, bounded.actor_user_id FROM bounded ORDER BY bounded.asset_id, bounded.occurred_at, bounded.version_number `); await queryRunner.query(` INSERT INTO asset_context_history ( asset_id, parent_id, operational_area_id, operator_company_id, valid_from, change_reason, asset_version_number, source, created_by ) SELECT asset.id, asset.parent_id, asset.operational_area_id, asset.operator_company_id, asset.created_at, 'Contexto inicial incorporado al historial', GREATEST(asset.current_version, 1), 'SYSTEM', asset.created_by FROM assets asset WHERE NOT EXISTS ( SELECT 1 FROM asset_context_history history WHERE history.asset_id = asset.id ) `); await queryRunner.query(` WITH mismatched AS ( SELECT history.id AS history_id, asset.id AS asset_id, asset.parent_id, asset.operational_area_id, asset.operator_company_id, GREATEST(asset.updated_at, history.valid_from) AS effective_from, GREATEST(asset.current_version, 1) AS asset_version_number, asset.updated_by FROM asset_context_history history JOIN assets asset ON asset.id=history.asset_id WHERE history.valid_until IS NULL AND ( history.parent_id IS DISTINCT FROM asset.parent_id OR history.operational_area_id IS DISTINCT FROM asset.operational_area_id OR history.operator_company_id IS DISTINCT FROM asset.operator_company_id ) ), closed AS ( UPDATE asset_context_history history SET valid_until=mismatched.effective_from, end_reason='Sincronización con contexto vigente al instalar D5.3.24', ended_at=CURRENT_TIMESTAMP FROM mismatched WHERE history.id=mismatched.history_id RETURNING mismatched.* ) INSERT INTO asset_context_history ( asset_id, parent_id, operational_area_id, operator_company_id, valid_from, change_reason, asset_version_number, source, created_by ) SELECT closed.asset_id, closed.parent_id, closed.operational_area_id, closed.operator_company_id, closed.effective_from, 'Contexto vigente sincronizado al instalar D5.3.24', closed.asset_version_number, 'SYSTEM', closed.updated_by FROM closed `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('assets.manage_context', 'Cambiar jerarquía, Área u Operadora preservando el historial temporal') ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description `); await queryRunner.query(` WITH mapping(role_code, permission_code) AS (VALUES ('admin', 'assets.manage_context'), ('supervisor', 'assets.manage_context') ) INSERT INTO role_permissions (role_id, permission_id) SELECT role.id, permission.id FROM mapping JOIN roles role ON role.code = mapping.role_code 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 asset_context_history TO ${applicationRole}`); await queryRunner.query(`REVOKE DELETE ON TABLE asset_context_history FROM ${applicationRole}`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code = 'assets.manage_context'`); await queryRunner.query(`DELETE FROM permissions WHERE code = 'assets.manage_context'`); await queryRunner.query(`DROP TABLE IF EXISTS asset_context_history`); } }