import { useEffect, useState, useCallback, useRef } from 'react'; import { usePatientServiceContext, useDocumentReferenceServiceContext, useFhirServiceContext, useAuthServiceContext } from '../services'; import { DocumentReference, Bundle, Organization } from 'fhir/r4'; import { describeFetchFailure, isRefusedRead } from '../utils/fetchFailureMessage'; import { viewerEpoch } from '../utils/writeThroughHold'; import { isAsOfIncomplete } from '../utils/searchTruncation'; interface UseFhirDocumentReferencesOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; isRoot?: boolean } interface UseFhirDocumentReferencesReturn { loading: boolean; error: string | undefined; /** The last load was answered with a 403: refused, not lost. */ refused: boolean; loadDocumentReferences: () => Promise; /** After a save: ask past the cache and wait for the document just written. */ reloadAfterWrite: (awaitId?: string) => Promise; clearError: () => void; documentReferencesBundle: ReturnType['documentReferencesBundle']; documentReferencesList: ReturnType['documentReferencesList']; /** This practice's list for the patient on screen was served as of a cutoff and not in full. */ incomplete: boolean; tempDocumentReferencesBundle: Bundle | null; tempDocumentReferencesList: DocumentReference[]; loadTempDocumentReferences: () => Promise; clearTempDocumentReferences: () => void; } export const useFhirDocumentReferences = (options: UseFhirDocumentReferencesOptions = {}): UseFhirDocumentReferencesReturn => { const { autoLoad = true, errorMessage = 'Failed to load document references', forceReload = false, isRoot = false } = options; const { wpOrganization } = useAuthServiceContext(); const { patientState, tempPatientState } = usePatientServiceContext(); const { fetchDocumentReferencesWithPolling, fetchDocumentReferences, documentReferencesBundle, documentReferencesScope, documentReferencesList } = useDocumentReferenceServiceContext(); // Only this practice's list for the patient on screen speaks for them; another scope's mark says nothing here. const incomplete = !isRoot && documentReferencesScope === `org:${wpOrganization?.current?.id ?? 'none'}:patient:${patientState?.id}` && isAsOfIncomplete(documentReferencesBundle); const { unpackBundle } = useFhirServiceContext(); // The ordinary list and a temporary patient's list each own their wait: clearing one never ends the other's. const [mainLoading, setMainLoading] = useState(false); const [tempLoading, setTempLoading] = useState(false); const loading = mainLoading || tempLoading; const [error, setError] = useState(); const [refused, setRefused] = useState(false); const [tempDocumentReferencesBundle, setTempDocumentReferencesBundle] = useState | null>(null); const [tempDocumentReferencesList, setTempDocumentReferencesList] = useState([]); // Whether this view is still shown, and the viewer its patient was chosen under: a reload asked for by a save // that answered after either changed starts no read for the old patient. const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const patientViewerRef = useRef(viewerEpoch()); useEffect(() => { patientViewerRef.current = viewerEpoch(); }, [patientState]); // Only the newest temporary-patient read may publish, and only under the viewer it started for. const tempRequestRef = useRef(0); // Ordinary reads can overlap; only the newest one ends the wait. const mainRequestRef = useRef(0); const loadDocumentReferences = useCallback(async () => { if (!isRoot && !patientState) { return; } const request = ++mainRequestRef.current; setMainLoading(true); setError(undefined); setRefused(false); try { await fetchDocumentReferencesWithPolling(patientState, wpOrganization.current as Organization, isRoot); } catch (err) { console.error('Failed to load document references:', err); setError(describeFetchFailure(err, errorMessage)); setRefused(isRefusedRead(err)); } finally { if (request === mainRequestRef.current) setMainLoading(false); } }, [patientState, isRoot, fetchDocumentReferencesWithPolling, errorMessage]); // A save left the cached list a step behind, and the search may lag the write by a few seconds. const reloadAfterWrite = useCallback(async (awaitId?: string) => { if (!isRoot && !patientState) { return; } if (!mountedRef.current || patientViewerRef.current !== viewerEpoch()) { return; } try { await fetchDocumentReferencesWithPolling(patientState, wpOrganization.current as Organization, isRoot, true, awaitId); } catch (err) { console.error('Failed to reload document references:', err); setError(describeFetchFailure(err, errorMessage)); setRefused(isRefusedRead(err)); } }, [patientState, isRoot, fetchDocumentReferencesWithPolling, errorMessage]); const loadTempDocumentReferences = useCallback(async () => { if (!tempPatientState) { return; } const request = ++tempRequestRef.current; const viewer = viewerEpoch(); const current = () => mountedRef.current && request === tempRequestRef.current && viewer === viewerEpoch(); setTempLoading(true); setError(undefined); try { const bundle = await fetchDocumentReferences(tempPatientState, wpOrganization.current as Organization, false); if (!current()) return; const documentReferences = unpackBundle(bundle); setTempDocumentReferencesBundle(bundle); setTempDocumentReferencesList(documentReferences); } catch (err) { if (!current()) return; console.error('Failed to load temp document references:', err); setError(describeFetchFailure(err, errorMessage)); } finally { if (current()) setTempLoading(false); } }, [tempPatientState, fetchDocumentReferences, unpackBundle, errorMessage]); const clearError = useCallback(() => { setError(undefined); }, []); const clearTempDocumentReferences = useCallback(() => { // The pending read is abandoned, so its wait ends here; its own completion will not touch it. tempRequestRef.current++; setTempLoading(false); setTempDocumentReferencesBundle(null); setTempDocumentReferencesList([]); }, []); // The list is shared, so a bundle already standing may be another scope's: the practice's drawer // left behind on a patient's page drew the practice's files as the patient's. Each view asks once for // its own scope; the service answers a same-scope ask from its cache. const scope = isRoot ? 'practice' : `patient:${patientState?.id ?? ''}`; const askedScopeRef = useRef(null); useEffect(() => { // A failed load stays failed until something asks again; retrying here re-ran it on every render. if (!autoLoad || mainLoading || error) return; if (documentReferencesBundle && askedScopeRef.current === scope) return; if (!isRoot && !patientState) return; askedScopeRef.current = scope; loadDocumentReferences(); }, [autoLoad, documentReferencesBundle, mainLoading, error, loadDocumentReferences, scope, isRoot, patientState]); useEffect(() => { if (forceReload) { loadDocumentReferences(); } }, [forceReload, loadDocumentReferences]); useEffect(() => { // The practice drawer has no patient, temporary or otherwise. if (tempPatientState && !isRoot) { // The last temporary patient's documents are not shown while the next one's load. setTempDocumentReferencesBundle(null); setTempDocumentReferencesList([]); loadTempDocumentReferences(); } else { clearTempDocumentReferences(); } }, [tempPatientState, isRoot, loadTempDocumentReferences, clearTempDocumentReferences]); return { loading, error, refused, loadDocumentReferences, reloadAfterWrite, clearError, documentReferencesBundle, documentReferencesList, incomplete, tempDocumentReferencesBundle, tempDocumentReferencesList, loadTempDocumentReferences, clearTempDocumentReferences }; };