import type { ContractDocumentArticle, ContractDocumentSnapshot, } from './entities/contract.entity'; /** * One recorded change between two document snapshots. A body edit carries the * text on both sides so the audit trail shows WHAT was rewritten, not merely * that something was — the UI diffs the two strings for display. */ export type ContractDocumentChange = | { kind: 'ARTICLE_ADDED'; articleId: string; title: string } | { kind: 'ARTICLE_REMOVED'; articleId: string; title: string } | { kind: 'ARTICLE_RENAMED'; articleId: string; title: string; fromTitle: string; } | { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string; /** Body before / after the edit. Absent on revisions recorded earlier. */ fromBody?: string; toBody?: string; } | { kind: 'ARTICLE_REORDERED'; articleId: string; title: string; fromOrder: number; toOrder: number; } | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } | { kind: 'WHEREAS_CHANGED'; added: number; removed: number } /** * A contract field (not a document article) changed — the customer editing a * DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type. */ | { kind: 'FIELD_CHANGED'; field: string; label: string; from: string | null; to: string | null; }; type SnapshotLike = Pick< ContractDocumentSnapshot, 'documentTitle' | 'whereasClauses' | 'articles' > | null; /** Match on id when present, else on normalized title (editors may omit ids). */ function articleKey(article: ContractDocumentArticle): string { return article.id || `title:${article.title.trim().toLowerCase()}`; } function indexArticles( articles: ContractDocumentArticle[] | undefined, ): Map { const map = new Map(); for (const article of articles ?? []) { map.set(articleKey(article), article); } return map; } /** * Compare two document snapshots and describe what changed, article by article. * Returns an empty array when the snapshots are equivalent, so callers can skip * recording a no-op revision. */ export function diffSnapshots( before: SnapshotLike, after: SnapshotLike, ): ContractDocumentChange[] { const changes: ContractDocumentChange[] = []; const beforeTitle = before?.documentTitle ?? null; const afterTitle = after?.documentTitle ?? null; if (beforeTitle !== afterTitle && afterTitle !== null) { changes.push({ kind: 'DOCUMENT_TITLE_CHANGED', title: afterTitle, fromTitle: beforeTitle, }); } const beforeWhereas = before?.whereasClauses ?? []; const afterWhereas = after?.whereasClauses ?? []; const beforeWhereasSet = new Set(beforeWhereas); const afterWhereasSet = new Set(afterWhereas); const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length; const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length; if (whereasAdded > 0 || whereasRemoved > 0) { changes.push({ kind: 'WHEREAS_CHANGED', added: whereasAdded, removed: whereasRemoved, }); } const beforeArticles = indexArticles(before?.articles); const afterArticles = indexArticles(after?.articles); for (const [key, article] of afterArticles) { const previous = beforeArticles.get(key); if (!previous) { changes.push({ kind: 'ARTICLE_ADDED', articleId: article.id, title: article.title, }); continue; } if (previous.title !== article.title) { changes.push({ kind: 'ARTICLE_RENAMED', articleId: article.id, title: article.title, fromTitle: previous.title, }); } if (previous.body !== article.body) { changes.push({ kind: 'ARTICLE_BODY_CHANGED', articleId: article.id, title: article.title, fromBody: previous.body, toBody: article.body, }); } if (previous.order !== article.order) { changes.push({ kind: 'ARTICLE_REORDERED', articleId: article.id, title: article.title, fromOrder: previous.order, toOrder: article.order, }); } } for (const [key, article] of beforeArticles) { if (afterArticles.has(key)) continue; changes.push({ kind: 'ARTICLE_REMOVED', articleId: article.id, title: article.title, }); } return changes; } /** Human label per audited contract field, in the order they read on the form. */ export const CONTRACT_FIELD_LABELS: Record = { contractKind: 'Contract kind', tradeDirection: 'Trade direction', freightType: 'Freight type', serviceType: 'Service type', paymentCurrency: 'Payment currency', contractType: 'Contract type', isHazardous: 'Hazardous', hazardClass: 'Hazard class', unNumber: 'UN number', isReefer: 'Reefer', equipmentReturn: 'Equipment return', customsClearingAgent: 'Customs clearing agent', firstMilePickupAddress: 'First-mile pickup address', lastMileDeliveryAddress: 'Last-mile delivery address', routes: 'Routes', cargoScope: 'Cargo scope', }; /** Render a field value for the audit trail — never "[object Object]". */ function displayValue(value: unknown): string | null { if (value === null || value === undefined || value === '') return null; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; return String(value); } /** * Compare two flat maps of contract fields and report what changed. Only keys * present in `after` are considered, so a partial update never reports the * fields it did not touch. */ export function diffContractFields( before: Record, after: Record, ): ContractDocumentChange[] { const changes: ContractDocumentChange[] = []; for (const [field, nextRaw] of Object.entries(after)) { const next = displayValue(nextRaw); const previous = displayValue(before[field]); if (next === previous) continue; changes.push({ kind: 'FIELD_CHANGED', field, label: CONTRACT_FIELD_LABELS[field] ?? field, from: previous, to: next, }); } return changes; } /** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ export function summarizeChanges(changes: ContractDocumentChange[]): string { if (changes.length === 0) return 'No changes'; const articleVerbs: Record = { ARTICLE_ADDED: 'added', ARTICLE_REMOVED: 'removed', ARTICLE_RENAMED: 'renamed', ARTICLE_BODY_CHANGED: 'edited', ARTICLE_REORDERED: 'reordered', }; const counts = new Map(); const parts: string[] = []; const fields: string[] = []; for (const change of changes) { const verb = articleVerbs[change.kind]; if (verb) { counts.set(verb, (counts.get(verb) ?? 0) + 1); } else if (change.kind === 'DOCUMENT_TITLE_CHANGED') { parts.push('document title changed'); } else if (change.kind === 'WHEREAS_CHANGED') { parts.push('recitals changed'); } else if (change.kind === 'FIELD_CHANGED') { fields.push(change.label.toLowerCase()); } } if (fields.length > 0) { parts.push( fields.length <= 3 ? `${fields.join(', ')} changed` : `${fields.length} contract fields changed`, ); } const articleParts = [...counts.entries()].map( ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, ); return [...articleParts, ...parts].join(', '); }