Files
dh-inspeccion-v2/api-v3/src/database/migrations/1787421600000-phase-d5-3-operational-context.ts
T

475 lines
20 KiB
TypeScript

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<void> {
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<void> {
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');
}
}