import { useEffect, useRef, useState, useCallback } from 'react'; import { usePractitionerServiceContext } from '../services'; import { describeFetchFailure, isRetryableFailure } from '../utils/fetchFailureMessage'; import { usePracticeUnlocked } from '../utils/practiceUnlocked'; interface UseFhirPractitionersOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; /** The mount's read skips the held list, for a screen whose doors depend on who is on staff now. */ refreshOnMount?: boolean; } interface UseFhirPractitionersReturn { loading: boolean; error: string | undefined; /** * WAS THAT A BOUNDARY OR A FAULT - as a fact, not as a sentence. * * Added for: the staff Schedules page explains a permission limit gracefully twice and then * rendered *"Failed to load practitioners. You do not have access to this information."* about the SAME * denial, as a red error. One screen said both *"this is how it works"* and *"something went wrong."* * * THE PAGE MUST NOT ANSWER THIS BY READING THE MESSAGE. `describeFetchFailure` gives a denial a * distinctive suffix, so string-matching it would work - and it is the exact coupling that made a copy * edit a silent regression once already. * The retryability of a response is a property of the RESPONSE; it is carried here rather than reverse * engineered from prose. * * Additive: every existing consumer keeps `error` unchanged and can ignore this. */ errorIsPermissionDenial: boolean; loadPractitioners: (force?: boolean) => Promise; clearError: () => void; practitionersBundle: ReturnType['practitionersBundle']; practitionersList: ReturnType['practitionersList']; } export const useFhirPractitioners = (options: UseFhirPractitionersOptions = {}): UseFhirPractitionersReturn => { const { autoLoad = true, errorMessage = "Failed to load practitioners", forceReload = false, refreshOnMount = false } = options; const { fetchPractitionersWithPolling, practitionersBundle, practitionersList } = usePractitionerServiceContext(); // Loading from the first render, so nothing decides on the held list before the fresh one arrives. const [loading, setLoading] = useState(autoLoad && refreshOnMount); const [error, setError] = useState(); const [errorIsPermissionDenial, setErrorIsPermissionDenial] = useState(false); const loadPractitioners = useCallback(async (force = false) => { setLoading(true); setError(undefined); setErrorIsPermissionDenial(false); try { await fetchPractitionersWithPolling(force || forceReload); } catch (err) { console.error('Failed to load practitioners:', err); setError(describeFetchFailure(err, errorMessage)); // The same question `describeFetchFailure` just answered, kept as a fact so a caller does not // have to read the sentence back to find out. setErrorIsPermissionDenial(!isRetryableFailure(err)); } finally { setLoading(false); } }, [fetchPractitionersWithPolling, errorMessage, forceReload]); // A read refused while the practice waited for its owner is asked again once it is unlocked. usePracticeUnlocked(() => { if (error) void loadPractitioners(true); }); const clearError = useCallback(() => { setError(undefined); setErrorIsPermissionDenial(false); }, []); // Each mount asks once even when a list is held: the service answers from it only while it is fresh, // so someone who joined the practice since is not missing until a full reload. const askedOnMount = useRef(false); useEffect(() => { if (!autoLoad) return; if (!practitionersBundle || forceReload || !askedOnMount.current) { const mountAsk = !askedOnMount.current; askedOnMount.current = true; loadPractitioners(mountAsk && refreshOnMount); } }, [autoLoad, practitionersBundle, forceReload, loadPractitioners]); return { loading, error, errorIsPermissionDenial, loadPractitioners, clearError, practitionersBundle, practitionersList }; };