import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = [ 'asset_types.read', 'asset_types.manage', 'assets.read', 'assets.create', 'assets.update', 'assets.change_status', ] as const; const rolePermissionValues = ` ('admin', 'asset_types.read'), ('admin', 'asset_types.manage'), ('admin', 'assets.read'), ('admin', 'assets.create'), ('admin', 'assets.update'), ('admin', 'assets.change_status'), ('director', 'asset_types.read'), ('director', 'assets.read'), ('director', 'assets.change_status'), ('supervisor', 'asset_types.read'), ('supervisor', 'assets.read'), ('supervisor', 'assets.create'), ('supervisor', 'assets.update'), ('supervisor', 'assets.change_status'), ('inspector', 'asset_types.read'), ('inspector', 'assets.read'), ('inspector', 'assets.create'), ('inspector', 'assets.update'), ('auditor', 'asset_types.read'), ('auditor', 'assets.read') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseB1AssetMaster1786636800000 implements MigrationInterface { name = 'PhaseB1AssetMaster1786636800000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TYPE asset_information_status AS ENUM ( 'DRAFT', 'PENDING_SURVEY', 'SURVEYED', 'VALIDATED', 'OBSERVED', 'OUTDATED', 'INACTIVE' ) `); await queryRunner.query(` CREATE TYPE asset_attribute_data_type AS ENUM ( 'TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'DATETIME', 'SELECT' ) `); await queryRunner.query(` CREATE TABLE asset_types ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(80) NOT NULL, name varchar(160) NOT NULL, description text NOT NULL DEFAULT '', can_be_root boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, updated_by uuid, CONSTRAINT fk_asset_types_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_asset_types_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query( 'CREATE UNIQUE INDEX uq_asset_types_code_ci ON asset_types (LOWER(code))', ); await queryRunner.query( 'CREATE INDEX idx_asset_types_is_active ON asset_types (is_active)', ); await queryRunner.query(` CREATE TABLE asset_type_parent_rules ( child_type_id uuid NOT NULL, parent_type_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT pk_asset_type_parent_rules PRIMARY KEY (child_type_id, parent_type_id), CONSTRAINT chk_asset_type_parent_rules_not_self CHECK (child_type_id <> parent_type_id), CONSTRAINT fk_asset_type_parent_rules_child FOREIGN KEY (child_type_id) REFERENCES asset_types(id) ON DELETE CASCADE, CONSTRAINT fk_asset_type_parent_rules_parent FOREIGN KEY (parent_type_id) REFERENCES asset_types(id) ON DELETE RESTRICT ) `); await queryRunner.query(` CREATE INDEX idx_asset_type_parent_rules_parent ON asset_type_parent_rules (parent_type_id) `); await queryRunner.query(` CREATE TABLE asset_attribute_definitions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_type_id uuid NOT NULL, code varchar(80) NOT NULL, name varchar(160) NOT NULL, data_type asset_attribute_data_type NOT NULL, is_required boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, unit varchar(40), options jsonb, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_asset_attribute_sort_order CHECK (sort_order >= 0), CONSTRAINT chk_asset_attribute_select_options CHECK ( (data_type = 'SELECT' AND options IS NOT NULL AND jsonb_typeof(options) = 'array') OR (data_type <> 'SELECT' AND options IS NULL) ), CONSTRAINT fk_asset_attribute_definitions_type FOREIGN KEY (asset_type_id) REFERENCES asset_types(id) ON DELETE RESTRICT ) `); await queryRunner.query(` CREATE UNIQUE INDEX uq_asset_attribute_definitions_code_ci ON asset_attribute_definitions (asset_type_id, LOWER(code)) `); await queryRunner.query(` CREATE INDEX idx_asset_attribute_definitions_type ON asset_attribute_definitions (asset_type_id, sort_order) `); await queryRunner.query(` CREATE INDEX idx_asset_attribute_definitions_active ON asset_attribute_definitions (is_active) `); await queryRunner.query(` CREATE TABLE assets ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_type_id uuid NOT NULL, parent_id uuid, code varchar(120) NOT NULL, name varchar(200) NOT NULL, description text, information_status asset_information_status NOT NULL DEFAULT 'DRAFT', created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, updated_by uuid, CONSTRAINT chk_assets_not_self_parent CHECK (id <> parent_id), CONSTRAINT fk_assets_type FOREIGN KEY (asset_type_id) REFERENCES asset_types(id) ON DELETE RESTRICT, CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_assets_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_assets_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query( 'CREATE UNIQUE INDEX uq_assets_code_ci ON assets (LOWER(code))', ); await queryRunner.query('CREATE INDEX idx_assets_type_id ON assets (asset_type_id)'); await queryRunner.query('CREATE INDEX idx_assets_parent_id ON assets (parent_id)'); await queryRunner.query( 'CREATE INDEX idx_assets_information_status ON assets (information_status)', ); await queryRunner.query(` CREATE FUNCTION enforce_asset_hierarchy_rules() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.parent_id IS NULL THEN IF NOT EXISTS ( SELECT 1 FROM asset_types WHERE id = NEW.asset_type_id AND can_be_root = true ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'asset type does not allow root assets'; END IF; ELSE IF NEW.parent_id = NEW.id THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'asset cannot be its own parent'; END IF; IF NOT EXISTS ( SELECT 1 FROM assets parent INNER JOIN asset_type_parent_rules rule ON rule.parent_type_id = parent.asset_type_id AND rule.child_type_id = NEW.asset_type_id WHERE parent.id = NEW.parent_id ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'asset parent type is not allowed'; END IF; IF EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM assets WHERE id = NEW.parent_id UNION ALL SELECT parent.id, parent.parent_id FROM assets parent INNER JOIN ancestors current ON parent.id = current.parent_id ) SELECT 1 FROM ancestors WHERE id = NEW.id ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'asset hierarchy cycle detected'; END IF; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_assets_hierarchy_rules BEFORE INSERT OR UPDATE OF asset_type_id, parent_id ON assets FOR EACH ROW EXECUTE FUNCTION enforce_asset_hierarchy_rules() `); await queryRunner.query(` CREATE TABLE asset_attribute_values ( asset_id uuid NOT NULL, definition_id uuid NOT NULL, value jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by uuid, CONSTRAINT pk_asset_attribute_values PRIMARY KEY (asset_id, definition_id), CONSTRAINT fk_asset_attribute_values_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE, CONSTRAINT fk_asset_attribute_values_definition FOREIGN KEY (definition_id) REFERENCES asset_attribute_definitions(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_attribute_values_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_asset_attribute_values_definition ON asset_attribute_values (definition_id) `); await queryRunner.query(` CREATE FUNCTION enforce_asset_attribute_type_match() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ( SELECT asset.asset_type_id IS DISTINCT FROM definition.asset_type_id FROM assets asset, asset_attribute_definitions definition WHERE asset.id = NEW.asset_id AND definition.id = NEW.definition_id ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'attribute definition does not belong to asset type'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_asset_attribute_type_match BEFORE INSERT OR UPDATE OF asset_id, definition_id ON asset_attribute_values FOR EACH ROW EXECUTE FUNCTION enforce_asset_attribute_type_match() `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('asset_types.read', 'Consultar tipos y atributos del Maestro de Activos'), ('asset_types.manage', 'Administrar tipos, jerarquías permitidas y atributos'), ('assets.read', 'Listar y consultar activos'), ('assets.create', 'Crear activos'), ('assets.update', 'Editar activos y sus atributos'), ('assets.change_status', 'Cambiar el estado de información 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'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(` GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE asset_types, asset_type_parent_rules, asset_attribute_definitions, assets, asset_attribute_values TO ${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 TRIGGER trg_asset_attribute_type_match ON asset_attribute_values'); await queryRunner.query('DROP FUNCTION enforce_asset_attribute_type_match'); await queryRunner.query('DROP TABLE asset_attribute_values'); await queryRunner.query('DROP TRIGGER trg_assets_hierarchy_rules ON assets'); await queryRunner.query('DROP FUNCTION enforce_asset_hierarchy_rules'); await queryRunner.query('DROP TABLE assets'); await queryRunner.query('DROP TABLE asset_attribute_definitions'); await queryRunner.query('DROP TABLE asset_type_parent_rules'); await queryRunner.query('DROP TABLE asset_types'); await queryRunner.query('DROP TYPE asset_attribute_data_type'); await queryRunner.query('DROP TYPE asset_information_status'); } }