import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; 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; } /** * `iam.users.name` is a localized object ({ en, am, … }), not a string — a * plain `String(name)` there yields "[object Object]" in the audit trail. */ interface IamUserRow { name?: Record | string | null; username?: string | null; email?: string | null; } /** Best display name for a user row: English label → any locale → login → email. */ function pickUserName(user: IamUserRow): string | null { const { name } = user; if (typeof name === 'string' && name.trim()) return name.trim(); if (name && typeof name === 'object') { const localized = name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); if (localized?.trim()) return localized.trim(); } return user.username?.trim() || user.email?.trim() || 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> { const resolved = new Map(); const ids = [...new Set(actorIds.filter(Boolean))]; if (ids.length === 0) return resolved; try { const rows = (await this.dataSource.query( `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, [ids], )) as Array; for (const row of rows) { const name = pickUserName(row); if (name) resolved.set(row.id, name); } } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); } return resolved; } /** 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; } }