import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = ['asset_imports.apply'] as const; const rolePermissionValues = ` ('admin', 'asset_imports.apply'), ('director', 'asset_imports.apply') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD537ImportPlanningApplication1787680800000 implements MigrationInterface { name = 'PhaseD537ImportPlanningApplication1787680800000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE asset_import_plans ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), batch_id uuid NOT NULL, revision integer NOT NULL, status varchar(24) NOT NULL, external_id_namespace varchar(80), operator_asset_id uuid, summary jsonb NOT NULL DEFAULT '{}'::jsonb, plan_hash char(64) NOT NULL, generated_by uuid, generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, applied_by uuid, applied_at timestamptz, application_summary jsonb, rolled_back_by uuid, rolled_back_at timestamptz, rollback_summary jsonb, superseded_at timestamptz, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_asset_import_plan_revision CHECK (revision > 0), CONSTRAINT chk_asset_import_plan_status CHECK (status IN ('REVIEW_REQUIRED','READY','APPLIED','ROLLED_BACK','SUPERSEDED','FAILED')), CONSTRAINT chk_asset_import_plan_namespace CHECK (external_id_namespace IS NULL OR external_id_namespace ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'), CONSTRAINT chk_asset_import_plan_hash CHECK (plan_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT fk_asset_import_plan_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_import_plan_operator FOREIGN KEY (operator_asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_import_plan_generated_by FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_plan_applied_by FOREIGN KEY (applied_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_plan_rolled_back_by FOREIGN KEY (rolled_back_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT uq_asset_import_plan_revision UNIQUE (batch_id, revision) ) `); await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_import_plan_active_batch ON asset_import_plans (batch_id) WHERE superseded_at IS NULL`); await queryRunner.query(`CREATE INDEX idx_asset_import_plans_status ON asset_import_plans (status, generated_at DESC)`); await queryRunner.query(` CREATE TABLE asset_import_plan_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), plan_id uuid NOT NULL, item_order integer NOT NULL, entity_key varchar(320) NOT NULL, entity_kind varchar(40) NOT NULL, action varchar(16) NOT NULL, status varchar(24) NOT NULL, asset_type_code varchar(80), display_name varchar(260) NOT NULL, generated_code varchar(120), parent_entity_key varchar(320), matched_asset_id uuid, applied_asset_id uuid, applied_object_id varchar(255), payload jsonb NOT NULL DEFAULT '{}'::jsonb, source_row_numbers jsonb NOT NULL DEFAULT '[]'::jsonb, review_codes jsonb NOT NULL DEFAULT '[]'::jsonb, resolution_note text, resolved_by uuid, resolved_at timestamptz, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_asset_import_plan_item_order CHECK (item_order > 0), CONSTRAINT chk_asset_import_plan_item_kind CHECK (entity_kind IN ('ORGANIZATION','AREA','FIELD','OPERATOR_RELATION','INSTALLATION','TECHNICAL_ASSET')), CONSTRAINT chk_asset_import_plan_item_action CHECK (action IN ('CREATE','MATCH','REVIEW','IGNORE')), CONSTRAINT chk_asset_import_plan_item_status CHECK (status IN ('PLANNED','MATCHED','REVIEW','IGNORED','APPLIED','ROLLED_BACK','FAILED')), CONSTRAINT chk_asset_import_plan_item_rows CHECK (jsonb_typeof(source_row_numbers)='array'), CONSTRAINT chk_asset_import_plan_item_reviews CHECK (jsonb_typeof(review_codes)='array'), CONSTRAINT fk_asset_import_plan_item_plan FOREIGN KEY (plan_id) REFERENCES asset_import_plans(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_import_plan_item_match FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_plan_item_applied_asset FOREIGN KEY (applied_asset_id) REFERENCES assets(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_plan_item_resolved_by FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT uq_asset_import_plan_item_key UNIQUE (plan_id, entity_key) ) `); await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_order ON asset_import_plan_items (plan_id, item_order)`); await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_action ON asset_import_plan_items (plan_id, action, entity_kind)`); await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_match ON asset_import_plan_items (matched_asset_id) WHERE matched_asset_id IS NOT NULL`); await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_applied ON asset_import_plan_items (applied_asset_id) WHERE applied_asset_id IS NOT NULL`); await queryRunner.query( `INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, ['asset_imports.apply', 'Aplicar o revertir planes de importación transaccional sobre el Maestro'], ); await queryRunner.query(` WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.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 roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [appRole])) as unknown[]; if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_import_plans, asset_import_plan_items TO ${applicationRole}`); await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_plans, asset_import_plan_items FROM ${applicationRole}`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) DELETE FROM role_permissions rp USING roles r,permissions p,mapping m WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code `); await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`, [newPermissions]); await queryRunner.query(`DROP TABLE asset_import_plan_items`); await queryRunner.query(`DROP TABLE asset_import_plans`); } }