475 lines
20 KiB
TypeScript
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');
|
|
}
|
|
}
|