196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
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<AssetProvenanceView> {
|
|
return this.load(this.dataSource.manager, assetId);
|
|
}
|
|
|
|
async update(
|
|
assetId: string,
|
|
dto: UpdateAssetProvenanceDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<AssetProvenanceView> {
|
|
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<AssetProvenanceView> {
|
|
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<AssetProvenanceView> {
|
|
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;
|
|
}
|
|
}
|