import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, summarizeChanges, } from './contract-document-diff.util'; import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; import type { ContractDocumentSnapshot } from './entities/contract.entity'; export interface RecordRevisionInput { contractId: string; before: ContractDocumentSnapshot | null; after: ContractDocumentSnapshot | null; actorId?: string | null; actorRole?: string | null; actorName?: string | null; stepId?: string | null; } /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; changes: ContractDocumentChange[]; actorId?: string | null; actorRole?: string | null; actorName?: string | null; stepId?: string | null; } @Injectable() export class ContractDocumentHistoryService { private readonly logger = new Logger(ContractDocumentHistoryService.name); constructor( @InjectRepository(ContractDocumentRevision) private readonly revisionRepo: Repository, @InjectDataSource() private readonly dataSource: DataSource, ) {} /** * Append a revision describing what an edit changed. Best-effort: recording * history must never break the edit that triggered it, so failures are logged * and swallowed. A no-op edit records nothing. */ async record(input: RecordRevisionInput): Promise { return this.recordChanges({ ...input, changes: diffSnapshots(input.before, input.after), }); } /** * Append a revision from an already-computed change set — the contract-field * path, where there is no document snapshot to diff. Same best-effort * contract as {@link record}: a no-op change set records nothing, and a * failure here never breaks the edit that triggered it. */ async recordChanges(input: RecordChangesInput): Promise { try { if (input.changes.length === 0) return; await this.revisionRepo.save( this.revisionRepo.create({ contractId: input.contractId, actorId: input.actorId ?? null, actorRole: input.actorRole ?? null, actorName: input.actorName ?? (await this.resolveActorName(input.actorId)), stepId: input.stepId ?? null, summary: summarizeChanges(input.changes), changes: input.changes, }), ); } catch (err) { this.logger.error( `Failed to record document revision for contract ${input.contractId}: ${String(err)}`, ); } } /** * Name for the acting user. `iam.users` is owned by the auth system and has * no entity here, so it is read directly; a miss is not an error — the trail * still carries the id, role and timestamp. */ private async resolveActorName( actorId?: string | null, ): Promise { if (!actorId) return null; const names = await this.resolveActorNames([actorId]); return names.get(actorId) ?? null; } /** Batched {@link resolveActorName} — one query for a whole revision list. */ private async resolveActorNames( actorIds: string[], ): Promise> { try { return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); return new Map(); } } /** Revision history for a contract, newest first. */ async list(contractId: string): Promise { const revisions = await this.revisionRepo.find({ where: { contractId }, order: { createdAt: 'DESC' }, }); // Rows written before actor_name existed still carry an actor_id — resolve // those for display (one query for the whole list) rather than backfilling. const missing = revisions .filter((r) => !r.actorName && r.actorId) .map((r) => r.actorId as string); if (missing.length === 0) return revisions; const names = await this.resolveActorNames(missing); for (const revision of revisions) { if (!revision.actorName && revision.actorId) { revision.actorName = names.get(revision.actorId) ?? null; } } return revisions; } }