import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import type { SaveTransport } from '../utils/saveOperation'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { toFhirInstant } from '../utils/fhirDateTimeDisplay'; import { referenceFromFormItem } from '../utils/referenceFromFormItem'; import { Bundle, DiagnosticReport, Encounter, Patient, Reference } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { filledPeriod } from '../utils/filledPeriod'; import { usePatientServiceContext } from './PatientService.tsx'; // - WAS `environment.healthLakeEndpoint`, A KEY THAT DOES NOT EXIST ON `environment`. // It evaluated to `undefined`, so every URL this service built began with the literal string // "undefined", became a RELATIVE url, resolved against the WordPress origin // (`.../staff-portal/undefinedDiagnosticReport?...`) and 404'd. Diagnostic Reports never loaded for any // patient, authorized or not - the leaf has never once worked. // DO NOT "fix" this by adding a `healthLakeEndpoint` key back to the environment. That name meant // "talk to HealthLake directly from the browser", which bypasses the authorizer - the entire // authorization boundary - and is the actual bug. The endpoints now exist behind the gateway // ( added `/R4/DiagnosticReport` to the fhir-clinical stack); point at those. // `fhirClinicalApi` already ends in `/R4` and carries NO trailing slash, which is why every call site // below gained a `/` separator. The old constant was written expecting a trailing slash - so this was // not the "one-line change" the card predicted, and swapping the constant alone would have produced // `.../R4DiagnosticReport`. Same repair, same reasoning, already proven in `ImagingStudyService.tsx` and // `ImagingFrameService.tsx`; copied rather than re-derived. const API_URL = environment.fhirClinicalApi; export const useDiagnosticReportService = () => { const { get, post, put, delete: del } = useHttpService(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDate, addFhirPeriodIfApplicable, unpackBundle, extractReferenceId } = useFhirServiceContext(); const { patientState } = usePatientServiceContext(); const [diagnosticReportsList, setDiagnosticReportsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [diagnosticReportsBundle, setDiagnosticReportsBundle] = useState | null>(null); const diagnosticReportsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { diagnosticReportsBundleRef.current = diagnosticReportsBundle; }, [diagnosticReportsBundle]); // WHOSE RESULTS ARE THESE? This service had NO patient reset at all - not the falsy-patientState // one its thirty-four siblings share, which never fires on a chart switch, but none whatsoever. // So the cache survived even a patient going away entirely, and for five minutes after loading // one chart it answered every other chart with that chart's labs and imaging. // // It went unnoticed longer than the others precisely BECAUSE it lacked the broken reset: the // sweep that found this class identified its population by that reset, so a service without one // could not appear in it. Failing to match the pattern is not the same as not having the problem. const cacheOwner = useCacheOwner('DiagnosticReport', () => { setLastUpserted(null); setDiagnosticReportsBundle(null); setDiagnosticReportsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setDiagnosticReportsBundle(null); setDiagnosticReportsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchDiagnosticReports = useCallback(async (patient: Patient): Promise> => { return overlayHeldBundle('DiagnosticReport', await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/DiagnosticReport?${params.toString()}`); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchDiagnosticReportsWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. if (!forceReload && !shouldReload && !writtenSince('DiagnosticReport', lastFetchTimeRef.current) && diagnosticReportsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return diagnosticReportsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: DiagnosticReport[]; do { bundle = await fetchDiagnosticReports(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('DiagnosticReport', lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setDiagnosticReportsBundle(bundle); setDiagnosticReportsList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list that // is actually in state. cacheOwner.claim(patient.id, operation); return bundle; }, [fetchDiagnosticReports, unpackBundle, lastUpserted, shouldReload]); // `fresh`: the report as stored now, never the cached copy - what a refused save shows as "saved now". const fetchDiagnosticReport = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedReport = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? diagnosticReportsList.find(report => report.id === id) : undefined; if (cachedReport) { return cachedReport; } return get(`${API_URL}/DiagnosticReport/${id}`, { ...transport }); }, [get, diagnosticReportsList]); const fetchDiagnosticReportHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/DiagnosticReport/${id}/_history`); }, [get]); const createDiagnosticReport = useCallback(async (diagnosticReport: DiagnosticReport, transport?: SaveTransport): Promise => { // - the subject goes in the QUERY STRING, not only in the body. `POST /R4/DiagnosticReport` // sits behind `requirePatientAccess`, which runs in an API Gateway request authorizer, and an // authorizer never receives a body. Without this the create is refused before any handler runs, // and the browser is told only that the ACCOUNT lacks access. Same key this service already // searches with, so the two calls agree. const params = new URLSearchParams(); params.append('subject', extractReferenceId(diagnosticReport.subject) as string); return post(`${API_URL}/DiagnosticReport?${params.toString()}`, diagnosticReport, { ...transport }); }, [post, extractReferenceId]); // CONDITIONAL: written only over `basedOn`, the version the editor opened on (or reviewed), never the version // echoed in the body. A refusal is a RecordChangedError: 412 another write landed first, 428 no version named. const updateDiagnosticReport = useCallback(async (diagnosticReport: DiagnosticReport, basedOn: string | undefined, transport?: SaveTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); return put(`${API_URL}/DiagnosticReport/${diagnosticReport.id}`, diagnosticReport, { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); }, [put]); const deleteDiagnosticReport = useCallback(async (diagnosticReport: DiagnosticReport, transport?: SaveTransport): Promise => { return del(`${API_URL}/DiagnosticReport/${diagnosticReport.id}`, { ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); }, [del]); const transformToFhirDiagnosticReport = useCallback((item: any, patient: Patient, newEncounter?: Encounter): DiagnosticReport => { const { identifier, basedOn, status, category, code, subject, encounter, effectiveDateTime, effectivePeriod, issued, performer, resultsInterpreter, result, imagingStudy, media, conclusion, conclusionCode, presentedForm, note } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const encounterReference: Reference = createReferenceObject('Encounter', newEncounter?.id as string); let diagnosticReport: DiagnosticReport = { resourceType: 'DiagnosticReport', status: status, subject: patientReference, ...(code && { code: transformToCodeableConcept(code) }), ...((encounter || newEncounter) && { encounter: newEncounter ? encounterReference : encounter }), ...(identifier && identifier.length > 0 && { identifier }), ...(basedOn && basedOn.length > 0 && { basedOn: basedOn .map((base: any) => referenceFromFormItem(base)) .filter((ref: Reference | undefined): ref is Reference => Boolean(ref)) }), ...(category && category.length > 0 && { category: category.map((cat: any) => transformToCodeableConcept(cat) ) }), ...(effectiveDateTime && { effectiveDateTime: formatDate(effectiveDateTime) }), ...(filledPeriod(effectivePeriod) && { effectivePeriod: filledPeriod(effectivePeriod) }), ...(toFhirInstant(issued) && { issued: toFhirInstant(issued) }), ...(performer && performer.length > 0 && { performer: performer .map((perf: any) => referenceFromFormItem(perf)) .filter((ref: Reference | undefined): ref is Reference => Boolean(ref)) }), ...(resultsInterpreter && resultsInterpreter.length > 0 && { resultsInterpreter: resultsInterpreter .map((interp: any) => referenceFromFormItem(interp)) .filter((ref: Reference | undefined): ref is Reference => Boolean(ref)) }), ...(result && result.length > 0 && { result: result .map((res: any) => referenceFromFormItem(res, 'Observation')) .filter((ref: Reference | undefined): ref is Reference => Boolean(ref)) }), ...(imagingStudy && imagingStudy.length > 0 && { imagingStudy: imagingStudy .map((study: any) => referenceFromFormItem(study, 'ImagingStudy')) .filter((ref: Reference | undefined): ref is Reference => Boolean(ref)) }), ...(media && media.length > 0 && { media: media.map((m: any) => ({ comment: m.comment, link: referenceFromFormItem(m.link, 'Media') as Reference })) }), ...(conclusion && { conclusion }), ...(conclusionCode && conclusionCode.length > 0 && { conclusionCode: conclusionCode.map((code: any) => transformToCodeableConcept(code) ) }), ...(presentedForm && presentedForm.length > 0 && { presentedForm }) }; return diagnosticReport; }, [createReferenceObject, transformToCodeableConcept, formatDate]); const transformFromFhirDiagnosticReport = useCallback((diagnosticReport: DiagnosticReport): any => { const { identifier, basedOn, status, category, code, encounter, effectiveDateTime, effectivePeriod, issued, performer, resultsInterpreter, result, imagingStudy, media, conclusion, conclusionCode, presentedForm, } = diagnosticReport; const formData = { identifier: identifier || [], basedOn: basedOn || [], status: status || 'registered', category: category ? category.map(cat => transformFromCodeableConcept(cat)) : [], code: transformFromCodeableConcept(code), encounter: encounter || null, effectiveDateTime: effectiveDateTime || '', effectivePeriod: effectivePeriod || { start: '', end: '' }, issued: issued || '', performer: performer || [], resultsInterpreter: resultsInterpreter || [], result: result || [], imagingStudy: imagingStudy || [], media: media || [], conclusion: conclusion || '', conclusionCode: conclusionCode ? conclusionCode.map(code => transformFromCodeableConcept(code)) : [], presentedForm: presentedForm || [] }; return formData; }, [transformFromCodeableConcept]); return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, diagnosticReportsBundle, diagnosticReportsList, setDiagnosticReportsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDiagnosticReports, fetchDiagnosticReportsWithPolling, fetchDiagnosticReport, fetchDiagnosticReportHistory, createDiagnosticReport, updateDiagnosticReport, deleteDiagnosticReport, transformToFhirDiagnosticReport, transformFromFhirDiagnosticReport }; }; const DiagnosticReportServiceContext = createContext | undefined>(undefined); export const DiagnosticReportServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const diagnosticReportService = useDiagnosticReportService(); return ( {children} ); }; export const useDiagnosticReportServiceContext = () => { const context = useContext(DiagnosticReportServiceContext); if (context === undefined) { throw new Error('useDiagnosticReportServiceContext must be used within a DiagnosticReportServiceProvider'); } return context; };