mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
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, string> | 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<ContractDocumentRevision>,
|
|
@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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<Map<string, string>> {
|
|
const resolved = new Map<string, string>();
|
|
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<IamUserRow & { id: string }>;
|
|
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<ContractDocumentRevision[]> {
|
|
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;
|
|
}
|
|
}
|