Files
dh-inspeccion-v2/api-v3/src/database/migrations/1786723201000-phase-b3-asset-versions.ts
T

204 lines
7.9 KiB
TypeScript

import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCode = 'assets.read_history';
const rolePermissionValues = `
('admin', 'assets.read_history'),
('director', 'assets.read_history'),
('supervisor', 'assets.read_history'),
('inspector', 'assets.read_history'),
('auditor', 'assets.read_history')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseB3AssetVersions1786723201000 implements MigrationInterface {
name = 'PhaseB3AssetVersions1786723201000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN current_version integer NOT NULL DEFAULT 0,
ADD CONSTRAINT chk_assets_current_version CHECK (current_version >= 0)
`);
await queryRunner.query(`
CREATE TABLE asset_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
version_number integer NOT NULL,
change_type varchar(32) NOT NULL,
changed_fields text[] NOT NULL DEFAULT '{}',
snapshot jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
actor_user_id uuid,
actor_username varchar(80),
source varchar(32) NOT NULL,
request_id varchar(128),
CONSTRAINT uq_asset_versions_asset_number
UNIQUE (asset_id, version_number),
CONSTRAINT chk_asset_versions_number CHECK (version_number >= 1),
CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED'
)),
CONSTRAINT chk_asset_versions_source CHECK (source IN (
'WEB', 'ANDROID', 'SYSTEM', 'IMPORT'
)),
CONSTRAINT chk_asset_versions_snapshot_object
CHECK (jsonb_typeof(snapshot) = 'object'),
CONSTRAINT fk_asset_versions_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_asset_number
ON asset_versions (asset_id, version_number DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_occurred_at
ON asset_versions (occurred_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_change_type
ON asset_versions (change_type)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_snapshot_gin
ON asset_versions USING GIN (snapshot jsonb_path_ops)
`);
await queryRunner.query('UPDATE assets SET current_version = 1');
await queryRunner.query(`
INSERT INTO asset_versions (
asset_id, version_number, change_type, changed_fields, snapshot,
occurred_at, actor_user_id, actor_username, source
)
SELECT
asset.id,
1,
'BASELINE',
ARRAY['baseline']::text[],
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'description', asset.description,
'type', JSONB_BUILD_OBJECT(
'id', asset_type.id,
'code', asset_type.code,
'name', asset_type.name
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'informationStatus', asset.information_status,
'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order,
'value', value.value
) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value
ON value.definition_id = definition.id
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
'geometryType', geometry.geometry_type,
'source', geometry.source,
'accuracyM', geometry.accuracy_m::double precision,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,
'createdAt', geometry.created_at,
'updatedAt', geometry.updated_at,
'updatedBy', geometry.updated_by
) END,
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', 1
),
asset.updated_at,
asset.updated_by,
actor.username,
'SYSTEM'
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
LEFT JOIN users actor ON actor.id = asset.updated_by
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('assets.read_history', 'Consultar versiones históricas completas 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');
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE asset_versions
TO ${quoteIdentifier(appRole)}
`);
await queryRunner.query(`
REVOKE UPDATE, DELETE ON TABLE asset_versions
FROM ${quoteIdentifier(appRole)}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 = $1
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)`,
[permissionCode],
);
await queryRunner.query('DROP TABLE asset_versions');
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_current_version');
await queryRunner.query('ALTER TABLE assets DROP COLUMN current_version');
}
}