import { useEffect, useState, useCallback, useRef } from 'react'; import { useCommunicationServiceContext, useAuthServiceContext } from '../services'; import { useCapabilityServiceContext } from '../services/CapabilityService'; import { isStaffOfInactivePractice } from '../services/PracticeClosureService'; import { describeFetchFailure, isRetryableFailure } from '../utils/fetchFailureMessage'; import { usePracticeUnlocked } from '../utils/practiceUnlocked'; import type { NameRead } from '../utils/messaging/counterpartyRowName'; /** * The route the thread list is read from, exactly as the server's own table keys it. * * A TYPO HERE DISABLES MESSAGING FOR EVERYONE, SILENTLY. `mayCall` answers from a Set of permitted * keys, so an unrecognised key is indistinguishable from a denied one - both come back `false`. Verified * against `handlers/routes.ts` in the api service rather than guessed: `GET /R4/CommunicationRequest`, * whose `allowedRoles` are Practitioners, Receptionists, Admins, Patients and RelatedPersons - with no * Staff entry and no `requiredPermission`, which is precisely why a delegated Staff-group role is denied * and why no org setting can grant it. */ const THREAD_LIST_ROUTE = '/CommunicationRequest'; interface UseCommunicationManagerOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; /** Read the list again when this surface opens, even if it was read earlier in the sign-in. */ rereadOnOpen?: boolean; } interface UseCommunicationManagerReturn { /** How a counterparty's name read went in the current scope; absent while unread or in another scope. */ participantNameRead?: (id?: string) => NameRead | undefined; loading: boolean; error: string | undefined; /** * Could a retry clear this? `undefined` when there is no error, or when the cause was unclassifiable. * Consumers must treat `undefined` as RETRYABLE - see `MessagesLoadFailure`. */ errorRetryable: boolean | undefined; /** * The server says this caller may not read the thread list at all, so nothing was requested. * * IT IS A DISTINCT STATE FROM BOTH "loading" AND "no conversations", and collapsing it into either * reproduces a defect. Rendering the ordinary empty state would assert this person has no messages - * a false claim. Rendering an error would * claim the system is broken when it is working exactly as designed. */ notPermitted: boolean; /** * The load has FINISHED for the identity currently in view - as reactive state, so a consumer can tell * "there are no conversations" from "nobody has asked yet". * * Every source here starts empty, so for a person with no threads a completed load changes nothing and * there is no transition to watch for. Do not substitute `loading`: it starts FALSE and is only set * inside an effect, so there is a painted frame where nothing is loading and nothing is loaded. */ communicationDataLoaded: boolean; loadCommunicationData: (force?: boolean) => Promise; clearError: () => void; patientCommunicationRequests: ReturnType['patientCommunicationRequests']; staffCommunicationRequests: ReturnType['staffCommunicationRequests']; patientUnreadMessages: ReturnType['patientUnreadMessages']; staffUnreadMessages: ReturnType['staffUnreadMessages']; getCommunicationThread: ReturnType['getCommunicationThread']; getMessagesForRequest: ReturnType['getMessagesForRequest']; getMessagesForThread: ReturnType['getMessagesForThread']; getUnreadCountForRequest: ReturnType['getUnreadCountForRequest']; setActiveThread: ReturnType['setActiveThread']; markThreadAsRead: ReturnType['markThreadAsRead']; openDirectThread: ReturnType['openDirectThread']; // - the front-desk pool. Alongside the direct opener rather than replacing it: // practitioners stay individual, the office is a role. openFrontDeskThread: ReturnType['openFrontDeskThread']; openStaffDirectThread: ReturnType['openStaffDirectThread']; resolveDirectThread: ReturnType['resolveDirectThread']; addCommunicationRequest: ReturnType['addCommunicationRequestToState']; updateCommunicationRequest: ReturnType['updateCommunicationRequestInState']; deleteCommunicationRequest: ReturnType['deleteCommunicationRequestFromState']; } export const useCommunicationManager = ( options: UseCommunicationManagerOptions = {} ): UseCommunicationManagerReturn => { const { autoLoad = true, errorMessage = "Failed to load communication data", forceReload = false, rereadOnOpen = false, } = options; const { currentUser, userRole, selectedWorkspace, wpOrganization, currentOrganization, } = useAuthServiceContext(); const { initializeCommunicationData, isInitialized, isInitializing, listingRunKey, communicationDataLoaded, participantNameRead, patientCommunicationRequests, staffCommunicationRequests, patientUnreadMessages, staffUnreadMessages, getCommunicationThread, getMessagesForRequest, getMessagesForThread, getUnreadCountForRequest, setActiveThread, markThreadAsRead, openDirectThread, openFrontDeskThread, openStaffDirectThread, resolveDirectThread, addCommunicationRequestToState, updateCommunicationRequestInState, deleteCommunicationRequestFromState } = useCommunicationServiceContext(); const { mayCall, state: capabilityState } = useCapabilityServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [errorRetryable, setErrorRetryable] = useState(); const effectiveUserRole = selectedWorkspace ?? userRole; /** * - DO NOT ASK FOR SOMETHING THIS ROLE CAN NEVER BE PERMITTED TO HAVE. * * Measured on one build with a control: `hr1` and `marketing1` - both delegated `Staff`-group roles - * were refused **403** on `CommunicationRequest?sender=` and `?recipient=`, where a practitioner got * **200** on both. That is the grant shape demonstrated rather than inferred, and it is not a race or * a misconfiguration: the route has no `Staff` entry and no `requiredPermission`, so **no org setting * can grant it.** * * `=== false` ONLY, never truthiness. `mayCall` returns `undefined` for NOT KNOWN - a capability * endpoint that is slow, down, or simply not deployed yet - and treating that as "no" would blank * messaging for every user in the product the moment it hiccuped. * * THE COMMENT THIS REPLACES CLAIMED THE OPPOSITE AND THE SERVER DISAGREES: *"dropped so PM/Billing/ * HR can reach colleague messaging"*. The client enables it; the route refuses it. This gate makes * that contradiction honest rather than resolving it - whichever way the product decision goes, this * line is self-correcting, because the day the route admits `Staff` the answer flips to `true` and the * fetch resumes with no client change. */ const notPermitted = mayCall('GET', THREAD_LIST_ROUTE) === false; // A PATIENT READING THEIR OWN THREADS NEEDS NO PRACTICE. Requiring one switched messaging // off on the neutral host, where the inbox then said the patient had none. Those routes // carry allowOwnPatientRecordWithoutOrganization and bound the result by the participant // filter. Staff still need one: every staff thread belongs to a practice. RelatedPerson is // excluded on purpose: the server exemption excludes an acting-as proxy too. const readsOwnRecord = currentUser?.fhir?.resourceType === 'Patient'; const withoutOrganization = !readsOwnRecord && !wpOrganization?.current?.id; const communicationAllowed = Boolean(effectiveUserRole) && !notPermitted && !withoutOrganization && !isStaffOfInactivePractice(currentUser, currentOrganization); const communicationAllowedRef = useRef(communicationAllowed); useEffect(() => { communicationAllowedRef.current = communicationAllowed; if (!communicationAllowed) { settledRef.current = undefined; setLoading(false); setError(undefined); setErrorRetryable(undefined); } }, [communicationAllowed]); // A read refused while a new member's access was still settling is answered by a later read that // succeeds, often another surface's. Once the shared data has loaded, that refusal is no longer true. useEffect(() => { if (!communicationDataLoaded) return; setError(undefined); setErrorRetryable(undefined); }, [communicationDataLoaded]); // Only a user's first read reports loading. Every front-desk pool ping forces a re-read, and a // loading flag on each one swapped the whole list for its skeleton, so the desk list flashed. // Keyed by user, so a switched seat shows its skeleton rather than the previous user's list. // A page that mounts over threads already read starts settled: its first ping is a refresh too. const settledRef = useRef(undefined); const loadCommunicationData = useCallback(async (force = false) => { if (!currentUser?.fhir || !communicationAllowed) return; const userId = currentUser.fhir.id; const shown = settledRef.current === userId || (settledRef.current === undefined && isInitialized); if (!shown) setLoading(true); setError(undefined); setErrorRetryable(undefined); try { await initializeCommunicationData(force); settledRef.current = userId; } catch (err) { console.error('Failed to load communication data:', err); if (communicationAllowedRef.current) { setError(describeFetchFailure(err, errorMessage)); // The SAME classifier that wrote the sentence decides whether a button is offered. // Two readings of one failure is how the panel came to contradict its own banner. setErrorRetryable(isRetryableFailure(err)); } } finally { setLoading(false); } }, [currentUser, communicationAllowed, initializeCommunicationData, errorMessage, isInitialized]); // Opening the surface reads the list again. It is read once per sign-in otherwise, and a desk that // left Messages missed the front-desk ping: back on the page, the Inbox served the list from sign-in. // Only when it was ALREADY read as this surface opened; a first read is the load below, not a second. const openedOnALoadedList = useRef(isInitialized); const rereadOnThisOpening = useRef(false); useEffect(() => { if (!rereadOnOpen || !openedOnALoadedList.current || rereadOnThisOpening.current) return; if (!currentUser?.fhir || !communicationAllowed) return; rereadOnThisOpening.current = true; void loadCommunicationData(true); }, [rereadOnOpen, currentUser, communicationAllowed, loadCommunicationData]); // A read refused while the practice waited for its owner is asked again once it is unlocked. usePracticeUnlocked(() => { if (error) void loadCommunicationData(true); }); const clearError = useCallback(() => { setError(undefined); setErrorRetryable(undefined); }, []); useEffect(() => { if (!autoLoad) return; if (!currentUser?.fhir || !communicationAllowed) return; // HOLD THE FIRST FETCH UNTIL CAPABILITY HAS AN ANSWER, or the gate above arrives too late to // prevent anything: on a cold load `mayCall` is `undefined`, the request fires, and the 403 this // card is about happens anyway before the answer lands. // Only `loading` waits. `unavailable` proceeds - that is the whole reason the capability service // keeps "we could not ask" separate from "you may not", and waiting on it would turn a capability // outage into a messaging outage. if (capabilityState === 'loading') return; if (isInitializing) return; if (!isInitialized || forceReload) { loadCommunicationData(); } }, [ autoLoad, currentUser, communicationAllowed, // Capability resolves asynchronously, so this effect MUST re-run when the answer lands: on a // cold load it is held above, and without this dependency it would never be released. capabilityState, isInitialized, isInitializing, // A new identity, practice or lifetime is a new run to start, even if neither flag changed value. listingRunKey, forceReload, loadCommunicationData ]); return { participantNameRead, loading, error, errorRetryable, notPermitted, communicationDataLoaded, loadCommunicationData, clearError, patientCommunicationRequests, staffCommunicationRequests, patientUnreadMessages, staffUnreadMessages, getCommunicationThread, getMessagesForRequest, getMessagesForThread, getUnreadCountForRequest, setActiveThread, markThreadAsRead, openDirectThread, openFrontDeskThread, openStaffDirectThread, resolveDirectThread, addCommunicationRequest: addCommunicationRequestToState, updateCommunicationRequest: updateCommunicationRequestInState, deleteCommunicationRequest: deleteCommunicationRequestFromState }; }; export default useCommunicationManager;