import { isAsOfIncomplete } from '../../utils/searchTruncation'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { Bundle } from 'fhir/r4'; import { useHttpService } from '../../services/HttpService'; import { environment } from '../../environments/environment'; import { summariseShownItem, type ShownResource, type ShownItemSummary } from './shareableRecordItem'; import { onViewerChange, overlayHeld, refersToPatient, sameViewerSince, startSearch, viewerEpoch } from '../../utils/writeThroughHold'; import { fetchAllBundlePages } from '../../utils/fetchAllBundlePages'; import { SaveRetiredError } from '../../utils/saveOperation'; // Read kind by kind, so one refused or failed kind does not hide the others. export interface ShareableItem extends ShownItemSummary { reference: string; } const KINDS: { type: ShownResource['resourceType']; base: string }[] = [ { type: 'ImagingStudy', base: environment.fhirClinicalApi }, { type: 'DiagnosticReport', base: environment.fhirClinicalApi }, { type: 'Observation', base: environment.fhirClinicalApi }, { type: 'CarePlan', base: environment.fhirClinicalApi }, { type: 'DocumentReference', base: environment.fhirFoundationApi }, ]; export interface ShareableItemsState { items: ShareableItem[]; loading: boolean; /** Every kind failed: nothing here can be trusted as the whole record. */ failed: boolean; /** Some kinds failed, stopped at the page cap or were served short of a cutoff: what is listed is real, but not everything. */ partial: boolean; } const LOADING: ShareableItemsState = { items: [], loading: true, failed: false, partial: false }; // The collector hands back the cursor as it appeared in the next link, already escaped; it is set once, unescaped. const cursorOf = (pageParam: string): string => { try { return decodeURIComponent(pageParam); } catch { return pageParam; } }; // `scope`: the meeting session the picker belongs to; another session is another opening, even for the same patient. export const useShareableRecordItems = (patientId: string | undefined, open: boolean, scope?: string): ShareableItemsState => { const { get } = useHttpService(); const [viewer, setViewer] = useState(viewerEpoch); // Each change of viewer, practice, patient or opening is a new generation: what an earlier one listed is never shown. const owner = `${viewer}|${patientId ?? ''}|${open}|${scope ?? ''}`; const ownership = useRef({ owner, generation: 0 }); if (ownership.current.owner !== owner) ownership.current = { owner, generation: ownership.current.generation + 1 }; const key = `${ownership.current.generation}|${owner}`; const [answer, setAnswer] = useState<{ key: string; state: ShareableItemsState } | null>(null); // The opening rendered now, and whether the reader is still mounted: a read belongs to its opening from the render // that replaces it, not from the later passive cleanup. const currentKey = useRef(key); currentKey.current = key; const mounted = useRef(true); useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); useEffect(() => { if (!open || !patientId) return undefined; let live = true; const epoch = viewerEpoch(); const mine = key; const controller = new AbortController(); const owns = () => live && mounted.current && viewerEpoch() === epoch && currentKey.current === mine; // Every page is admitted only while this opening holds: before it is prepared, before it is sent, when it // answers, and before what it read settles any held record or is shown. const admit = () => { if (owns()) return; controller.abort(); throw new SaveRetiredError(); }; void Promise.allSettled(KINDS.map(async ({ type, base }) => { const started = startSearch(); let asOfIncomplete = false; // Every page: a patient can hold more than one page of a kind, and the first alone reads as all of it. const { resources, truncated } = await fetchAllBundlePages(async (pageParam) => { admit(); const params = new URLSearchParams({ subject: patientId, _count: '50' }); if (pageParam) params.set('page', cursorOf(pageParam)); const page = await get>(`${base}/${type}?${params.toString()}`, { admit, signal: controller.signal }); admit(); if (isAsOfIncomplete(page)) asOfIncomplete = true; return page; }); const incomplete = truncated || asOfIncomplete; admit(); if (!sameViewerSince(started)) return { resources: [] as ShownResource[], incomplete }; // A record this viewer just saved shows before the search has it; a search cut off at the cap or served short // of a cutoff proves no absence. const shown = overlayHeld(type, resources, (resource) => refersToPatient(resource, patientId), Date.now(), !incomplete, started); return { resources: shown, incomplete }; })).then((results) => { if (!owns()) return; const items = results.flatMap((result) => result.status === 'fulfilled' ? result.value.resources.flatMap((resource) => resource?.id ? [{ reference: `${resource.resourceType}/${resource.id}`, ...summariseShownItem(resource) }] : []) : []); const failures = results.filter((result) => result.status === 'rejected').length; const incomplete = results.some((result) => result.status === 'fulfilled' && result.value.incomplete); const failed = failures === KINDS.length; setAnswer({ key, state: { items, loading: false, failed, partial: !failed && (failures > 0 || incomplete) } }); }); return () => { live = false; controller.abort(); }; }, [get, open, patientId, scope, key]); return answer?.key === key ? answer.state : LOADING; };