import { useEffect, useState, useCallback } from 'react'; import { useAuthServiceContext, useScheduleServiceContext, useStaffPermissionServiceContext, } from '../services'; import { describeFetchFailure } from '../utils/fetchFailureMessage'; import { usePracticeUnlocked } from '../utils/practiceUnlocked'; interface UseFhirSchedulesOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirSchedulesReturn { /** * MAY THIS READER SEE STAFF SCHEDULES AT ALL? Separate from `restricted`, which answers whose. * * A stored profile of `{ schedules: level none, scope all }` is real - measured on Great River's * HR role - and read as scope alone it says "this reader sees everyone" about somebody who sees * nothing. The surface that words the empty state needs to tell those apart, because "no staff * schedules found for this period" is a claim about the PRACTICE and is false here. */ maySeeSchedules: boolean; loading: boolean; error: string | undefined; /** `force` reads past the shared five-minute cache, as an explicit retry must. */ loadSchedules: (force?: boolean) => Promise; clearError: () => void; schedulesBundle: ReturnType['schedulesBundle']; schedulesList: ReturnType['schedulesList']; /** * - TRUE WHEN THIS LIST CANNOT ANSWER A QUESTION ABOUT ANYONE ELSE. The caller may only * see their OWN schedules, so what came back is NARROWED, not empty. * * A surface that ignores this reports *"this provider has no schedule"* about a colleague who is * working - a false claim about somebody's job, made with a 200 and nothing in the response to hint * at it. * `schedulesList` still returns what the caller CAN see: their own blocks are a true answer to a * narrower question, and emptying the list would be a second lie in the other direction. * It is NOT a gate. The server narrows regardless; this only decides what the screen SAYS. */ restricted: boolean; } export const useFhirSchedules = (options: UseFhirSchedulesOptions = {}): UseFhirSchedulesReturn => { const { autoLoad = true, errorMessage = "Failed to load schedules", forceReload = false } = options; // `USER_ROLES` is deliberately gone from here: the fetch no longer branches on WHO IS LOOKING. // `userRole` remains only as an "is anyone signed in yet" guard. const { userRole } = useAuthServiceContext(); // - whose schedules this caller may see. The server narrows the search to their own roles // when this is `own`, and says so NOWHERE in the response. // // BOTH HALVES. The scope answers WHOSE, and it is meaningless until the reader may see any at all: // `{ schedules: level none, scope all }` is a real stored profile, and read as scope alone it says // "this reader sees everyone" about somebody who sees nothing. The server returns 200 and an empty // set either way, so this is the only thing that can tell the two apart. const { canView, schedulesScope } = useStaffPermissionServiceContext(); const maySeeSchedules = canView('schedules'); const { fetchSchedulesWithPolling, schedulesBundle, schedulesList } = useScheduleServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const loadSchedules = useCallback(async (force = false) => { if (!userRole) { return; } setLoading(true); setError(undefined); /* - ONE SCOPE OF FETCH, AND THE CLIENT NO LONGER NARROWS IT. This used to read: if the viewer's active workspace is Practitioner, fetch only THEIR schedules; otherwise fetch the organization's. That branches on WHO IS LOOKING, while every one of the eleven callers is asking about somebody ELSE - booking, the staff directory, a colleague's record. So a clinician who also holds `staff:manage` (the solo practitioner who is also the admin - the typical small practice this product is built for) opened a colleague's staff record and got a list containing only their own blocks. **Every colleague read as "no schedule."** AND IT WAS WORSE THAN PER-SCREEN, because the result is cached on the shared ScheduleService context for five minutes: whichever screen loaded FIRST fixed the scope for every screen after it, so switching to the Admin workspace did not widen a list that was already populated. **One scope of fetch removes that entirely** - there is now no narrower bundle for a wider question to be answered from, which is why this needs no cache-coverage bookkeeping. It gives away nothing. The SERVER decides what this caller may see ( schedules scope), so an org-wide request from an `own`-scoped caller comes back narrowed anyway. The client-side narrowing only ever hid which question was being asked. */ try { await fetchSchedulesWithPolling(undefined, force); } catch (err) { console.error('Failed to load schedules:', err); setError(describeFetchFailure(err, errorMessage)); } finally { setLoading(false); } }, [userRole, fetchSchedulesWithPolling, errorMessage]); // A read refused while the practice waited for its owner is asked again once it is unlocked. usePracticeUnlocked(() => { if (error) void loadSchedules(); }); const clearError = useCallback(() => { setError(undefined); }, []); useEffect(() => { if (!autoLoad) return; if (!userRole) return; if (!schedulesBundle || forceReload) { loadSchedules(); } }, [autoLoad, userRole, schedulesBundle, forceReload, loadSchedules]); return { loading, error, loadSchedules, clearError, schedulesBundle, schedulesList, // TRUE FOR BOTH NARROWED CASES, because the consumer's question is "may I present this as // the practice's schedules?" and the answer is no in each. Which of the two it is travels in // `maySeeSchedules` for the surface that words it. restricted: !maySeeSchedules || schedulesScope === 'own', maySeeSchedules, }; };