import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext, } from '../common/http/request-context'; import { AssetDataOrigin, AssetVersionChangeType, AuditAction, } from '../database/entities'; import { AssetHistoryService } from './asset-history.service'; import type { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto'; export interface AssetProvenanceView { assetId: string; origin: AssetDataOrigin; sourceName: string | null; sourceReference: string | null; observedAt: Date | null; notes: string | null; verifiedAt: Date | null; verifiedBy: string | null; verifiedByUsername: string | null; updatedAt: Date; updatedBy: string | null; updatedByUsername: string | null; } function assetNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Activo no encontrado', }); } @Injectable() export class AssetProvenanceService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, ) {} get(assetId: string): Promise { return this.load(this.dataSource.manager, assetId); } async update( assetId: string, dto: UpdateAssetProvenanceDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { this.validateSource(dto); return this.dataSource.transaction(async (manager) => { const before = await this.load(manager, assetId, true); await manager.query( `UPDATE assets SET data_origin = $2, source_name = $3, source_reference = $4, source_observed_at = $5, source_notes = $6, provenance_verified_at = NULL, provenance_verified_by = NULL, provenance_updated_at = CURRENT_TIMESTAMP, provenance_updated_by = $7, updated_at = CURRENT_TIMESTAMP, updated_by = $7 WHERE id = $1`, [ assetId, dto.origin, dto.sourceName?.trim() || null, dto.sourceReference?.trim() || null, dto.observedAt ? new Date(dto.observedAt) : null, dto.notes?.trim() || null, principal.userId, ], ); const versionNumber = await this.history.capture( manager, assetId, AssetVersionChangeType.PROVENANCE_UPDATED, principal, request, ); const updated = await this.load(manager, assetId); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_PROVENANCE_UPDATED, entityType: 'asset_provenance', entityId: assetId, beforeData: { ...before }, afterData: { ...updated }, metadata: { versionNumber, verificationCleared: before.verifiedAt !== null }, }, manager, ); return updated; }); } async verify( assetId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const before = await this.load(manager, assetId, true); if (before.verifiedAt) return before; await manager.query( `UPDATE assets SET provenance_verified_at = CURRENT_TIMESTAMP, provenance_verified_by = $2, provenance_updated_at = CURRENT_TIMESTAMP, provenance_updated_by = $2, updated_at = CURRENT_TIMESTAMP, updated_by = $2 WHERE id = $1`, [assetId, principal.userId], ); const versionNumber = await this.history.capture( manager, assetId, AssetVersionChangeType.PROVENANCE_VERIFIED, principal, request, ); const verified = await this.load(manager, assetId); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_PROVENANCE_VERIFIED, entityType: 'asset_provenance', entityId: assetId, beforeData: { ...before }, afterData: { ...verified }, metadata: { versionNumber }, }, manager, ); return verified; }); } private validateSource(dto: UpdateAssetProvenanceDto): void { const requiresNamedSource = dto.origin === AssetDataOrigin.PROVIDED_DOCUMENT || dto.origin === AssetDataOrigin.IMPORT; if (requiresNamedSource && !dto.sourceName?.trim()) { throw new BadRequestException({ code: 'PROVENANCE_SOURCE_REQUIRED', message: 'La documentación recibida y las importaciones requieren identificar la fuente', }); } } private async load( manager: EntityManager, assetId: string, lock = false, ): Promise { const [row] = (await manager.query( `SELECT asset.id AS "assetId", asset.data_origin AS origin, asset.source_name AS "sourceName", asset.source_reference AS "sourceReference", asset.source_observed_at AS "observedAt", asset.source_notes AS notes, asset.provenance_verified_at AS "verifiedAt", asset.provenance_verified_by AS "verifiedBy", verifier.username AS "verifiedByUsername", asset.provenance_updated_at AS "updatedAt", asset.provenance_updated_by AS "updatedBy", updater.username AS "updatedByUsername" FROM assets asset LEFT JOIN users verifier ON verifier.id = asset.provenance_verified_by LEFT JOIN users updater ON updater.id = asset.provenance_updated_by WHERE asset.id = $1 ${lock ? 'FOR UPDATE OF asset' : ''}`, [assetId], )) as AssetProvenanceView[]; if (!row) throw assetNotFound(); return row; } }