import React, { useEffect, useState } from 'react'; import type { Bundle, Provenance } from 'fhir/r4'; import { useHttpService } from '../../services'; import { provenanceApiBase } from '../../utils/provenanceApiBase'; import { readPatientCreationOrigin, describePatientOrigin } from '../../utils/patientCreationOrigin'; import { findSelfRecordOrigin, describeSelfRecordOrigin, findLatestChangeByOthersPaged, describeLatestChangeByOthers, } from '../../utils/recordOrigin/selfRecordOrigin'; import { parseMyHistoryResponse } from '../../utils/history/parseMyHistoryResponse'; import { environment } from '../../environments/environment'; import { fetchAllPagesAsBundle } from '../../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../../utils/pageSizeOverride'; // done-when ④ - the patient record shows how it came to exist, in plain words. // // *"Added by Jane Smith at the front desk - 14 August"* - the design's own example. // // WHY IT IS ON THE CHART AND NOT IN AN AUDIT SCREEN: *"where did this record come from?"* is asked // while looking at the record, usually because something about it is surprising - a duplicate, a name // nobody recognises, a patient who says they never registered. An answer that lives somewhere else is an // answer nobody reaches. // // THE FOUNDATION GATEWAY, and getting this wrong is what was raised for. The instinct in // the original comment was right - Provenance is FHIR CRUD, not `apiUrl` - and the answer was one // gateway off: `GET /R4/Provenance` is declared only in `01-fhir-foundation.yaml`. It is not "a // silent 404" either; a gateway that does not declare the path answers the CORS preflight with no // `Access-Control-Allow-Origin`, so it fails as `net::ERR_FAILED` with NO status code at all. //, which is where that reasoning now lives so it cannot drift per caller. interface Props { patientId: string; /** * WHO IS READING THIS RECORD. * * `staff` is a receptionist on somebody else's chart and keeps the sentence this component shipped * with. `self` is the patient in their own portal, or a related person acting for them - a different * sentence, and on a staff-created record it also asks them to check what was typed for them. * Defaulted, so the chart is untouched. */ audience?: 'staff' | 'self'; /** * EVERY REFERENCE THAT IS THE READER, so the self line can tell "somebody else typed this" from "you * did" - their own record, and the related person they are acting through when acting for somebody. * * PASSED IN RATHER THAN RESOLVED HERE, and that is worth a line. Reading the auth context inside this * component gave it a second dependency that its own gateway test does not mock, so the file that pins * WHICH gateway Provenance is read from started failing on a change that has nothing to do with * gateways. The identity of the reader belongs to the screen that already knows it. */ viewerReferences?: readonly (string | undefined)[]; } const PatientRecordOrigin: React.FC = ({ patientId, audience = 'staff', viewerReferences = [], }) => { const { get } = useHttpService(); const [text, setText] = useState(null); // Self audience only: the latest change somebody else made to the reader's own record. const [changeText, setChangeText] = useState(null); const viewerKey = viewerReferences.join('|'); /* * THE PATIENT READS A DIFFERENT SOURCE, BECAUSE THE ONE THE CHART USES REFUSES THEM. * * `GET /R4/Provenance` excludes patients by design - they have their own activity feed, and two * org-trimmed views of one record would disagree about the same events. Measured in the portal * 2026-09-14: the read comes back 403, this component fails silent on a refusal by design, and the * sentence a patient was supposed to see was never shown to one. * * So the self audience asks `my-history`, which carries the actor, the organization and the moment, * and which a patient may read. One page of the default window: a patient the desk has just * registered has a short feed, which is the case this exists for, and an older record simply is not * in it - where saying nothing is already the right answer. */ useEffect(() => { if (audience !== 'self') return; let cancelled = false; // Both self reads go through the shared parser: an unreadable answer throws into the silent catch. get(`${environment.fhirGuardianshipApi}/my-history`) .then((body) => { if (cancelled) return; setText(describeSelfRecordOrigin( findSelfRecordOrigin(parseMyHistoryResponse(body).events, patientId), viewerReferences, ) ?? null); }) // Silent, for the same reason the chart's read is: a failed read must not put a claim // about the record on the screen. .catch(() => { if (!cancelled) setText(null); }); // THE CHANGES ARE ASKED FOR ON THEIR OWN. The default page is mostly reads and sign-ins, so a // correction made weeks ago is not on it; this read is this record's changes only, newest first, // with no window floor. // Followed back page by page: the reader's own edits can fill the first page. findLatestChangeByOthersPaged((before) => { const changes = new URLSearchParams({ resource: `Patient/${patientId}`, changes: 'true' }); if (before) changes.set('before', before); return get(`${environment.fhirGuardianshipApi}/my-history?${changes.toString()}`).then(parseMyHistoryResponse); }, patientId, viewerReferences, undefined, () => cancelled) .then((change) => { if (cancelled) return; setChangeText(describeLatestChangeByOthers(change) ?? null); }) .catch(() => { if (!cancelled) setChangeText(null); }); return () => { cancelled = true; }; // `viewerKey` rather than the array: a new array every render would re-run this on every // render, and the only thing that matters is whether the references themselves changed. // eslint-disable-next-line react-hooks/exhaustive-deps }, [get, patientId, audience, viewerKey]); useEffect(() => { if (audience === 'self') return; let cancelled = false; // - THIS ONE WAS NOT MERELY SHORT, IT WAS LOOKING IN THE WRONG PLACE. // A patient accrues one Provenance per write, so an established chart has hundreds. This asked // for 50 and then searched them for the record's CREATION - which is the OLDEST one. Nothing // orders the result (`_sort` is not sent, so the order is the datastore's business), meaning the // creation entry was in the answer only by luck, and its absence renders as a confident // "created before we started recording" rather than as a failure see the catch below, which // was written to avoid exactly that claim. fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.set('target', `Patient/${patientId}`); params.set('_count', searchPageSize('50')); if (pageParam) params.set('page', pageParam); return get>(`${provenanceApiBase()}/Provenance?${params.toString()}`); }) .then((bundle) => { if (cancelled) return; const provenances = (bundle?.entry ?? []) .map((entry) => entry.resource as Provenance) .filter((resource) => resource?.resourceType === 'Provenance'); setText(describePatientOrigin(readPatientCreationOrigin(provenances))); }) // ⛔ SILENT ON FAILURE. This is provenance, not a gate: a failed read must not put "created // before we started recording" on a chart, because that is a CLAIM about the record rather // than about our request — and it is indistinguishable from the true version of itself. .catch(() => { if (!cancelled) setText(null); }); return () => { cancelled = true; }; }, [get, patientId, audience]); if (!text && !changeText) return null; return (

{text} {text && changeText && ' '} {changeText && {changeText}}

); }; export default PatientRecordOrigin; export { PatientRecordOrigin };