mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
169 lines
4.9 KiB
TypeScript
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(', ');
|
|
}
|