import type { ContractDocumentArticle, ContractDocumentSnapshot, } from './entities/contract.entity'; /** * One recorded change between two document snapshots. Granularity is per * article: a body edit is reported as "the body changed", not as a text diff. */ 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 } | { 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 }; 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, }); } 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; } /** 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[] = []; 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'); } } const articleParts = [...counts.entries()].map( ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, ); return [...articleParts, ...parts].join(', '); }