import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = ['asset_imports.read', 'asset_imports.manage'] as const; const rolePermissionValues = ` ('admin', 'asset_imports.read'), ('admin', 'asset_imports.manage'), ('director', 'asset_imports.read'), ('director', 'asset_imports.manage'), ('supervisor', 'asset_imports.read'), ('supervisor', 'asset_imports.manage') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD535ImportCenterScalability1787594400000 implements MigrationInterface { name = 'PhaseD535ImportCenterScalability1787594400000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_code_trgm ON assets USING gin (code gin_trgm_ops)`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_name_trgm ON assets USING gin (name gin_trgm_ops)`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_external_identifiers_value_trgm ON asset_external_identifiers USING gin (value gin_trgm_ops)`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_attribute_values_text_trgm ON asset_attribute_values USING gin ((value #>> '{}') gin_trgm_ops)`); await queryRunner.query(`CREATE TYPE asset_import_batch_status AS ENUM ('ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED')`); await queryRunner.query(`CREATE TYPE asset_import_row_status AS ENUM ('READY','WARNING','CONFLICT','IGNORED')`); await queryRunner.query(`CREATE TYPE asset_import_suggested_action AS ENUM ('CREATE','MATCH','REVIEW','IGNORE')`); await queryRunner.query(` CREATE TABLE asset_import_batches ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), original_name varchar(255) NOT NULL, stored_name varchar(255) NOT NULL, mime_type varchar(120) NOT NULL, size_bytes integer NOT NULL, sha256 char(64) NOT NULL, profile_code varchar(80) NOT NULL, profile_confidence integer NOT NULL DEFAULT 0, worksheet_name varchar(160), header_row integer, total_rows integer NOT NULL DEFAULT 0, ready_rows integer NOT NULL DEFAULT 0, warning_rows integer NOT NULL DEFAULT 0, conflict_rows integer NOT NULL DEFAULT 0, ignored_rows integer NOT NULL DEFAULT 0, status asset_import_batch_status NOT NULL, source_document_id uuid, source_label varchar(240), notes text, analysis jsonb NOT NULL DEFAULT '{}'::jsonb, uploaded_by uuid, analyzed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_asset_import_batch_size CHECK (size_bytes > 0 AND size_bytes <= 26214400), CONSTRAINT chk_asset_import_profile_confidence CHECK (profile_confidence >= 0 AND profile_confidence <= 100), CONSTRAINT chk_asset_import_batch_counts CHECK (total_rows >= 0 AND ready_rows >= 0 AND warning_rows >= 0 AND conflict_rows >= 0 AND ignored_rows >= 0), CONSTRAINT fk_asset_import_batch_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_batch_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(`CREATE INDEX idx_asset_import_batches_created_at ON asset_import_batches (created_at DESC)`); await queryRunner.query(`CREATE INDEX idx_asset_import_batches_status ON asset_import_batches (status)`); await queryRunner.query(`CREATE INDEX idx_asset_import_batches_sha256 ON asset_import_batches (sha256)`); await queryRunner.query(` CREATE TABLE asset_import_rows ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), batch_id uuid NOT NULL, worksheet_name varchar(160) NOT NULL, row_number integer NOT NULL, status asset_import_row_status NOT NULL, suggested_action asset_import_suggested_action NOT NULL, raw_data jsonb NOT NULL, normalized_data jsonb NOT NULL, issue_codes jsonb NOT NULL DEFAULT '[]'::jsonb, fingerprint char(64) NOT NULL, matched_asset_id uuid, imported_asset_id uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_asset_import_row_number CHECK (row_number > 0), CONSTRAINT chk_asset_import_row_issues_array CHECK (jsonb_typeof(issue_codes) = 'array'), CONSTRAINT fk_asset_import_rows_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_import_rows_matched_asset FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL, CONSTRAINT fk_asset_import_rows_imported_asset FOREIGN KEY (imported_asset_id) REFERENCES assets(id) ON DELETE SET NULL, CONSTRAINT uq_asset_import_row_source UNIQUE (batch_id, worksheet_name, row_number) ) `); await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_status ON asset_import_rows (batch_id,status,row_number)`); await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_action ON asset_import_rows (batch_id,suggested_action)`); await queryRunner.query(`CREATE INDEX idx_asset_import_rows_fingerprint ON asset_import_rows (fingerprint)`); await queryRunner.query(`CREATE INDEX idx_asset_import_rows_inventory_id ON asset_import_rows ((normalized_data->>'inventoryId')) WHERE normalized_data ? 'inventoryId'`); const descriptions: Record = { 'asset_imports.read': 'Consultar lotes, análisis y conflictos de importación del Maestro', 'asset_imports.manage': 'Analizar archivos XLSX/CSV y administrar lotes de importación del Maestro', }; for (const permission of newPermissions) { await queryRunner.query( `INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, [permission, descriptions[permission]], ); } 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_batches, asset_import_rows TO ${applicationRole}`); await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_batches, asset_import_rows 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_rows`); await queryRunner.query(`DROP TABLE asset_import_batches`); await queryRunner.query(`DROP TYPE asset_import_suggested_action`); await queryRunner.query(`DROP TYPE asset_import_row_status`); await queryRunner.query(`DROP TYPE asset_import_batch_status`); await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_attribute_values_text_trgm`); await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_external_identifiers_value_trgm`); await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_name_trgm`); await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_code_trgm`); } }