Files
dh-inspeccion-v2/api-v3/src/database/migrations/1788804000000-phase-d5-3-24-asset-context-history.ts
T

188 lines
9.3 KiB
TypeScript

import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5324AssetContextHistory1788804000000 implements MigrationInterface {
name = 'PhaseD5324AssetContextHistory1788804000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE asset_context_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
parent_id uuid,
operational_area_id uuid,
operator_company_id uuid,
valid_from timestamptz NOT NULL,
valid_until timestamptz,
change_reason text NOT NULL,
end_reason text,
asset_version_number integer NOT NULL,
source varchar(32) NOT NULL DEFAULT 'WEB',
request_id varchar(128),
created_by uuid,
ended_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at timestamptz,
CONSTRAINT fk_asset_context_history_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_context_history_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_asset_context_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT chk_asset_context_history_context_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)
),
CONSTRAINT chk_asset_context_history_reason CHECK (length(btrim(change_reason)) >= 5),
CONSTRAINT chk_asset_context_history_version CHECK (asset_version_number >= 1)
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_context_history_active ON asset_context_history (asset_id) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_asset_period ON asset_context_history (asset_id, valid_from DESC, valid_until)`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_area_period ON asset_context_history (operational_area_id, valid_from DESC) WHERE operational_area_id IS NOT NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_company_period ON asset_context_history (operator_company_id, valid_from DESC) WHERE operator_company_id IS NOT NULL`);
await queryRunner.query(`
WITH ordered AS (
SELECT
version.asset_id,
version.version_number,
version.occurred_at,
NULLIF(version.snapshot #>> '{parent,id}', '')::uuid AS parent_id,
NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid AS operational_area_id,
NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid AS operator_company_id,
version.actor_user_id,
version.source,
version.request_id,
ROW_NUMBER() OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS row_number,
LAG(NULLIF(version.snapshot #>> '{parent,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_parent_id,
LAG(NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operational_area_id,
LAG(NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operator_company_id
FROM asset_versions version
), changes AS (
SELECT *
FROM ordered
WHERE row_number = 1
OR parent_id IS DISTINCT FROM previous_parent_id
OR operational_area_id IS DISTINCT FROM previous_operational_area_id
OR operator_company_id IS DISTINCT FROM previous_operator_company_id
), bounded AS (
SELECT
changes.*,
LEAD(changes.occurred_at) OVER (PARTITION BY changes.asset_id ORDER BY changes.occurred_at, changes.version_number) AS valid_until
FROM changes
)
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, valid_until, change_reason, asset_version_number,
source, request_id, created_by
)
SELECT
bounded.asset_id,
bounded.parent_id,
bounded.operational_area_id,
bounded.operator_company_id,
bounded.occurred_at,
bounded.valid_until,
CASE WHEN bounded.row_number = 1
THEN 'Contexto inicial reconstruido desde el historial versionado'
ELSE 'Cambio de contexto reconstruido desde el historial versionado'
END,
bounded.version_number,
bounded.source,
bounded.request_id,
bounded.actor_user_id
FROM bounded
ORDER BY bounded.asset_id, bounded.occurred_at, bounded.version_number
`);
await queryRunner.query(`
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, change_reason, asset_version_number, source, created_by
)
SELECT
asset.id, asset.parent_id, asset.operational_area_id, asset.operator_company_id,
asset.created_at, 'Contexto inicial incorporado al historial', GREATEST(asset.current_version, 1),
'SYSTEM', asset.created_by
FROM assets asset
WHERE NOT EXISTS (
SELECT 1 FROM asset_context_history history WHERE history.asset_id = asset.id
)
`);
await queryRunner.query(`
WITH mismatched AS (
SELECT
history.id AS history_id,
asset.id AS asset_id,
asset.parent_id,
asset.operational_area_id,
asset.operator_company_id,
GREATEST(asset.updated_at, history.valid_from) AS effective_from,
GREATEST(asset.current_version, 1) AS asset_version_number,
asset.updated_by
FROM asset_context_history history
JOIN assets asset ON asset.id=history.asset_id
WHERE history.valid_until IS NULL
AND (
history.parent_id IS DISTINCT FROM asset.parent_id
OR history.operational_area_id IS DISTINCT FROM asset.operational_area_id
OR history.operator_company_id IS DISTINCT FROM asset.operator_company_id
)
), closed AS (
UPDATE asset_context_history history
SET valid_until=mismatched.effective_from,
end_reason='Sincronización con contexto vigente al instalar D5.3.24',
ended_at=CURRENT_TIMESTAMP
FROM mismatched
WHERE history.id=mismatched.history_id
RETURNING mismatched.*
)
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, change_reason, asset_version_number, source, created_by
)
SELECT
closed.asset_id, closed.parent_id, closed.operational_area_id, closed.operator_company_id,
closed.effective_from, 'Contexto vigente sincronizado al instalar D5.3.24',
closed.asset_version_number, 'SYSTEM', closed.updated_by
FROM closed
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('assets.manage_context', 'Cambiar jerarquía, Área u Operadora preservando el historial temporal')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('admin', 'assets.manage_context'),
('supervisor', 'assets.manage_context')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
JOIN roles role ON role.code = mapping.role_code
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_context_history TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE asset_context_history FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code = 'assets.manage_context'`);
await queryRunner.query(`DELETE FROM permissions WHERE code = 'assets.manage_context'`);
await queryRunner.query(`DROP TABLE IF EXISTS asset_context_history`);
}
}