Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts
2026-07-20 12:24:58 +00:00

169 lines
4.9 KiB
TypeScript

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<string, ContractDocumentArticle> {
const map = new Map<string, ContractDocumentArticle>();
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<string, string> = {
ARTICLE_ADDED: 'added',
ARTICLE_REMOVED: 'removed',
ARTICLE_RENAMED: 'renamed',
ARTICLE_BODY_CHANGED: 'edited',
ARTICLE_REORDERED: 'reordered',
};
const counts = new Map<string, number>();
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(', ');
}