import { useCallback } from 'react'; import type { Bundle, Provenance, Resource } from 'fhir/r4'; import { environment } from '../../environments/environment'; import { useHttpService, type RequestAdmission } from '../../services'; import type { MyHistoryEvent } from '../history/collapseHistory'; import { parseMyHistoryResponse } from '../history/parseMyHistoryResponse'; import { provenanceApiBase } from '../provenanceApiBase'; import { isResourceDeleted } from '../isResourceDeleted'; import { isPermanentDenial } from '../fetchFailureMessage'; import type { DemographicHistoryRow } from './demographicTimeline'; /** * How much of a record's attribution was read: all of it, part of it, refused, failed, or an answer that did not say. * Only `complete` lets an author be chosen from it. */ export type AttributionCoverage = 'complete' | 'partial' | 'refused' | 'unreadable' | 'unknown'; /** A filtered activity read for one record, and whether it reached the oldest change. */ export interface ActorFallbackRead { events: MyHistoryEvent[]; complete: boolean; } /** The read a request belongs to: admitted before each send and stage, and cancelling what it sent once retired. */ export interface ReadOwner { admit: RequestAdmission; signal: AbortSignal; } export interface ResourceHistoryFetchResult { versions: Resource[]; provenances: Provenance[]; /** How much of the attribution was read; absent on reads that never asked for it. */ attribution?: AttributionCoverage; /** * The Provenance read was REFUSED, rather than coming back empty. * * AN EMPTY LIST AND A REFUSED ONE ARE DIFFERENT FACTS AND USED TO BE THE SAME ONE. Both left * every row reading "changed by unknown", which tells a patient the system does not know who * changed their date of birth when the truth is that they were not allowed to ask. The values * and timestamps are right in both cases; only the actor is missing, and only one of the two * reasons is about them. */ provenanceRefused: boolean; /** The Provenance read failed, whether refused or broken. */ provenanceUnreadable?: boolean; /** * Set when the raw history was refused and only this practice's verified demographic edits could * be read. An empty list then means no change could be verified, not that none happened. */ demographicRows?: DemographicHistoryRow[]; } /** * Resolves the FHIR gateway base URL for a resource's `_history`/`Provenance` reads. * * Single source of truth for the resourceType-to-gateway table; FhirHistory.tsx's * fetchHistory imports this rather than keeping its own copy. */ export function resolveHistoryBaseUrl(resourceType: string): string { switch (resourceType) { case 'DocumentReference': case 'Consent': return environment.fhirFoundationApi; // The four people resources moved to the identity gateway; the administrative // ones below stayed on base. They shared one arm until the split. case 'Patient': case 'Practitioner': case 'PractitionerRole': case 'RelatedPerson': return environment.fhirIdentityApi; case 'Appointment': case 'Organization': case 'Location': case 'HealthcareService': case 'Schedule': case 'Task': case 'Encounter': case 'EpisodeOfCare': case 'Device': return environment.fhirBaseApi; default: return environment.fhirClinicalApi; } } /** What the server said of a field-history Provenance read; a missing or unknown marker proves nothing. */ const coverageOf = (bundle: unknown): AttributionCoverage => { const read = bundle as { resourceType?: unknown; type?: unknown; entry?: unknown; readCompleteness?: { version?: unknown; status?: unknown } } | null; if (read?.resourceType !== 'Bundle' || read.type !== 'searchset' || read.readCompleteness?.version !== 1) return 'unknown'; if (!Array.isArray(read.entry)) return 'unknown'; const status = read.readCompleteness.status; return status === 'complete' || status === 'partial' ? status : 'unknown'; }; const isTime = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(new Date(value).getTime()); const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const optionalString = (value: unknown) => value === undefined || typeof value === 'string'; /** An absent reference, or one whose present `reference` and `display` are text. */ const isReadableReference = (value: unknown) => value === undefined || (isObject(value) && optionalString(value.reference) && optionalString(value.display)); // The members correlation and creation read: each agent's actor and on-whose-behalf, and the activity's codes. const isReadableAgent = (agent: unknown) => isObject(agent) && isReadableReference(agent.who) && isReadableReference(agent.onBehalfOf); const isReadableActivity = (activity: unknown) => activity === undefined || (isObject(activity) && optionalString(activity.text) && (activity.coding === undefined || (Array.isArray(activity.coding) && activity.coding.every((coding) => isObject(coding) && optionalString(coding.code))))); /** A Provenance row fit for the candidate set: it says when, its agents and activity read, and it names this record or an edition. */ const isAttributionRow = (resource: unknown, record: string): resource is Provenance => { const row = resource as Provenance | null | undefined; if (row?.resourceType !== 'Provenance' || !isTime(row.recorded) || !Array.isArray(row.target)) return false; if (!Array.isArray(row.agent) || !row.agent.every(isReadableAgent) || !isReadableActivity(row.activity)) return false; if (!row.target.every(isReadableReference)) return false; return row.target.some((each) => { const reference = each?.reference; if (reference === record) return true; return typeof reference === 'string' && reference.startsWith(`${record}/_history/`) && /^[A-Za-z0-9.-]{1,64}$/.test(reference.slice(record.length + '/_history/'.length)); }); }; /** One filtered activity row for exactly this record, with what attribution reads from it. */ const isActivityRow = (event: unknown, resourceType: string, id: string): event is MyHistoryEvent & { id: string } => { const row = event as Partial | null | undefined; return typeof row === 'object' && row !== null && typeof row.id === 'string' && row.id !== '' && isTime(row.recorded) && row.resourceType === resourceType && row.resourceId === id && typeof row.action === 'string' && typeof row.actorName === 'string' && optionalString(row.actorReference); }; // The same allowance the server gives its own attribution read: twenty pages, a thousand rows, eight seconds. const FALLBACK_PAGES = 20; const FALLBACK_EVENTS = 1000; const FALLBACK_MS = 8000; /** * Client fetch of the raw material the field-history diff engine (`buildFieldTimeline` + * `correlateProvenance`) needs for one resource: its `_history` version bodies and the * `Provenance` resources that document who changed it. * * `versions` is returned in the SAME order HealthLake's `_history` bundle returns them * (newest-first) -- `buildFieldTimeline` depends on that ordering and does no sorting of its own. * * - THE PROVENANCE QUERY DOES NOT FOLLOW ITS TARGET'S GATEWAY, AND ROUTING IT THAT WAY IS * WHY "who changed this" HAS ONLY EVER RESOLVED FOR `DocumentReference`. `GET /R4/Provenance` is * declared once, in `01-fhir-foundation.yaml`, so it exists on the FOUNDATION gateway alone. Every * other resource type sent the query to base or clinical, which do not declare the path - and a * gateway that does not declare a path answers the preflight with no CORS header, so it fails as * `net::ERR_FAILED` with no status. The catch below then degrades to `provenances: []`, which is * indistinguishable from a resource nobody has ever edited.. * * The query stays deliberately scoped to THIS resource via `?target={resourceType}/{id}` -- * `correlateProvenance` (Task 3) assumes every Provenance it's given belongs to the one resource being * correlated; an unscoped query would let another resource's Provenance timestamps win a false match. * * A Provenance-fetch failure is isolated and degrades to `provenances: []` (so "who" falls back to * "unknown" via `correlateProvenance` rather than failing the whole field-history view). A * `_history` failure propagates to the caller EXCEPT when the resource was hard-deleted (base read * returns 410) -- HealthLake 500s the `_history` LIST for a deleted resource, and the * meaningful degraded state there is "no field history for a deleted record", so that one case * returns empty rather than failing the whole view. */ export function useFieldHistoryFetch() { const { get } = useHttpService(); const fetchResourceHistory = useCallback( async (resourceType: string, id: string, read?: ReadOwner): Promise => { const base = resolveHistoryBaseUrl(resourceType); // Every stage is sent as this read, so a retired one starts nothing further. const send = (url: string) => get(url, read ? { admit: read.admit, signal: read.signal } : {}); let historyBundle: Bundle; try { historyBundle = await send>( // Server merges all `_history` pages now; the old `count` param was proxy-dropped. `${base}/${resourceType}/${id}/_history` ); } catch (err) { if (read?.signal.aborted) throw err; // An affiliation-only desk is refused a patient's raw history (404) and may read the // narrow route instead. If that fails too, the original failure stands. if (resourceType === 'Patient' && (err as { status?: number } | null)?.status === 404) { try { const narrow = await send<{ rows?: DemographicHistoryRow[] }>( `${base}/Patient/${id}/demographic-history` ); return { versions: [], provenances: [], provenanceRefused: false, attribution: 'unknown', demographicRows: narrow.rows ?? [] }; } catch (narrowErr) { console.error(`Failed to load verified demographic history for Patient/${id}:`, narrowErr); } } // Hard-deleted resource: HealthLake 500s the `_history` LIST. That has a // meaningful degraded state -- no field history for a record that's gone -- so return // empty instead of failing the whole view. Any OTHER failure still propagates. if (await isResourceDeleted(send, base, resourceType, id)) { // A hard-deleted record was never refused - nothing was asked for. Saying // otherwise would blame permission for a record that is simply gone. return { versions: [], provenances: [], provenanceRefused: false, attribution: 'unknown' }; } throw err; } const versions: Resource[] = (historyBundle.entry ?? []) .map((entry) => entry.resource) .filter((resource): resource is Resource => !!resource); let provenances: Provenance[] = []; let provenanceRefused = false; let provenanceUnreadable = false; let attribution: AttributionCoverage; try { // NOT `base` - see the header. Provenance lives on the foundation gateway whatever // gateway its target lives on. The server reads every page and says whether it read them all. const provenanceBundle = await send<{ entry?: unknown } | null>( `${provenanceApiBase()}/Provenance?target=${resourceType}/${id}&fieldHistory=true` ); attribution = coverageOf(provenanceBundle); const rows = (Array.isArray(provenanceBundle?.entry) ? provenanceBundle.entry : []) .map((entry) => (entry as { resource?: unknown } | null)?.resource); provenances = rows.filter((resource) => isAttributionRow(resource, `${resourceType}/${id}`)); // A row that cannot be read leaves the candidate set short, whatever the marker said. if (provenances.length < rows.length && attribution === 'complete') attribution = 'partial'; } catch (err) { if (read?.signal.aborted) throw err; // Isolated: the actor degrades rather than failing the whole field-history fetch // over a Provenance-only problem. That isolation is right and is unchanged. // // WHAT IS RECORDED NOW IS *WHY*. A 401/403 means the caller was refused, which is a // statement about permission; anything else is a fault, which is a statement about // the system. Collapsing both into an empty list made a refusal indistinguishable // from a change nobody recorded, and the product's standing rule is that a refusal // renders as a refusal and never as empty data. console.error(`Failed to load Provenance for ${resourceType}/${id}:`, err); provenances = []; provenanceUnreadable = true; provenanceRefused = isPermanentDenial( (err as { status?: number } | null | undefined)?.status ?? 0, ); attribution = provenanceRefused ? 'refused' : 'unreadable'; } return { versions, provenances, provenanceRefused, provenanceUnreadable, attribution }; }, [get] ); // Who changed a value, for callers refused the Provenance read. Patients are excluded from // GET /R4/Provenance by design and have my-history instead, so their field history reads the // actor from there, filtered to the resource being explained. // An exhausted answer costs one request; an older page is asked for only with the cursor the server gave, on the // same filter. A failed, malformed, capped or stalled read is incomplete, never a whole one. const readActorFallback = useCallback( async (resourceType: string, id: string, read?: ReadOwner): Promise => { // Both narrowings, because either alone leaves the wrong question asked: the resource filter still // returns every access to it, and staff reads far outnumber edits on a record they work in. const filter = `?resource=${encodeURIComponent(`${resourceType}/${id}`)}&changes=true`; const endsAt = Date.now() + FALLBACK_MS; // The allowance bounds each answer too: one held past it, or the read retiring, ends the read and asks nothing more. const stop = new AbortController(); const end = () => stop.abort(); const timer = setTimeout(end, FALLBACK_MS); read?.signal.addEventListener('abort', end); if (read?.signal.aborted) end(); const ended = new Promise((resolve) => stop.signal.addEventListener('abort', () => resolve(null))); const within = () => !stop.signal.aborted && Date.now() < endsAt; const events: MyHistoryEvent[] = []; const ids = new Set(); const cursors = new Set(); let before: string | undefined; try { for (let page = 0; page < FALLBACK_PAGES && within(); page += 1) { let response: unknown; try { response = await Promise.race([ get( `${environment.fhirGuardianshipApi}/my-history${filter}${before ? `&before=${encodeURIComponent(before)}` : ''}`, { admit: read?.admit, signal: stop.signal }, ), ended, ]); } catch (err) { if (!stop.signal.aborted) console.error('Failed to load my-history for field-history attribution:', err); return { events, complete: false }; } if (!within()) return { events, complete: false }; // An unreadable answer, or one with a row for another record, names nobody: earlier pages stand, never as whole. let answer: ReturnType; try { answer = parseMyHistoryResponse(response); } catch (err) { console.error('Unreadable my-history answer for field-history attribution:', err); return { events, complete: false }; } const rows = answer.events; if (!rows.every((event) => isActivityRow(event, resourceType, id))) return { events, complete: false }; for (const event of rows as (MyHistoryEvent & { id: string })[]) { if (ids.has(event.id)) continue; ids.add(event.id); events.push(event); if (events.length >= FALLBACK_EVENTS) return { events, complete: false }; } // An answer that does not say whether older rows remain is kept, but never read as whole. if (answer.mayHaveOlder === undefined) return { events, complete: false }; if (!answer.mayHaveOlder) return { events, complete: within() }; const cursor = answer.olderThan; if (!cursor || cursors.has(cursor) || (before !== undefined && cursor >= before)) { return { events, complete: false }; } cursors.add(cursor); before = cursor; } return { events, complete: false }; } finally { clearTimeout(timer); read?.signal.removeEventListener('abort', end); } }, [get], ); // The events alone, for a caller that needs no coverage. const fetchActorFallback = useCallback( async (resourceType: string, id: string): Promise => (await readActorFallback(resourceType, id)).events, [readActorFallback], ); return { fetchResourceHistory, fetchActorFallback, readActorFallback }; }