// - what actually changed between two versions of a resource. // // The audit trail records THAT a resource was written, never which fields. The per-field history // feature already fetches `_history`, but it is PATH-DRIVEN: it works because each knows the // path it cares about. My History has to DISCOVER the change, so this supplies the missing half. // // Deliberately RESOURCE-AGNOSTIC. Turning `contact[0].telecom[1].value` into "Spouse One's phone // number" needs per-resource knowledge, and that lives in its own module - keeping them apart is what // stops this from accumulating FHIR domain rules it has no business holding, and is why it can be // tested exhaustively as a pure function. export interface FieldChange { /** Dotted/indexed path, e.g. `contact[0].telecom[1].value`. */ path: string; kind: 'added' | 'removed' | 'changed'; before?: unknown; after?: unknown; } // Plumbing every write touches. `meta.versionId` and `meta.lastUpdated` change on EVERY save and the // server rewrites `meta.tag`, so reporting them would bury the one real change under noise on every // row. `text` is the generated narrative - a rendering of the data, not data the patient edited. const IGNORED_KEYS = new Set(['meta', 'id', 'resourceType', 'text', 'implicitRules', 'language']); // A patient must never be shown a wall of rows because something upstream reordered an array. The // caller summarises the overflow honestly ("and N more") rather than this silently truncating. const MAX_CHANGES = 50; const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const join = (base: string, key: string): string => (base ? `${base}.${key}` : key); function walk(before: unknown, after: unknown, path: string, out: FieldChange[]): void { if (out.length >= MAX_CHANGES) return; // Both sides absent - nothing to say. if (before === undefined && after === undefined) return; // A WHOLLY NEW (or wholly removed) subtree is ONE event, not one per leaf. Adding an email address // writes both `telecom[1].system` and `telecom[1].value`; recursing would report "added contact // details" and "added an email address" as two separate changes for a single act. People add an // email address, not two fields - and a change list that double-counts undermines the trust this // feed runs on. if (before === undefined && (isObject(after) || Array.isArray(after))) { out.push({ path, kind: 'added', before: undefined, after }); return; } if (after === undefined && (isObject(before) || Array.isArray(before))) { out.push({ path, kind: 'removed', before, after: undefined }); return; } if (Array.isArray(before) || Array.isArray(after)) { const beforeArr = Array.isArray(before) ? before : []; const afterArr = Array.isArray(after) ? after : []; const length = Math.max(beforeArr.length, afterArr.length); // Compared BY INDEX, deliberately. FHIR arrays carry no stable identity to match on, and // guessing at one (by system, by code, by a heuristic) would report a reorder as a pair of // unrelated edits - confidently wrong. Index comparison is occasionally noisy but never lies // about what it is doing. for (let i = 0; i < length; i += 1) { walk(beforeArr[i], afterArr[i], `${path}[${i}]`, out); } return; } if (isObject(before) || isObject(after)) { const beforeObj = isObject(before) ? before : {}; const afterObj = isObject(after) ? after : {}; const keys = new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]); for (const key of keys) { // Only skip plumbing at the ROOT - a nested field genuinely called `language` or `text` // (Patient.communication[].language, contact.name.text) is real data the patient may have // edited, and dropping it would silently under-report a change. if (!path && IGNORED_KEYS.has(key)) continue; walk(beforeObj[key], afterObj[key], join(path, key), out); } return; } if (before === after) return; if (before === undefined) { out.push({ path, kind: 'added', before: undefined, after }); return; } if (after === undefined) { out.push({ path, kind: 'removed', before, after: undefined }); return; } out.push({ path, kind: 'changed', before, after }); } /** * Fields that differ between two versions of the same resource, oldest state first. * * Never throws: a missing version yields an empty list. This feeds a patient-facing expansion, and a * blank detail panel is recoverable where a crashed page is not. */ export function diffResourceVersions(previous: unknown, current: unknown): FieldChange[] { if (!isObject(previous) || !isObject(current)) return []; const changes: FieldChange[] = []; walk(previous, current, '', changes); return changes; }