Files
dh-inspeccion-v2/api-v3/src/asset-master/asset-history.service.ts
T

370 lines
15 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import {
AssetVersionChangeType,
AuditSource,
} from '../database/entities';
import { changedSnapshotFields } from './asset-version-diff';
import type {
AssetVersionPageQueryDto,
ListAssetVersionsQueryDto,
} from './dto/list-asset-versions-query.dto';
export interface AssetVersionSummary {
id: string;
assetId: string;
assetCode: string;
assetName: string;
typeId: string;
typeName: string;
informationStatus: string;
operationalStatus: string;
versionNumber: number;
changeType: AssetVersionChangeType;
changedFields: string[];
occurredAt: Date;
actorUserId: string | null;
actorUsername: string | null;
source: AuditSource;
requestId: string | null;
isCurrent: boolean;
}
export interface AssetVersionDetail extends AssetVersionSummary {
snapshot: Record<string, unknown>;
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
function versionNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_VERSION_NOT_FOUND',
message: 'Versión de activo no encontrada',
});
}
@Injectable()
export class AssetHistoryService {
constructor(private readonly dataSource: DataSource) {}
async capture(
manager: EntityManager,
assetId: string,
changeType: AssetVersionChangeType,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<number> {
await manager.query(
`UPDATE assets
SET current_version = COALESCE(current_version, 0) + 1
WHERE id = $1`,
[assetId],
);
const [versionRow] = (await manager.query(
`SELECT current_version
FROM assets
WHERE id = $1`,
[assetId],
)) as Array<{ current_version: number | string | null }>;
if (!versionRow) throw assetNotFound();
const versionNumber = Number(versionRow.current_version);
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
}
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
const [previousRow] = (await manager.query(
`SELECT snapshot
FROM asset_versions
WHERE asset_id = $1 AND version_number < $2
ORDER BY version_number DESC
LIMIT 1`,
[assetId, versionNumber],
)) as Array<{ snapshot: Record<string, unknown> }>;
const changedFields = changedSnapshotFields(previousRow?.snapshot ?? null, snapshot);
const source = principal.transport === 'bearer'
? AuditSource.ANDROID
: AuditSource.WEB;
await manager.query(
`INSERT INTO asset_versions (
asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
assetId,
versionNumber,
changeType,
changedFields,
snapshot,
principal.userId,
principal.username,
source,
request.requestId,
],
);
return versionNumber;
}
async list(query: ListAssetVersionsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
version.snapshot->>'code' ILIKE ${search}
OR version.snapshot->>'name' ILIKE ${search}
OR version.actor_username ILIKE ${search}
)`);
}
if (query.typeId) {
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
}
if (query.status) {
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
}
if (query.changeType) {
conditions.push(`version.change_type = ${add(query.changeType)}`);
}
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
}
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
await this.requireAsset(assetId);
return this.listWithConditions(
query.page,
query.pageSize,
['version.asset_id = $1'],
[assetId],
);
}
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
await this.requireAsset(assetId);
const [row] = (await this.dataSource.query(
`${this.selectSummary()}, version.snapshot
FROM asset_versions version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
WHERE version.asset_id = $1 AND version.version_number = $2`,
[assetId, versionNumber],
)) as AssetVersionDetail[];
if (!row) throw versionNotFound();
return row;
}
private async listWithConditions(
page: number,
pageSize: number,
conditions: string[],
parameters: unknown[],
) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
const limit = `$${parameters.length + 1}`;
const offset = `$${parameters.length + 2}`;
const data = (await this.dataSource.query(
`${this.selectSummary()}
FROM asset_versions version
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
${where}
ORDER BY version.occurred_at DESC, version.version_number DESC
LIMIT ${limit} OFFSET ${offset}`,
paginated,
)) as AssetVersionSummary[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
};
}
private selectSummary(): string {
return `SELECT
version.id,
version.asset_id AS "assetId",
version.snapshot->>'code' AS "assetCode",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber",
version.change_type AS "changeType",
version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt",
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.source,
version.request_id AS "requestId",
(version.version_number = current_asset.current_version) AS "isCurrent"`;
}
private async requireAsset(assetId: string): Promise<void> {
const [row] = (await this.dataSource.query(
'SELECT 1 FROM assets WHERE id = $1',
[assetId],
)) as unknown[];
if (!row) throw assetNotFound();
}
private async loadCurrentSnapshot(
manager: EntityManager,
assetId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query(
`SELECT JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'commonName', asset.common_name,
'description', asset.description,
'type', JSONB_BUILD_OBJECT(
'id', asset_type.id,
'code', asset_type.code,
'name', asset_type.name,
'operationalRole', asset_type.operational_role
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operational_area.id,
'code', operational_area.code,
'name', operational_area.name
) END,
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operator_company.id,
'code', operator_company.code,
'name', operator_company.name
) END,
'informationStatus', asset.information_status,
'operationalStatus', asset.operational_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,
'media', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id,
'kind', media.kind,
'originalName', media.original_name,
'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'uploadedBy', media.uploaded_by,
'createdAt', media.created_at,
'updatedAt', media.updated_at
) ORDER BY media.created_at, media.id)
FROM asset_media media
WHERE media.asset_id = asset.id
AND media.deleted_at IS NULL
), '[]'::jsonb),
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
'organizationMemberships', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',m.id,'parentOrganizationId',m.parent_organization_id,'memberOrganizationId',m.member_organization_id,'role',m.role,'participationPercent',m.participation_percent::double precision,'validFrom',m.valid_from,'validUntil',m.valid_until,'sourceDocumentId',m.source_document_id,'notes',m.notes,'endReason',m.end_reason) ORDER BY m.valid_until NULLS FIRST,m.valid_from DESC)
FROM organization_memberships m WHERE m.parent_organization_id=asset.id OR m.member_organization_id=asset.id
),'[]'::jsonb),
'externalIdentifiers', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',i.id,'namespace',i.namespace,'value',i.value,'validFrom',i.valid_from,'validUntil',i.valid_until,'sourceDocumentId',i.source_document_id,'notes',i.notes,'endReason',i.end_reason) ORDER BY i.valid_until NULLS FIRST,i.namespace,i.valid_from DESC) FROM asset_external_identifiers i WHERE i.asset_id=asset.id
),'[]'::jsonb),
'sourceDocuments', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('linkId',l.id,'relationType',l.relation_type,'documentId',d.id,'documentType',d.document_type,'documentNumber',d.document_number,'title',d.title,'issuer',d.issuer,'documentDate',d.document_date,'externalReference',d.external_reference) ORDER BY d.document_date DESC NULLS LAST,d.created_at DESC) FROM asset_source_documents l JOIN source_documents d ON d.id=l.document_id WHERE l.asset_id=asset.id
),'[]'::jsonb),
'legalRights', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
),'[]'::jsonb),
'provenance', JSONB_BUILD_OBJECT(
'origin', asset.data_origin,
'sourceName', asset.source_name,
'sourceReference', asset.source_reference,
'observedAt', asset.source_observed_at,
'notes', asset.source_notes,
'verifiedAt', asset.provenance_verified_at,
'verifiedBy', asset.provenance_verified_by,
'updatedAt', asset.provenance_updated_at,
'updatedBy', asset.provenance_updated_by
),
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', asset.current_version
) AS snapshot
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 assets operational_area ON operational_area.id = asset.operational_area_id
LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
WHERE asset.id = $1`,
[assetId],
)) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw assetNotFound();
return row.snapshot;
}
}