import { MigrationInterface, QueryRunner } from 'typeorm'; const permissionCode = 'assets.read_history'; const rolePermissionValues = ` ('admin', 'assets.read_history'), ('director', 'assets.read_history'), ('supervisor', 'assets.read_history'), ('inspector', 'assets.read_history'), ('auditor', 'assets.read_history') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseB3AssetVersions1786723201000 implements MigrationInterface { name = 'PhaseB3AssetVersions1786723201000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE assets ADD COLUMN current_version integer NOT NULL DEFAULT 0, ADD CONSTRAINT chk_assets_current_version CHECK (current_version >= 0) `); await queryRunner.query(` CREATE TABLE asset_versions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, version_number integer NOT NULL, change_type varchar(32) NOT NULL, changed_fields text[] NOT NULL DEFAULT '{}', snapshot jsonb NOT NULL, occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, actor_user_id uuid, actor_username varchar(80), source varchar(32) NOT NULL, request_id varchar(128), CONSTRAINT uq_asset_versions_asset_number UNIQUE (asset_id, version_number), CONSTRAINT chk_asset_versions_number CHECK (version_number >= 1), CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED' )), CONSTRAINT chk_asset_versions_source CHECK (source IN ( 'WEB', 'ANDROID', 'SYSTEM', 'IMPORT' )), CONSTRAINT chk_asset_versions_snapshot_object CHECK (jsonb_typeof(snapshot) = 'object'), CONSTRAINT fk_asset_versions_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_versions_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_asset_versions_asset_number ON asset_versions (asset_id, version_number DESC) `); await queryRunner.query(` CREATE INDEX idx_asset_versions_occurred_at ON asset_versions (occurred_at DESC) `); await queryRunner.query(` CREATE INDEX idx_asset_versions_change_type ON asset_versions (change_type) `); await queryRunner.query(` CREATE INDEX idx_asset_versions_snapshot_gin ON asset_versions USING GIN (snapshot jsonb_path_ops) `); await queryRunner.query('UPDATE assets SET current_version = 1'); await queryRunner.query(` INSERT INTO asset_versions ( asset_id, version_number, change_type, changed_fields, snapshot, occurred_at, actor_user_id, actor_username, source ) SELECT asset.id, 1, 'BASELINE', ARRAY['baseline']::text[], JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'description', asset.description, 'type', JSONB_BUILD_OBJECT( 'id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name ), 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', parent.id, 'code', parent.code, 'name', parent.name ) END, 'informationStatus', asset.information_status, 'attributes', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'definitionId', definition.id, 'code', definition.code, 'name', definition.name, 'dataType', definition.data_type, 'isRequired', definition.is_required, 'unit', definition.unit, 'options', definition.options, 'sortOrder', definition.sort_order, 'value', value.value ) ORDER BY definition.sort_order, definition.name) FROM asset_attribute_definitions definition LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true ), '[]'::jsonb), 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'assetId', geometry.asset_id, 'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, 'geometryType', geometry.geometry_type, 'source', geometry.source, 'accuracyM', geometry.accuracy_m::double precision, 'capturedAt', geometry.captured_at, 'deviceLabel', geometry.device_label, 'createdAt', geometry.created_at, 'updatedAt', geometry.updated_at, 'updatedBy', geometry.updated_by ) END, 'createdAt', asset.created_at, 'updatedAt', asset.updated_at, 'createdBy', asset.created_by, 'updatedBy', asset.updated_by, 'currentVersion', 1 ), asset.updated_at, asset.updated_by, actor.username, 'SYSTEM' FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets parent ON parent.id = asset.parent_id LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id LEFT JOIN users actor ON actor.id = asset.updated_by `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('assets.read_history', 'Consultar versiones históricas completas de activos') 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 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'); await queryRunner.query(` GRANT SELECT, INSERT ON TABLE asset_versions TO ${quoteIdentifier(appRole)} `); await queryRunner.query(` REVOKE UPDATE, DELETE ON TABLE asset_versions FROM ${quoteIdentifier(appRole)} `); } 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 = $1 AND NOT EXISTS ( SELECT 1 FROM role_permissions WHERE permission_id = permissions.id )`, [permissionCode], ); await queryRunner.query('DROP TABLE asset_versions'); await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_current_version'); await queryRunner.query('ALTER TABLE assets DROP COLUMN current_version'); } }