import { useEffect } from 'react'; import { DiagnosticReport } from 'fhir/r4'; import { useDiagnosticReportServiceContext, useFhirServiceContext, usePatientServiceContext, useWebSocketServiceContext } from '../services'; import { isArchived } from '../utils/archivedResource'; import { useNoticeFence } from './useNoticeFence'; /** * A RESULT THE SERVER ALREADY PUSHED, AND NOBODY WAS LISTENING FOR. * * All three `DiagnosticReport` verbs broadcast to the patient's own channel as well as the * organization's, and there was no hook on this side to consume any of them - so the event arrived on the * socket and was dropped. Combined with a record list that is fetched once per document, the patient did * not get their result late; they did not get it at all until they reloaded the whole page. * * Spencer, 2026-09-06: *"a lab or imaging result reaches the patient without delay when it exists; it is * never held for the practitioner's review."* A stale screen is indistinguishable from "no result yet" to * the person waiting for it, which is the absence-that-is-not-an-absence the denial rule forbids. * * Shaped after `useFhirConditionBroadcast` deliberately - it is the one proven in a live two-tab test - * with one difference, below, that FHIR forces. */ export const useFhirDiagnosticReportBroadcast = () => { const { subscribe, unsubscribe } = useWebSocketServiceContext(); const { diagnosticReportsList, setDiagnosticReportsList, fetchDiagnosticReport } = useDiagnosticReportServiceContext(); const { patientState } = usePatientServiceContext(); const { extractReferenceId } = useFhirServiceContext(); const fence = useNoticeFence(patientState?.id); useEffect(() => { if (!patientState) { return; } const handleDiagnosticReportEvent = (cloudEvent: any) => { const report: DiagnosticReport = cloudEvent.data; const eventType = cloudEvent.type; /* * `subject` IS OPTIONAL ON DiagnosticReport, WHERE IT IS REQUIRED ON Condition, AND THAT IS * THE ONE PLACE THIS HOOK DIFFERS FROM ITS MODEL. * * A report with no subject belongs to no patient, so it is ignored here. The server guards * the same case on the way out and this is the other end of that guard. * * BE HONEST ABOUT WHAT THIS LINE BUYS: `extractReferenceId` already accepts `undefined` and * returns `undefined` (FhirService.tsx:645-648), so removing the check changes no behaviour * today - the `!patientId` test below catches it either way. It is kept as a statement that * the optionality is known about rather than as a load-bearing guard, and it is written down * that way so nobody later reads it as protection it does not provide. */ const patientId = report?.subject ? extractReferenceId(report.subject) : undefined; if (!patientId || patientState.id !== patientId) { return; } // The patient's own channel names which report changed and nothing of it: what may be shown is read // again through the gated GET, and a report that read no longer returns leaves the list. // Every event about a report retires a reread still pending for it, and so does a change of patient. const stands = report.id ? fence.claim(report.id) : () => false; if (cloudEvent.data?.invalidated === true && report.id) { const id = report.id; if (eventType === 'com.akashichealthrecords.fhir.diagnosticreport.deleted') { setDiagnosticReportsList(prev => prev.filter(p => p.id !== id)); return; } fetchDiagnosticReport(id, true) .then((fresh) => { if (!stands()) return; setDiagnosticReportsList(prev => (isArchived(fresh) ? prev.filter(p => p.id !== id) : prev.some(p => p.id === id) ? prev.map(p => p.id === id ? fresh : p) : [...prev, fresh])); }) .catch(() => { if (stands()) setDiagnosticReportsList(prev => prev.filter(p => p.id !== id)); }); return; } switch (eventType) { case 'com.akashichealthrecords.fhir.diagnosticreport.created': // IDEMPOTENT ON PURPOSE. This list has two writers - this broadcast, and the // service's own refetch after a write, which polls until the new id appears. An // unconditional append has already put a SECOND copy of one resource on screen once, // observed on a patient's questionnaire answers where one submission rendered as two // completed forms. Match on id: replace if present, append if not, and keep the // broadcast's copy because its body is at least as fresh. setDiagnosticReportsList(prev => prev.some(p => p.id === report.id) ? prev.map(p => p.id === report.id ? report : p) : [...prev, report] ); break; case 'com.akashichealthrecords.fhir.diagnosticreport.updated': // An archived report leaves the LIST and stays in the RECORD - history is never // destroyed, so this removes it from what is on screen and deletes nothing. setDiagnosticReportsList(prev => isArchived(report) ? prev.filter(p => p.id !== report.id) : prev.map(p => p.id === report.id ? report : p) ); break; case 'com.akashichealthrecords.fhir.diagnosticreport.deleted': setDiagnosticReportsList(prev => prev.filter(p => p.id !== report.id)); break; } }; subscribe('com.akashichealthrecords.fhir.diagnosticreport.created', handleDiagnosticReportEvent); subscribe('com.akashichealthrecords.fhir.diagnosticreport.updated', handleDiagnosticReportEvent); subscribe('com.akashichealthrecords.fhir.diagnosticreport.deleted', handleDiagnosticReportEvent); return () => { unsubscribe('com.akashichealthrecords.fhir.diagnosticreport.created', handleDiagnosticReportEvent); unsubscribe('com.akashichealthrecords.fhir.diagnosticreport.updated', handleDiagnosticReportEvent); unsubscribe('com.akashichealthrecords.fhir.diagnosticreport.deleted', handleDiagnosticReportEvent); }; }, [patientState, subscribe, unsubscribe, extractReferenceId, diagnosticReportsList, setDiagnosticReportsList, fetchDiagnosticReport, fence]); };