import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = [ 'asset_relations.read', 'asset_relations.manage', ] as const; const rolePermissionValues = ` ('admin', 'asset_relations.read'), ('admin', 'asset_relations.manage'), ('director', 'asset_relations.read'), ('supervisor', 'asset_relations.read'), ('supervisor', 'asset_relations.manage'), ('inspector', 'asset_relations.read'), ('auditor', 'asset_relations.read') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD53OperationalContext1787421600000 implements MigrationInterface { name = 'PhaseD53OperationalContext1787421600000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TYPE asset_type_operational_role AS ENUM ('GENERIC', 'AREA', 'COMPANY') `); await queryRunner.query(` ALTER TABLE asset_types ADD COLUMN operational_role asset_type_operational_role NOT NULL DEFAULT 'GENERIC' `); await queryRunner.query(` CREATE INDEX idx_asset_types_operational_role ON asset_types (operational_role) `); await queryRunner.query(` CREATE TABLE area_company_relations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), area_id uuid NOT NULL, company_id uuid NOT NULL, valid_from timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, valid_until timestamptz, start_reason text NOT NULL, end_reason text, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, ended_by uuid, CONSTRAINT chk_area_company_relation_dates CHECK (valid_until IS NULL OR valid_until >= valid_from), CONSTRAINT chk_area_company_relation_start_reason CHECK (length(btrim(start_reason)) >= 3), CONSTRAINT chk_area_company_relation_end_reason CHECK (valid_until IS NULL OR (end_reason IS NOT NULL AND length(btrim(end_reason)) >= 3)), CONSTRAINT fk_area_company_relation_area FOREIGN KEY (area_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_area_company_relation_company FOREIGN KEY (company_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_area_company_relation_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_area_company_relation_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_area_company_relations_area_id ON area_company_relations (area_id, valid_from DESC) `); await queryRunner.query(` CREATE INDEX idx_area_company_relations_company_id ON area_company_relations (company_id, valid_from DESC) `); await queryRunner.query(` CREATE INDEX idx_area_company_relations_valid_until ON area_company_relations (valid_until) `); await queryRunner.query(` CREATE UNIQUE INDEX uq_area_company_relations_active_pair ON area_company_relations (area_id, company_id) WHERE valid_until IS NULL `); await queryRunner.query(` ALTER TABLE assets ADD COLUMN operational_area_id uuid, ADD COLUMN operator_company_id uuid, ADD CONSTRAINT chk_assets_operational_assignment_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) ), ADD CONSTRAINT fk_assets_operational_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT, ADD CONSTRAINT fk_assets_operator_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT `); await queryRunner.query(` CREATE INDEX idx_assets_operational_area_id ON assets (operational_area_id) `); await queryRunner.query(` CREATE INDEX idx_assets_operator_company_id ON assets (operator_company_id) `); await queryRunner.query(` CREATE INDEX idx_assets_operational_context ON assets (operational_area_id, operator_company_id) `); await queryRunner.query(` UPDATE asset_versions SET snapshot = JSONB_SET( JSONB_SET( JSONB_SET( snapshot, '{type,operationalRole}', '"GENERIC"'::jsonb, true ), '{operationalArea}', 'null'::jsonb, true ), '{operatorCompany}', 'null'::jsonb, true ) `); await queryRunner.query(` CREATE FUNCTION protect_operational_type_changes() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.operational_role IN ('AREA'::asset_type_operational_role, 'COMPANY'::asset_type_operational_role) AND EXISTS ( SELECT 1 FROM assets WHERE asset_type_id = NEW.id AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL) ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA or COMPANY types cannot contain assets with their own operational assignment'; END IF; IF OLD.operational_role = 'AREA'::asset_type_operational_role AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false) AND ( EXISTS ( SELECT 1 FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id WHERE area.asset_type_id = OLD.id AND relation.valid_until IS NULL ) OR EXISTS ( SELECT 1 FROM assets assigned INNER JOIN assets area ON area.id = assigned.operational_area_id WHERE area.asset_type_id = OLD.id ) ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA type cannot change role or be inactivated while it is operationally in use'; END IF; IF OLD.operational_role = 'COMPANY'::asset_type_operational_role AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false) AND ( EXISTS ( SELECT 1 FROM area_company_relations relation INNER JOIN assets company ON company.id = relation.company_id WHERE company.asset_type_id = OLD.id AND relation.valid_until IS NULL ) OR EXISTS ( SELECT 1 FROM assets assigned INNER JOIN assets company ON company.id = assigned.operator_company_id WHERE company.asset_type_id = OLD.id ) ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'COMPANY type cannot change role or be inactivated while it is operationally in use'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_asset_types_protect_operational_changes BEFORE UPDATE OF operational_role, is_active ON asset_types FOR EACH ROW EXECUTE FUNCTION protect_operational_type_changes() `); await queryRunner.query(` CREATE FUNCTION enforce_area_company_relation_roles() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE area_role asset_type_operational_role; company_role asset_type_operational_role; BEGIN SELECT asset_type.operational_role INTO area_role FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset.id = NEW.area_id AND asset.information_status <> 'INACTIVE' AND asset_type.is_active = true; SELECT asset_type.operational_role INTO company_role FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset.id = NEW.company_id AND asset.information_status <> 'INACTIVE' AND asset_type.is_active = true; IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area relation target must be an active AREA asset'; END IF; IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company relation target must be an active COMPANY asset'; END IF; IF NEW.area_id = NEW.company_id THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company must be different assets'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_area_company_relation_roles BEFORE INSERT OR UPDATE OF area_id, company_id ON area_company_relations FOR EACH ROW EXECUTE FUNCTION enforce_area_company_relation_roles() `); await queryRunner.query(` CREATE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid; BEGIN IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF; IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company must be assigned together'; END IF; SELECT operational_role INTO asset_role FROM asset_types WHERE id = NEW.asset_type_id; IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company assets cannot receive an operational assignment'; END IF; SELECT asset_type.operational_role INTO area_role FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset.id = NEW.operational_area_id AND asset.information_status <> 'INACTIVE' AND asset_type.is_active = true; SELECT asset_type.operational_role INTO company_role FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset.id = NEW.operator_company_id AND asset.information_status <> 'INACTIVE' AND asset_type.is_active = true; IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an active AREA asset'; END IF; IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operator company must be an active COMPANY asset'; END IF; SELECT relation.id INTO active_relation_id FROM area_company_relations relation WHERE relation.area_id = NEW.operational_area_id AND relation.company_id = NEW.operator_company_id AND relation.valid_until IS NULL FOR KEY SHARE; IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company do not have an active relation'; END IF; IF NEW.parent_id IS NULL OR NOT 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.operational_area_id LIMIT 1 ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an ancestor in the physical hierarchy'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_assets_operational_context BEFORE INSERT OR UPDATE OF asset_type_id, parent_id, operational_area_id, operator_company_id ON assets FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_context() `); await queryRunner.query(` CREATE FUNCTION enforce_asset_operational_descendants() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.parent_id IS NOT DISTINCT FROM OLD.parent_id THEN RETURN NEW; END IF; IF EXISTS ( WITH RECURSIVE descendants AS ( SELECT child.id, child.parent_id, child.operational_area_id FROM assets child WHERE child.parent_id = NEW.id UNION ALL SELECT child.id, child.parent_id, child.operational_area_id FROM assets child INNER JOIN descendants parent ON child.parent_id = parent.id ) SELECT 1 FROM descendants descendant WHERE descendant.operational_area_id IS NOT NULL AND NOT EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM assets WHERE id = descendant.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 = descendant.operational_area_id LIMIT 1 ) LIMIT 1 ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'physical hierarchy change would invalidate descendant operational assignments'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_assets_operational_descendants AFTER UPDATE OF parent_id ON assets FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_descendants() `); await queryRunner.query(` CREATE FUNCTION protect_operational_anchor_inactivation() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE role asset_type_operational_role; BEGIN IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status OR NEW.information_status <> 'INACTIVE' THEN RETURN NEW; END IF; SELECT operational_role INTO role FROM asset_types WHERE id = NEW.asset_type_id; IF role = 'AREA'::asset_type_operational_role AND ( EXISTS (SELECT 1 FROM area_company_relations WHERE area_id = NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id = NEW.id) ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area cannot be inactivated while operational relations or assignments are active'; END IF; IF role = 'COMPANY'::asset_type_operational_role AND ( EXISTS (SELECT 1 FROM area_company_relations WHERE company_id = NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id = NEW.id) ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company cannot be inactivated while operational relations or assignments are active'; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_assets_protect_operational_anchor_inactivation BEFORE UPDATE OF information_status ON assets FOR EACH ROW EXECUTE FUNCTION protect_operational_anchor_inactivation() `); 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, permission === 'asset_relations.read' ? 'Consultar relaciones operativas entre áreas y empresas' : 'Administrar relaciones operativas entre áreas y empresas', ], ); } 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 ON TABLE area_company_relations TO ${applicationRole} `); await queryRunner.query(` REVOKE DELETE ON TABLE area_company_relations FROM ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` UPDATE asset_versions SET snapshot = (snapshot #- '{operationalArea}' #- '{operatorCompany}' #- '{type,operationalRole}') `); 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::text[]) AND NOT EXISTS ( SELECT 1 FROM role_permissions WHERE permission_id = permissions.id ) `, [newPermissions]); await queryRunner.query('DROP TRIGGER trg_asset_types_protect_operational_changes ON asset_types'); await queryRunner.query('DROP FUNCTION protect_operational_type_changes()'); await queryRunner.query('DROP TRIGGER trg_assets_protect_operational_anchor_inactivation ON assets'); await queryRunner.query('DROP FUNCTION protect_operational_anchor_inactivation()'); await queryRunner.query('DROP TRIGGER trg_assets_operational_descendants ON assets'); await queryRunner.query('DROP FUNCTION enforce_asset_operational_descendants()'); await queryRunner.query('DROP TRIGGER trg_assets_operational_context ON assets'); await queryRunner.query('DROP FUNCTION enforce_asset_operational_context()'); await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operator_company'); await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operational_area'); await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_operational_assignment_pair'); await queryRunner.query('DROP INDEX idx_assets_operational_context'); await queryRunner.query('DROP INDEX idx_assets_operator_company_id'); await queryRunner.query('DROP INDEX idx_assets_operational_area_id'); await queryRunner.query('ALTER TABLE assets DROP COLUMN operator_company_id'); await queryRunner.query('ALTER TABLE assets DROP COLUMN operational_area_id'); await queryRunner.query('DROP TRIGGER trg_area_company_relation_roles ON area_company_relations'); await queryRunner.query('DROP FUNCTION enforce_area_company_relation_roles()'); await queryRunner.query('DROP TABLE area_company_relations'); await queryRunner.query('DROP INDEX idx_asset_types_operational_role'); await queryRunner.query('ALTER TABLE asset_types DROP COLUMN operational_role'); await queryRunner.query('DROP TYPE asset_type_operational_role'); } }