import type { Provenance } from 'fhir/r4'; import type { MyHistoryEvent } from '../history/collapseHistory'; import type { FieldTimelineEntry } from './buildFieldTimeline'; // Clock-skew tolerance when matching a version's meta.lastUpdated to the record documenting the // write. Both are written in one transaction but not to the same millisecond, on either side. It is // a bound, not a guarantee: automated writes can land within it too, so the nearest record wins and // a tie between different actors names nobody. const TOLERANCE_MS = 5000; // Create, update, delete. Named positively and matching the server's own set: "not a read" also let // a sign-in through, and a sign-in is not somebody editing the field. const CHANGE_ACTIONS: ReadonlySet = new Set(['C', 'U', 'D']); interface ActorCandidate { recordedMs: number; who: string | undefined; } // The join, shared by both sources of "who" so they cannot drift into disagreeing about one edit. function correlate( entries: FieldTimelineEntry[], candidates: ActorCandidate[], refused: boolean, ): FieldTimelineEntry[] { return entries.map((entry) => { const whenMs = entry.whenIso ? new Date(entry.whenIso).getTime() : NaN; // No timestamp: unknown whatever the read did, since there is nothing to match on. if (Number.isNaN(whenMs)) { return { ...entry, who: undefined, whoConfidence: 'unknown' }; } // Only a record of this write can name its author. One outside the window documents another // write, however recent - which is what a change whose own record we cannot see would borrow. const nearby = candidates .map((c) => ({ who: c.who, distance: Math.abs(c.recordedMs - whenMs) })) .filter((c) => c.distance <= TOLERANCE_MS); const closest = Math.min(...nearby.map((c) => c.distance)); const authors = new Set(nearby.filter((c) => c.distance === closest).map((c) => c.who)); const who = authors.size === 1 ? [...authors][0] : undefined; if (!who) { return { ...entry, who: undefined, whoConfidence: refused ? 'refused' : 'unknown' }; } return { ...entry, who, whoConfidence: 'approx' }; }); } /** A related person's edit is theirs, made on the patient's behalf; say both. */ function proxyAttribution(name: string | undefined, reference: string | undefined, forPatient: boolean) { if (!name || !forPatient || !reference?.startsWith('RelatedPerson/')) return name; return `${name}, on behalf of the patient`; } /** * Names who made each change, from the Provenance recorded nearest to it, within seconds either side. * * Provenance.target references a resource, not a version, so this is a timestamp join and * confidence is never better than 'approx'. Pure. * * `refused` means the read was denied rather than empty. It only changes what an unmatched entry * is called: "we were not allowed to look" is not "nobody recorded it". */ export function correlateProvenance( entries: FieldTimelineEntry[], provenances: Provenance[], refused = false ): FieldTimelineEntry[] { const candidates = provenances .map((provenance) => { const agent = provenance.agent?.[0]; const name = agent?.who?.display ?? agent?.who?.reference; const forPatient = agent?.onBehalfOf?.reference?.startsWith('Patient/') ?? false; return { recordedMs: new Date(provenance.recorded).getTime(), who: proxyAttribution(name, agent?.who?.reference, forPatient), }; }) .filter((c) => !Number.isNaN(c.recordedMs)); return correlate(entries, candidates, refused); } /** * The same question, asked where a patient is allowed to ask it. * * GET /R4/Provenance excludes patients by design - they have my-history instead - so a patient * reading their own profile got a 403 and the overlay said it could not name anyone. This reads * the actor from their own activity feed, which carries the same fact. * * Changes only, never reads: an access row says somebody looked, and naming them as the editor * would be wrong. Matched on the resource as well as the moment, which the Provenance join cannot * do - a desk edit touches several resources in the same second. * * Still 'refused' for what this cannot reach: an entry older than the feed has an author we may * not read. */ export function correlateMyHistory( entries: FieldTimelineEntry[], events: MyHistoryEvent[], resourceType: string, resourceId: string, ): FieldTimelineEntry[] { const candidates = events .filter((event) => CHANGE_ACTIONS.has(event.action ?? '') && event.resourceType === resourceType && event.resourceId === resourceId && Boolean(event.actorName)) .map((event) => ({ recordedMs: new Date(event.recorded ?? '').getTime(), who: proxyAttribution(event.actorName, event.actorReference, true), })) .filter((c) => !Number.isNaN(c.recordedMs)); return correlate(entries, candidates, true); }