import type { SendCloseAnswer } from '../utils/messaging/sendRecovery'; import type { SendOperation } from '../utils/messaging/sendOperation'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { readClaimConflict, type ThreadClaimState } from '../utils/messaging/threadClaim'; import { useCapabilityServiceContext } from './CapabilityService'; import { FRONT_DESK_POOL_CHANNEL, parsePoolThreadId, readFrontDeskPoolNotice, withThreadPoolKey } from '../utils/messaging/poolThread'; import { createPoolPingReread } from '../utils/messaging/poolPingReread'; import { newerEdition } from '../utils/messaging/messageEdition'; import { isEncounterRoomThreadId } from '../utils/encounterRoomThread'; import { useOptionalWebSocketServiceContext } from './WebSocketService'; import { STAFF_INBOX_THREAD_TYPES, belongsInStaffInbox } from '../utils/messaging/staffInboxThreads'; import { isReferralThreadRequest, withReferralSubject } from '../utils/messaging/referralThread'; import { Annotation, Attachment, Bundle, Communication, CommunicationRequest, CodeableConcept, Encounter, Extension, Period, Organization, Patient, Practitioner, Reference, RelatedPerson } from 'fhir/r4'; import { environment } from '../environments/environment'; import { useHttpService, HttpError, type RequestAdmission } from './HttpService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; import { receiptReferenceOf } from '../utils/messaging/receiptReference'; import { onBehalfOf } from '../utils/messaging/sentOnBehalfBy'; import { useFhirServiceContext } from './FhirService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { IValueSet } from './ValueSetService.tsx'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle, isSearchTruncated, SEARCH_TRUNCATED_RELATION } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { directThreadCacheKey } from '../utils/directThreadCacheStorage'; import { reconcileThreadCache } from '../utils/reconcileThreadCache'; import { oneCopyPerThreadMessage } from '../utils/oneCopyPerThreadMessage'; import { authStorageArea } from './authStorage'; import { isRefusedRead } from '../utils/fetchFailureMessage'; import type { NameRead } from '../utils/messaging/counterpartyRowName'; import { readMyProxies } from '../hooks/useMyProxies'; import { SaveRetiredError } from '../utils/saveOperation'; /** The id-only notice the server sends a patient's helpers when a message arrives in one of the patient's threads. */ const MESSAGE_THREAD_UPDATED = 'message.thread.updated'; const API_URL = environment.fhirClinicalApi; const PRACTITIONER_API_URL = environment.fhirIdentityApi; const THREAD_ID_EXTENSION_URLS = [ 'https://akashichealthrecords.com/fhir/StructureDefinition/thread-id', 'https://akashic.health/fhir/StructureDefinition/thread-id', 'urn:akashic:thread:id', 'akashic-thread-id' ]; const READ_RECEIPT_EXTENSION_URLS = [ 'https://akashichealthrecords.com/fhir/StructureDefinition/read-receipt', 'https://akashic.health/fhir/StructureDefinition/read-receipt', 'urn:akashic:read:receipt', 'akashic-read-receipt' ]; const THREAD_TYPE_EXTENSION_URLS = [ 'https://akashichealthrecords.com/fhir/StructureDefinition/thread-type', 'https://akashic.health/fhir/StructureDefinition/thread-type', 'urn:akashic:thread:type', 'akashic-thread-type' ]; type AkashicThreadType = | 'patient_direct' | 'staff_direct' | 'collaboration_team' | 'provider_consult' | 'private_in_collaboration_team'; interface DirectThread { threadId: string; canonicalKey?: string; patientId?: string; organizationId?: string; // NULL on a POOL thread, and that is the point: there is no one receptionist who owns the // office's conversation. Anything dereferencing these must handle the absence - a sentinel // account id was rejected precisely because every downstream reader treats this as an account. staffAccountId?: string | null; staffRole?: 'practitioner' | 'receptionist' | null; // 'encounter-room' IS A REAL VALUE THE SERVER RETURNS AND THIS TYPE USED TO DENY IT. A union that // omits a case the wire actually carries reads as an exhaustive list to everybody after you, which is // how the staff inbox came to filter on a set of types that could not describe what it was handed. threadType?: 'direct' | 'pool' | 'encounter-room'; poolKey?: string | null; /** * - WHAT A POOL THREAD IS ABOUT. Absent on every thread that existed before this, which * the server treats as a patient; only an arriving referral says otherwise. `subjectLabel` and * `subjectSummary` are the row's name and the paragraph the desk reads, because a referral item holds * no messages - there is nothing to reply to, so the notice rides on the thread itself. */ subjectType?: 'patient' | 'referral' | null; subjectId?: string | null; subjectLabel?: string | null; subjectSummary?: string | null; isActive?: boolean; createdAt?: string; lastMessageAt?: string | null; legacyCommunicationRequestId?: string | null; } interface CachedDirectThread extends DirectThread { shellRequest?: CommunicationRequest; cachedAt: string; /** The listing scope (viewer, link, practice) that listed or opened it; only that scope may show it. */ listedIn?: string; } /** A listing answer: `complete` false means rows exist that the server could not read this time. */ export type ThreadListingState = 'unknown' | 'pending' | 'complete' | 'partial' | 'failed' | 'refused'; /** Where an asynchronous action was started: its listing scope, viewing lifetime and cache bucket. */ interface ActionContext { scope: string; lifetime: number; key: string | null; provider: number } interface CommunicationThread { request: CommunicationRequest; messages: Communication[]; unreadCount: number; lastActivity: Date; threadId?: string; /** * - present ONLY on a front-desk pool thread, and carried from the server rather than * re-derived. A claim expires by the clock; a second implementation of "is it still claimed" would * drift from the server about who is answering a patient. Absent means "not a pool thread", which is * what tells the UI there is no claim strip to render - not that nobody has claimed it. */ claim?: ThreadClaimState; } interface ThreadIdExtensionValue extends Extension { valueString?: string; valueId?: string; } interface ReadReceiptChildExtension extends Extension { valueReference?: Reference; valueInstant?: string; valueDateTime?: string; valueString?: string; } interface ReadReceiptExtension extends Extension { extension?: ReadReceiptChildExtension[]; } interface CommunicationRequestFormInput { status?: CommunicationRequest['status']; priority?: CommunicationRequest['priority']; category: IValueSet[]; authoredOn?: string; medium: IValueSet[]; reasonCode: IValueSet[]; reasonReference: Reference[]; replaces?: Reference[]; encounter?: Reference; note?: Annotation[]; } interface CommunicationRequestDisplayFields { occurrenceDateTime?: string; occurrencePeriod?: Period; } const FHIR_ID = /^[A-Za-z0-9.-]{1,64}$/; const createLegacyThreadShell = ( threadId: string, sender?: Reference, recipient?: Reference, organizationId?: string, authoredOn?: string ): CommunicationRequest => ({ resourceType: 'CommunicationRequest', id: threadId, status: 'active', authoredOn: authoredOn || new Date().toISOString(), ...(sender ? { sender } : {}), ...(recipient ? { recipient: [recipient] } : {}), ...(organizationId ? { extension: [ { url: 'https://akashichealthrecords.com/fhir/StructureDefinition/organization-id', valueString: organizationId } as Extension ] } : {}) } as CommunicationRequest); /** * The shell for a message that arrives on a thread this app has not loaded. A desk thread's id carries * its practice and pool, so the shell is marked as the desk, as a loaded one is; without that it read as a * direct thread named after whichever receptionist replied first. */ export const shellForArrivingMessage = (threadId: string, message: Communication): CommunicationRequest => { const pool = parsePoolThreadId(threadId); const shell = createLegacyThreadShell(threadId, message.sender, message.recipient?.[0], pool?.organizationId); return pool ? withThreadPoolKey(shell, { threadType: 'pool', poolKey: pool.poolKey }) : shell; }; const dedupeCommunicationRequests = (requests: CommunicationRequest[]): CommunicationRequest[] => { const seen = new Set(); return requests.filter((request) => { const id = request.id as string | undefined; if (!id) return false; if (seen.has(id)) return false; seen.add(id); return true; }); }; const getCommunicationRequestParticipants = (request: CommunicationRequest): Reference[] => [request.sender, ...(request.recipient || [])].filter( (reference): reference is Reference => !!reference?.reference ); const getReferenceResourceType = (reference?: Reference): string | undefined => reference?.reference?.split('/')[0]; const getReferenceResourceId = (reference?: Reference): string | undefined => reference?.reference?.split('/')[1]; const hasParticipantResourceType = (request: CommunicationRequest, resourceTypes: string[]): boolean => getCommunicationRequestParticipants(request).some((participant) => { const resourceType = getReferenceResourceType(participant); return !!resourceType && resourceTypes.includes(resourceType); }); const allParticipantsAreStaffReferences = (request: CommunicationRequest): boolean => { const participants = getCommunicationRequestParticipants(request); return participants.length > 0 && participants.every((participant) => { const resourceType = getReferenceResourceType(participant); return resourceType === 'Practitioner' || resourceType === 'PractitionerRole'; }); }; const getCommunicationRequestThreadType = (request: CommunicationRequest): AkashicThreadType | undefined => { const extension = request.extension?.find((item) => !!item?.url && THREAD_TYPE_EXTENSION_URLS.includes(item.url) ) as (Extension & { valueString?: string; valueCode?: string }) | undefined; const value = extension?.valueString || extension?.valueCode; return value as AkashicThreadType | undefined; }; const addThreadTypeExtensionToRequest = ( request: CommunicationRequest, threadType: AkashicThreadType, ): CommunicationRequest => { const extensions = [...(request.extension || [])]; const existingIndex = extensions.findIndex((item) => !!item?.url && THREAD_TYPE_EXTENSION_URLS.includes(item.url) ); const threadTypeExtension = { url: THREAD_TYPE_EXTENSION_URLS[0], valueString: threadType, } as Extension; if (existingIndex >= 0) { extensions[existingIndex] = threadTypeExtension; } else { extensions.push(threadTypeExtension); } return { ...request, extension: extensions, }; }; const getStaffParticipantIds = (request: CommunicationRequest): string[] => getCommunicationRequestParticipants(request) .filter((participant) => getReferenceResourceType(participant) === 'Practitioner') .map((participant) => getReferenceResourceId(participant)) .filter((id): id is string => !!id); const isSameStaffPair = ( request: CommunicationRequest, staffAId: string, staffBId: string, ): boolean => { const ids = new Set(getStaffParticipantIds(request)); return ids.size === 2 && ids.has(staffAId) && ids.has(staffBId); }; // EXPORTED FOR ITS TEST, and it is worth testing directly: this is where the inbox is SPLIT, so a // thread it cannot place appears on no screen at all. That failure is silent - there is no error and no // empty state, the row simply never exists. export const classifyCommunicationRequest = (request: CommunicationRequest): 'patient' | 'staff' | null => { const explicitThreadType = getCommunicationRequestThreadType(request); if (explicitThreadType === 'patient_direct' || explicitThreadType === 'collaboration_team' || explicitThreadType === 'private_in_collaboration_team') { return 'patient'; } if (explicitThreadType === 'staff_direct' || explicitThreadType === 'provider_consult') { return 'staff'; } // AN ARRIVING REFERRAL, WHICH HAS NO PARTICIPANT OF EITHER KIND AND SO MATCHED NEITHER TEST BELOW. // // There is no patient yet - deciding whether there is one is what triage does - and a pool shell's // staff side is null by design. So the classifier answered `null`, the thread went into neither // array, and it appeared in no list. Everything built to render it downstream (the row, the sending // practice's name, the match state) was correct and simply never reached. // // It joins the PATIENT side because that is the list the front desk already opens for work that // arrives from outside, and because the panel rendering that list was already taught to draw a // referral. Spencer, 2026-09-02: referrals arrive "the same way regular messages come, just marked // as referral request" - so the marker distinguishes them, not a separate list. if (isReferralThreadRequest(request)) { return 'patient'; } if (hasParticipantResourceType(request, ['Patient', 'RelatedPerson'])) { return 'patient'; } if (allParticipantsAreStaffReferences(request)) { return 'staff'; } return null; }; export const useCommunicationService = () => { const { get, post, put } = useHttpService(); // (B30) - see `fetchDirectThreads`. Safe to consume anywhere: the accessor returns a NEUTRAL // service when no provider is above it, so a missing provider degrades to "not known", never to "no". const { mayCall } = useCapabilityServiceContext(); const { currentUser, wpOrganization, actingAs } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { unpackBundle, transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, extractReferenceId, transformToReferenceCollection, transformFromReferenceCollection, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); // Whose threads a Patient login reads: the displayed patient, which is the act-as subject while // switched in. While acting it is nobody until the subject's record has loaded, so the caller's own // threads are never read under the subject; otherwise it falls back to the caller. Staff and // RelatedPerson logins always read as themselves. const messagingIdentity: Patient | Practitioner | RelatedPerson | undefined = currentUser?.fhir?.resourceType === 'Patient' ? (actingAs ? (patientState?.id === actingAs.subjectPatientId ? patientState : undefined) : (patientState ?? currentUser.fhir)) : currentUser?.fhir; const [patientCommunicationRequests, setPatientCommunicationRequests] = useState([]); const [staffCommunicationRequests, setStaffCommunicationRequests] = useState([]); const [communicationThreads, setCommunicationThreads] = useState>(new Map()); // WHETHER THE LOAD HAS FINISHED, AS STATE RATHER THAN AS THE REF BESIDE IT. // // `initializationRef.current.initialized` already tracks exactly this and cannot be read by a // consumer: a ref does not re-render, so a caller asking "do I have an answer yet" would get // whatever was true when something else happened to re-render it. // // WHY A CONSUMER NEEDS IT AT ALL, and it is the case no other signal covers: every source here // starts EMPTY - the request lists as `[]`, the thread map as an empty Map. For a patient with no // conversations, a finished load changes NOTHING, so there is no state transition to observe and // "nobody has asked yet" is indistinguishable from "there is nothing". Measured on the patient // home, whose unread tile stated a confident 0 for three seconds before the requests arrived. // // It is set beside the ref and cleared at the same two resets, so the two cannot disagree about // whose data is loaded. const [communicationDataLoaded, setCommunicationDataLoaded] = useState(false); // AN INBOX THAT COULD NOT ASK MUST NOT RENDER AS AN INBOX WITH NOTHING IN IT. // // Both thread listings below are deliberately non-fatal: losing the office inbox must not cost // somebody the whole Messages page. That is right, and on its own it produces the most misleading // screen in the product - a failed listing and an empty one are indistinguishable to the reader, and // the reader is told the reassuring one. The same conversation was written off as "no test data" // twice on that reading. // // So the failure is carried out to the screen rather than only to the console. It is not an error // state for the page: everything that did load still renders, and this only changes what is said in // place of "no conversations yet". // Published only by a live run, with the scope and lifetime it ran under; read back only by that same pair, so a // scope left and returned to says nothing until its own new run answers. const [threadListing, setThreadListing] = useState<{ scope: string; lifetime: number; state: ThreadListingState } | null>(null); // A helper's listing the server refused, for the viewer, acted-as link and practice it was refused under, // with the messaging grant read live from their own link (undefined when it could not be read). const [listingRefusal, setListingRefusal] = useState<{ key: string; messagingGranted?: boolean } | null>(null); const refusalKey = `${currentUser?.fhir?.id ?? ''}|${actingAs?.relatedPersonId ?? ''}|${wpOrganization?.current?.id ?? ''}`; // The viewer and acted-as link as last rendered, and the practice as it is now: an answer that comes back under // another scope than the one it was asked under changes nothing. const listingViewer = useRef(''); listingViewer.current = `${currentUser?.fhir?.id ?? ''}|${actingAs?.relatedPersonId ?? ''}`; const liveListingScope = useCallback(() => `${listingViewer.current}|${wpOrganization?.current?.id ?? ''}`, [wpOrganization]); const listingRefusedHere = listingRefusal?.key === refusalKey; // Only the newest grant read may publish; an older answer landing after a refresh says nothing now. const grantReadSeq = useRef(0); // A no-practice helper's grant while it is being read, or when it could not be settled: neither is a refusal // nor an empty inbox. const [neutralGrant, setNeutralGrant] = useState<{ key: string; state: 'pending' | 'unknown' } | null>(null); const neutralGrantHere = neutralGrant?.key === refusalKey ? neutralGrant.state : undefined; const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const activeThreadId = useRef(null); const initializationRef = useRef<{ initialized: boolean; initializing: boolean; userId: string | null; /** A refresh was asked for while this load ran: one more is owed when it settles, in this lifetime only. */ catchUp?: boolean; /** Whose load this is: identity, viewer, link and practice. Only the same scope may wait on it. */ scope?: string; /** The viewing lifetime it started in. A scope left and come back to is a new lifetime, not this one. */ lifetime?: number; }>({ initialized: false, initializing: false, userId: null }); // The latest load, for a catch-up owed by a run that settled after the render that started it. const loadAgain = useRef<() => void>(() => undefined); // Unmounted, every load and action still out is retired: nothing it finds is read further, cached or shown. // A counter rather than a flag, so a remount never revives an action the earlier mount started. const providerLifetime = useRef(0); useEffect(() => () => { initializationRef.current = { initialized: false, initializing: false, userId: null }; providerLifetime.current += 1; }, []); const newMessageIndicatorStyle = { position: 'absolute' as const, top: '8px', right: '8px', minWidth: '20px', height: '20px', borderRadius: '10px', backgroundColor: '#ff4d4f', boxShadow: '0 0 0 2px white', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'white', fontSize: '12px', fontWeight: 'bold', padding: '0 4px', zIndex: 1 }; const getDirectThreadCacheKey = useCallback((): string | null => { // Direct threads are Patient<->staff conversations, so the local cache must key on // whichever Patient is actually being displayed (messagingIdentity), not the raw login // identity -- otherwise switching act-as targets would surface a DIFFERENT identity's // cached threads (or pollute one identity's cache bucket with another's) on the next // read/write. See messagingIdentity's definition above. if (!messagingIdentity?.resourceType || !messagingIdentity?.id) { return null; } return directThreadCacheKey(messagingIdentity.resourceType, messagingIdentity.id); }, [messagingIdentity]); // An explicit key is the bucket an action or listing captured when it started; it never follows a newer identity. const readCachedDirectThreads = useCallback((bucket?: string | null): CachedDirectThread[] => { if (typeof window === 'undefined') { return []; } const key = bucket !== undefined ? bucket : getDirectThreadCacheKey(); if (!key) { return []; } try { const raw = authStorageArea()?.getItem(key) ?? null; if (!raw) { return []; } const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed as CachedDirectThread[] : []; } catch (error) { console.error('Failed to read cached direct threads:', error); return []; } }, [getDirectThreadCacheKey]); const writeCachedDirectThreads = useCallback((threads: CachedDirectThread[], bucket?: string | null) => { if (typeof window === 'undefined') { return; } const key = bucket !== undefined ? bucket : getDirectThreadCacheKey(); if (!key) { return; } try { authStorageArea()?.setItem(key, JSON.stringify(threads)); } catch (error) { console.error('Failed to persist cached direct threads:', error); } }, [getDirectThreadCacheKey]); /** * - DROP THE CACHED THREADS THE SERVER NO LONGER LISTS. * * THE DEFECT THIS CLOSES, measured on 2026-08-24: a referral the server had retired kept showing on * the front desk **through a reload and a brand-new tab**. The retire had fired, the row was * `isActive: false`, and `GET /R4/Communication/thread` had stopped returning it - the desk was * rendering this cache, and **nothing ever removed anything from it.** The cache only ever grew. * A coordinator books the patient, the referral correctly reads `Scheduled`, and the queue item they * were told would leave is still sitting there. **A queue nobody trusts is the paper tray we replaced.** * * ONLY EVER CALLED WITH A LIST THAT ACTUALLY ARRIVED, and that is the load-bearing rule. Both * callers below are wrapped in a `try` whose `catch` deliberately continues on cached threads - a * listing that FAILED says nothing about what exists, and reconciling against an empty result would * **wipe the desk on a network blip**. Never move this call into a `catch`, and never call it with * a list you did not get. * * `isInScope` MAKES "AUTHORITATIVE FOR WHAT?" EXPLICIT AT EACH CALL SITE, rather than assuming the * fetch speaks for the whole cache. The staff branch lists POOL threads only, so it may only retire * pool threads; dropping a receptionist's own conversations because a pool listing did not mention * them would be a far worse bug than the one being fixed. */ const reconcileCachedThreads = useCallback(( liveThreadIds: Set, isInScope: (thread: CachedDirectThread) => boolean, bucket?: string | null, ) => { const cached = readCachedDirectThreads(bucket); // The RULE is pure and lives in utils/reconcileThreadCache.ts, so it can be reasoned about // without a service tree; what belongs here is the cache key and the decision of when to apply it. const kept = reconcileThreadCache(cached, liveThreadIds, isInScope); // Written only when something actually left. An unconditional write would rewrite this key on // every inbox load for no reason, and on a shared front-desk machine that is a needless disk // touch of a file holding patient names. if (kept.length !== cached.length) { writeCachedDirectThreads(kept, bucket); } }, [readCachedDirectThreads, writeCachedDirectThreads]); // How each counterparty name read went, for the scope (viewer, acted-as link, practice) that asked: a // refused read is said as withheld and a failed one as unread, never as the record's reference. Kept // only for the live viewing lifetime, so no viewer or practice is shown another's outcome or name. // Every change of scope opens a new lifetime: returning to an earlier scope revives none of its outcomes. const nameLifetime = useRef({ scope: '', id: 0 }); const liveNameLifetime = useCallback(() => { const scope = liveListingScope(); if (nameLifetime.current.scope !== scope) nameLifetime.current = { scope, id: nameLifetime.current.id + 1 }; return nameLifetime.current.id; }, [liveListingScope]); liveNameLifetime(); // An action belongs to the scope, lifetime and cache bucket it started in; anything it finds after those move // is neither cached nor shown, and its caller is told it was retired rather than handed a thread. const liveCacheKey = useRef(null); liveCacheKey.current = getDirectThreadCacheKey(); const liveTrackedId = useRef(null); liveTrackedId.current = messagingIdentity?.id ?? null; // The one comparison every initialization flag and early exit uses: the run answers for what is on screen only // for the same messaging identity, listing scope and viewing lifetime. const runIsCurrent = useCallback((run: { userId: string | null; scope?: string; lifetime?: number }) => ( run.userId === liveTrackedId.current && run.scope === liveListingScope() && run.lifetime === liveNameLifetime() ), [liveListingScope, liveNameLifetime]); const startAction = useCallback((): ActionContext => ({ scope: liveListingScope(), lifetime: liveNameLifetime(), key: liveCacheKey.current, provider: providerLifetime.current, }), [liveListingScope, liveNameLifetime]); const actionCurrent = useCallback((context: ActionContext) => ( providerLifetime.current === context.provider && liveListingScope() === context.scope && liveNameLifetime() === context.lifetime && liveCacheKey.current === context.key ), [liveListingScope, liveNameLifetime]); const [participantNameReads, setParticipantNameReads] = useState<{ lifetime: number; reads: Record }>({ lifetime: 0, reads: {} }); const noteNameRead = useCallback((askedIn: number, id: string, read: NameRead) => { if (liveNameLifetime() !== askedIn) return; setParticipantNameReads((prev) => ({ lifetime: askedIn, reads: { ...(prev.lifetime === askedIn ? prev.reads : {}), [id]: read }, })); }, [liveNameLifetime]); const participantNameRead = useCallback((id: string | undefined): NameRead | undefined => id && participantNameReads.lifetime === liveNameLifetime() ? participantNameReads.reads[id] : undefined, [participantNameReads, liveNameLifetime]); const fetchPractitionerDisplayName = useCallback(async (practitionerId: string): Promise => { try { // The read answers { practitioner, roles }; read as a bare Practitioner it had no name and the id was stamped. const found = await get<{ practitioner?: Practitioner } | Practitioner>(`${PRACTITIONER_API_URL}/Practitioner/${practitionerId}`); const practitioner = 'practitioner' in found ? found.practitioner : found as Practitioner; return { status: 'loaded', name: formatFhirHumanName(practitioner?.name) }; } catch (error) { console.error(`Failed to resolve practitioner display for ${practitionerId}:`, error); return { status: isRefusedRead(error) ? 'refused' : 'failed' }; } }, [get]); const fetchPatientDisplayName = useCallback(async (patientId: string): Promise => { try { const patient = await get(`${PRACTITIONER_API_URL}/Patient/${patientId}`); return { status: 'loaded', name: formatFhirHumanName(patient.name) }; } catch (error) { console.error(`Failed to resolve patient display for ${patientId}:`, error); return { status: isRefusedRead(error) ? 'refused' : 'failed' }; } }, [get]); const enrichReferenceDisplay = useCallback(async (reference?: Reference, askedIn = liveNameLifetime()): Promise => { if (!reference?.reference) { return reference; } const [resourceType, resourceId] = reference.reference.split('/'); if (!resourceId || (resourceType !== 'Practitioner' && resourceType !== 'Patient')) { return reference; } const currentDisplay = reference.display?.trim(); if (currentDisplay && currentDisplay !== resourceId) { return reference; } const read = resourceType === 'Patient' ? await fetchPatientDisplayName(resourceId) : await fetchPractitionerDisplayName(resourceId); noteNameRead(askedIn, resourceId, read); // A name read under an earlier lifetime is never published, so no shell or cache built from it carries it. const resolvedDisplay = read.status === 'loaded' && liveNameLifetime() === askedIn ? read.name || undefined : undefined; if (!resolvedDisplay) { // An id cached as the display by an earlier build is dropped, never carried forward as a name. if (!currentDisplay) return reference; const { display: _staleId, ...withoutDisplay } = reference; return withoutDisplay; } return { ...reference, display: resolvedDisplay }; }, [fetchPractitionerDisplayName, fetchPatientDisplayName, liveNameLifetime, noteNameRead]); const enrichDirectThreadShellRequest = useCallback(async (request: CommunicationRequest, askedIn = liveNameLifetime()): Promise => { const enrichedSender = await enrichReferenceDisplay(request.sender, askedIn); const enrichedRecipient = request.recipient && request.recipient.length > 0 ? await Promise.all(request.recipient.map(async (recipient) => await enrichReferenceDisplay(recipient, askedIn) || recipient)) : request.recipient; // Names from an operation begun in an earlier lifetime are not published, whichever part finished last. if (liveNameLifetime() !== askedIn) { const unnamed = (reference: Reference): Reference => { if (reference.display?.trim() !== reference.reference?.split('/')[1]) return reference; const { display: _staleId, ...withoutDisplay } = reference; return withoutDisplay; }; return { ...request, ...(request.sender ? { sender: unnamed(request.sender) } : {}), ...(request.recipient ? { recipient: request.recipient.map(unnamed) } : {}), }; } return { ...request, ...(enrichedSender ? { sender: enrichedSender } : {}), ...(enrichedRecipient ? { recipient: enrichedRecipient } : {}) }; }, [enrichReferenceDisplay, liveNameLifetime]); const createDirectThreadShellRequest = useCallback((thread: DirectThread): CommunicationRequest => { const authoredOn = thread.lastMessageAt || thread.createdAt || new Date().toISOString(); const patientReference = thread.patientId ? createReferenceObject('Patient', thread.patientId) : undefined; //: this was `thread.staffRole === 'receptionist' ? 'Practitioner': 'Practitioner'` // - a ternary whose branches were identical, so it READ as a decision and made none. Its server // twin was removed when the pool half shipped; this is the client one. A staff account is a // Practitioner whatever role they hold, and a POOL thread has no staff account at all. const staffResourceType = 'Practitioner'; const staffReference = thread.staffAccountId ? createReferenceObject(staffResourceType, thread.staffAccountId) : undefined; // - the shell is the ONLY thing the inbox and the chat pane are handed, so "this is a // conversation with the office, not with a person" has to be carried ON it. Without the marker a // pool thread reads as a direct thread whose counterparty happens to be missing, which is exactly // how it ended up listed as "Unknown" and rendered as no conversation at all. // - AND THE SAME ARGUMENT AGAIN, one layer out. An arriving referral has no patient // and no staff account, so BOTH references above are undefined and the shell alone cannot say // what the row is. Without this marker it renders exactly as pool thread did before // its own marker existed: "Unknown", beside no conversation. if (currentUser?.fhir?.resourceType === 'Patient' || currentUser?.fhir?.resourceType === 'RelatedPerson') { return withReferralSubject(withThreadPoolKey(createLegacyThreadShell( thread.threadId, patientReference, staffReference, thread.organizationId, authoredOn ), thread), thread); } return withReferralSubject(withThreadPoolKey(createLegacyThreadShell( thread.threadId, staffReference, patientReference, thread.organizationId, authoredOn ), thread), thread); }, [createReferenceObject, currentUser]); // `owns` is read when the update is applied: an update queued before its action retired changes nothing. const upsertDirectThreadShellIntoState = useCallback((thread: DirectThread, shellRequest?: CommunicationRequest, owns: () => boolean = () => true) => { const request = shellRequest || createDirectThreadShellRequest(thread); const requestId = request.id as string; if (!requestId) { return request; } if (currentUser?.fhir?.resourceType === 'Practitioner') { setPatientCommunicationRequests(prev => (owns() ? dedupeCommunicationRequests([request, ...prev.filter(req => req.id !== requestId)]) : prev)); } else { setPatientCommunicationRequests(prev => (owns() ? dedupeCommunicationRequests([request, ...prev.filter(req => req.id !== requestId)]) : prev)); } return request; }, [createDirectThreadShellRequest, currentUser]); // Written into the bucket and under the listing scope the caller started in, never ones recaptured now. const cacheDirectThread = useCallback((thread: DirectThread, shellRequest: CommunicationRequest | undefined, origin: { key: string | null; scope: string }) => { const request = shellRequest || createDirectThreadShellRequest(thread); const existing = readCachedDirectThreads(origin.key); const next: CachedDirectThread[] = [ { ...thread, shellRequest: request, cachedAt: new Date().toISOString(), listedIn: origin.scope, }, ...existing.filter(existingThread => existingThread.threadId !== thread.threadId) ]; writeCachedDirectThreads(next, origin.key); return request; }, [createDirectThreadShellRequest, readCachedDirectThreads, writeCachedDirectThreads, get]); const getThreadIdExtension = useCallback((communication: Communication): string | null => { const extensions = communication.extension || []; for (const extension of extensions) { if (!extension?.url || !THREAD_ID_EXTENSION_URLS.includes(extension.url)) { continue; } const typedExtension = extension as ThreadIdExtensionValue; const value = typedExtension.valueString || typedExtension.valueId; if (typeof value === 'string' && value.trim()) { return value.trim(); } } return null; }, []); const getCommunicationThreadId = useCallback((communication: Communication): string | null => { const extensionThreadId = getThreadIdExtension(communication); if (extensionThreadId) { return extensionThreadId; } const basedOnReference = communication.basedOn?.[0]?.reference; if (!basedOnReference) { return null; } if (basedOnReference.startsWith('Thread/')) { return basedOnReference.split('/')[1]; } return extractReferenceId(communication.basedOn?.[0]) as string; }, [extractReferenceId, getThreadIdExtension]); const addOrUpdateThreadIdExtension = useCallback((communication: Communication, threadId: string): Communication => { const extensions = [...(communication.extension || [])]; const existingIndex = extensions.findIndex( (extension) => extension?.url && THREAD_ID_EXTENSION_URLS.includes(extension.url) ); const threadExtension: ThreadIdExtensionValue = { url: THREAD_ID_EXTENSION_URLS[0], valueString: threadId }; if (existingIndex >= 0) { extensions[existingIndex] = threadExtension; } else { extensions.push(threadExtension); } return { ...communication, extension: extensions }; }, []); const getCurrentUserReference = useCallback((): string | null => { if (!currentUser?.fhir?.resourceType || !currentUser?.fhir?.id) { return null; } return `${currentUser.fhir.resourceType}/${currentUser.fhir.id}`; }, [currentUser]); /** Whom this viewer's read receipts name (see receiptReferenceOf); send attribution stays getCurrentUserReference. */ const getReceiptReference = useCallback( (): string | null => receiptReferenceOf(currentUser?.fhir, actingAs), [currentUser, actingAs], ); const getReadReceiptExtensions = useCallback((communication: Communication): ReadReceiptExtension[] => { return (communication.extension || []).filter( (extension): extension is ReadReceiptExtension => !!extension?.url && READ_RECEIPT_EXTENSION_URLS.includes(extension.url) ); }, []); const hasReadReceiptForReference = useCallback(( communication: Communication, recipientReference: string, ): boolean => { const receipts = getReadReceiptExtensions(communication); return receipts.some((receipt) => { const receiptRecipient = receipt.extension?.find( (child) => child.url === 'recipient' )?.valueReference?.reference; const readAt = receipt.extension?.find((child) => child.url === 'readAt')?.valueInstant || receipt.extension?.find((child) => child.url === 'readAt')?.valueDateTime || receipt.extension?.find((child) => child.url === 'readAt')?.valueString; return receiptRecipient === recipientReference && !!readAt; }); }, [getReadReceiptExtensions]); const addReadReceiptForReference = useCallback(( communication: Communication, recipientReference: string, readAt: string, ): Communication => { if (hasReadReceiptForReference(communication, recipientReference)) { return communication; } const extensions = [...(communication.extension || [])]; const receiptExtension: ReadReceiptExtension = { url: READ_RECEIPT_EXTENSION_URLS[0], extension: [ { url: 'recipient', valueReference: { reference: recipientReference } } as ReadReceiptChildExtension, { url: 'readAt', valueInstant: readAt } as ReadReceiptChildExtension ] }; extensions.push(receiptExtension); return { ...communication, extension: extensions }; }, [hasReadReceiptForReference]); const patientUnreadMessages = Array.from(communicationThreads.values()) .filter(thread => patientCommunicationRequests.some(req => req.id === thread.request.id)) .reduce((sum, thread) => sum + thread.unreadCount, 0); const staffUnreadMessages = Array.from(communicationThreads.values()) .filter(thread => staffCommunicationRequests.some(req => req.id === thread.request.id)) .reduce((sum, thread) => sum + thread.unreadCount, 0); const calculateUnreadCount = useCallback((messages: Communication[], threadId: string): number => { const currentUserReference = getCurrentUserReference(); const receiptReference = getReceiptReference(); if (!currentUserReference || !receiptReference) return 0; if (activeThreadId.current === threadId) { return 0; } return messages.filter(msg => { const senderReference = msg.sender?.reference; const isFromOther = senderReference !== currentUserReference && onBehalfOf(msg)?.helperReference !== receiptReference; const isUnread = !hasReadReceiptForReference(msg, receiptReference); return isFromOther && isUnread; }).length; }, [getCurrentUserReference, getReceiptReference, hasReadReceiptForReference]); /** * RE-OPENING A THREAD MUST NOT DISCARD A MESSAGE THE SERVER HAS NOT INDEXED YET. * * MEASURED at the front desk, on a bundle that already carried the send-side half of this fix. A * receptionist replied in the pooled thread; the write returned 201, the message went into state, * and it was **gone from the screen a moment later**: * * POST /R4/Communication 201 -> appended to the thread in state * POST /R4/Communication/thread 200 <- the pool broadcast re-reads the claim * GET /R4/Communication?threadId=... 200 <- six messages, not the new one * and the thread in state is REPLACED * * The claim refresh is innocent and its comment says it is idempotent - which is true of the * CLAIM and false of the MESSAGES. `openFrontDeskThread` and `openDirectThread` rebuild the whole * thread from a fresh search and `set` it, so anything the search has not caught up with is * dropped. HealthLake's search index is eventually consistent, so that is a race the client loses * every time it re-reads immediately after writing. * * SO THE SERVER'S ANSWER STILL WINS FOR EVERYTHING IT RETURNED, and only messages it has never * mentioned survive alongside it. That is the narrow direction: a message we hold and the server * has not yet published is kept; a message the server returns is taken as it comes unless we hold a newer * confirmed version of it, so neither a stale local copy nor a lagging search undoes an edit or a read receipt. * * WHY NOT SIMPLY NOT RE-READ. Every caller of these openers wants the server's answer for a real * reason - the claim, a deep link, a reconnect. The bug is not that they read; it is that reading * threw away what was already known. */ const keepLocalOnlyMessages = useCallback(( fetched: Communication[], existing: CommunicationThread | undefined, ): Communication[] => { if (!existing?.messages?.length) return fetched; const fetchedIds = new Set(fetched.map((message) => message.id).filter(Boolean)); const localOnly = existing.messages.filter((message) => message.id && !fetchedIds.has(message.id)); const heldById = new Map(existing.messages.filter((message) => message.id).map((message) => [message.id, message])); const answered = fetched.map((message) => newerEdition(heldById.get(message.id), message)); const kept = answered.some((message, index) => message !== fetched[index]) ? answered : fetched; return localOnly.length === 0 ? kept : [...kept, ...localOnly]; }, []); const createCommunicationThread = useCallback((request: CommunicationRequest, messages: Communication[], forcedThreadId?: string): CommunicationThread => { // Fetched bundles arrive in arbitrary order (the Communication search has no _sort) - sort chronologically so every consumer can rely on it. const sortedMessages = [...messages].sort((a, b) => new Date(a.sent || a.meta?.lastUpdated || 0).getTime() - new Date(b.sent || b.meta?.lastUpdated || 0).getTime() ); const derivedThreadId = forcedThreadId || sortedMessages.find(message => getCommunicationThreadId(message)) && getCommunicationThreadId(sortedMessages.find(message => getCommunicationThreadId(message)) as Communication) || request.id as string; const threadId = derivedThreadId as string; const unreadCount = calculateUnreadCount(sortedMessages, threadId); const lastActivity = sortedMessages.length > 0 ? new Date(sortedMessages[sortedMessages.length - 1].sent || sortedMessages[sortedMessages.length - 1].meta?.lastUpdated || new Date()) : new Date(request.authoredOn || request.meta?.lastUpdated || new Date()); return { request, messages: sortedMessages, unreadCount, lastActivity, threadId }; }, [calculateUnreadCount, getCommunicationThreadId]); const getRequestType = useCallback((request: CommunicationRequest): 'patient' | 'staff' | null => { if (currentUser?.fhir?.resourceType === 'Patient' || currentUser?.fhir?.resourceType === 'RelatedPerson') { return 'patient'; } return classifyCommunicationRequest(request); }, [currentUser]); // The listing scope the published rows belong to. const shownScope = useRef(''); const renderListingScope = liveListingScope(); useEffect(() => { // Tracks messagingIdentity (not the raw login id) so that when it changes -- an act-as // switch resolving patientState to the subject, or reverting back to self -- this clears the // previous identity's requests/threads and drops `initialized`, so // initializeCommunicationData refetches for the newly-displayed patient instead of silently // keeping stale data around under a still-"initialized" flag keyed to the wrong identity. // Login/logout (currentUser itself going away) still fully resets exactly as before. const trackedId = messagingIdentity?.id ?? null; if (!currentUser?.fhir) { setPatientCommunicationRequests([]); setStaffCommunicationRequests([]); setCommunicationThreads(new Map()); setCommunicationDataLoaded(false); activeThreadId.current = null; initializationRef.current = { initialized: false, initializing: false, userId: null }; return; } if (initializationRef.current.userId !== trackedId) { setPatientCommunicationRequests([]); setStaffCommunicationRequests([]); setCommunicationThreads(new Map()); // The previous chart's answer is not this one's. Without this, moving between two patients // would leave the flag true and a consumer would read the old identity's emptiness as this // identity's zero - the same defect one chart to the left. setCommunicationDataLoaded(false); activeThreadId.current = null; initializationRef.current = { initialized: false, initializing: false, userId: trackedId }; } else if (shownScope.current !== renderListingScope) { // Same identity, another practice or link: its rows are not this scope's. The run is left alone, since the // manager (a child, so its effect ran first) may already have started this scope's own read. setPatientCommunicationRequests([]); setStaffCommunicationRequests([]); setCommunicationThreads(new Map()); setCommunicationDataLoaded(false); activeThreadId.current = null; } shownScope.current = renderListingScope; }, [currentUser, messagingIdentity, renderListingScope]); const fetchCommunicationRequestsForPractitioner = useCallback(async (practitioner: Practitioner, patient?: Patient): Promise> => { const senderParams = new URLSearchParams(); senderParams.append('_count', searchPageSize('100')); senderParams.append('sender', `Practitioner/${practitioner.id}`); if (patient) { senderParams.append('recipient', `Patient/${patient.id}`); } const recipientParams = new URLSearchParams(); recipientParams.append('_count', searchPageSize('100')); recipientParams.append('recipient', `Practitioner/${practitioner.id}`); // - TWO reads, and either one going short drops messages out of a clinician's inbox. // The sender half is worse than it looks: it is the practitioner's OWN sent messages, so a page // boundary makes it look like a message they sent was never sent. const [senderBundle, recipientBundle] = await Promise.all([ fetchAllPagesAsBundle((pageParam) => { if (pageParam) senderParams.set('page', pageParam); return get>(`${API_URL}/CommunicationRequest?${senderParams.toString()}`); }), fetchAllPagesAsBundle((pageParam) => { if (pageParam) recipientParams.set('page', pageParam); return get>(`${API_URL}/CommunicationRequest?${recipientParams.toString()}`); }), ]); const allEntries = [ ...(senderBundle.entry || []), ...(recipientBundle.entry || []) ]; const uniqueEntries = allEntries.filter((entry, index, self) => index === self.findIndex(e => e.resource?.id === entry.resource?.id)); // The merged bundle is hand-built, so truncation has to be carried across DELIBERATELY - the // two halves' link arrays are discarded right here. Either half stopping early means this inbox // is short, and a caller asking `isSearchTruncated` must get a true answer. const truncated = isSearchTruncated(senderBundle) || isSearchTruncated(recipientBundle); return { resourceType: 'Bundle', type: 'searchset', total: uniqueEntries.length, entry: uniqueEntries, ...(truncated ? { link: [{ relation: SEARCH_TRUNCATED_RELATION, url: '' }] } : {}), }; }, [get]); const fetchCommunicationRequestsForPatient = useCallback(async (patient: Patient): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('recipient', `Patient/${patient.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/CommunicationRequest?${params.toString()}`); }); }, [get]); const fetchCommunicationRequestsForRelatedPerson = useCallback(async (relatedPerson: RelatedPerson): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('recipient', `RelatedPerson/${relatedPerson.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/CommunicationRequest?${params.toString()}`); }); }, [get]); const fetchCommunication = useCallback(async (communicationRequest: CommunicationRequest): Promise> => { // A thread shell's id is its thread id, which no CommunicationRequest can carry; its thread read is the whole answer. if (!FHIR_ID.test(communicationRequest.id ?? '')) return { resourceType: 'Bundle', type: 'searchset', total: 0, entry: [] }; return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('based-on', `CommunicationRequest/${communicationRequest.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Communication?${params.toString()}`); }); }, [get]); // `request`: the reader's admission and cancellation, applied to every page; a plain read without one. const fetchCommunicationByThread = useCallback(async (threadId: string, request: { admit?: () => void; signal?: AbortSignal } = {}): Promise> => { return fetchAllPagesAsBundle((pageParam) => { request.admit?.(); const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('threadId', threadId); // Asks the server for just-sent messages its search has not indexed yet; safe only because this // reader keeps one copy per id across pages (oneCopyPerThreadMessage below). params.append('held', '1'); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Communication?${params.toString()}`, request); }).then(oneCopyPerThreadMessage); }, [get]); /** * (B30) - THE GATE ABOVE THIS ONE GUARDS A DIFFERENT ROUTE. * * Measured 2026-08-17: `hr1` and `marketing1` still fired a denied `GET /R4/Communication/thread` * on their landing screen. `useCommunicationManager` DOES hold the whole load until capability * answers - but it asks about **`/CommunicationRequest`**, and server half made that route * **200** for both roles. So the gate opens correctly, `initializeCommunicationData` runs, and the * SECOND route it calls is the one they are denied. * One gate cannot speak for two routes. This asks about the route it actually calls. * * `=== false` ONLY. `mayCall` returns `undefined` for NOT KNOWN - a capability endpoint that is * slow, down or not deployed yet - and treating that as "no" would empty the office inbox for every * staff member the moment it hiccuped. * A withheld listing now throws, so the screen says it could not show messages instead of saying there are none. */ const fetchDirectThreads = useCallback(async (): Promise<{ threads: DirectThread[]; complete: boolean }> => { // A WITHHELD REQUEST IS A REFUSAL, NOT AN EMPTY INBOX: returning [] here left the patient // portal saying it had no messages over one the practice had just emailed them about. The // throw makes no request and reaches the screen the same way a failed listing does. if (mayCall('GET', '/Communication/thread') === false) { throw new Error('Thread listing withheld: this caller may not list threads'); } // Parsed strictly: a body without a list is a failed read, never an empty inbox. An absent `complete` is a // server from before the flag, whose answers were whole; any other non-boolean is malformed. const response = await get<{ threads?: unknown; complete?: unknown }>(`${API_URL}/Communication/thread`); if (!Array.isArray(response?.threads)) throw new Error('Malformed thread listing: no thread list'); if (response.complete !== undefined && typeof response.complete !== 'boolean') throw new Error('Malformed thread listing: completeness'); return { threads: response.threads as DirectThread[], complete: response.complete !== false }; }, [get, mayCall]); const createCommunicationRequest = useCallback(async (request: CommunicationRequest): Promise => { return post(`${API_URL}/CommunicationRequest`, request); }, [post]); const updateCommunicationRequest = useCallback(async (request: CommunicationRequest): Promise => { return put(`${API_URL}/CommunicationRequest/${request.id}`, request); }, [put]); const createCommunication = useCallback(async (communication: Communication, send?: SendOperation): Promise => { return post(`${API_URL}/Communication`, communication, { admit: send?.admit }); }, [post]); // Settles this sender's own send whose answer was lost: whether it was sent, judged by the server alone. const closeSendOperation = useCallback(async (operationId: string, admit?: RequestAdmission): Promise => { return post(`${API_URL}/send-operation/${encodeURIComponent(operationId)}/close`, {}, { admit }); }, [post]); const resolveDirectThread = useCallback(async ( patientId: string, targetStaffAccountId: string, targetStaffRole: 'practitioner' | 'receptionist', organizationId?: string ): Promise => { const body = { patientId, targetStaffAccountId, targetStaffRole, ...(organizationId ? { organizationId } : {}) }; return post(`${API_URL}/Communication/thread`, body); }, [post]); /** * - the FRONT DESK, not a named receptionist. * * A patient writes to the office; the office is a role, not a person. The server half is * already deployed and keyed on `pool:front-desk`, and membership is resolved BY ROLE at read * time - never enumerated as participant rows, because an enumerated list is a snapshot that * keeps a leaver authorised and never admits a new hire. * `targetPool` INSTEAD OF `targetStaffAccountId`, never both: the endpoint requires exactly * one, and sending a sentinel account id was the design that was rejected. */ const FRONT_DESK_POOL = 'front-desk'; /** * The soft claim on a front-desk conversation - S5, the client half. * * THE SERVER GRANTS IT, NOT US. Two receptionists who start typing in the same second would both * believe they held it if the client decided; a conditional write decides instead, and the loser is * told WHO won so their screen can say "Alice is replying" rather than silently doing nothing. * * IT IS ADVISORY. Holding the claim does not gate sending - the send path is untouched - because a * hard lock cannot tell "Alice is mid-sentence" from "Alice's browser crashed", and at a real front * desk the second happens often enough to generate more phone calls than the duplicate replies it * would prevent. So "Reply anyway" needs nothing from the server. * * A 409 IS NOT AN ERROR HERE, IT IS AN ANSWER: it carries the current holder. It is returned rather * than thrown so a caller cannot treat "someone else has it" as "the request failed". */ const claimFrontDeskThread = useCallback(async (threadId: string): Promise => { try { return await post( `${API_URL}/Communication/thread/${encodeURIComponent(threadId)}/claim`, {}, ); } catch (error) { const conflict = readClaimConflict(error); if (conflict) return conflict; throw error; } }, [post]); const releaseFrontDeskThread = useCallback(async (threadId: string): Promise => { try { return await post( `${API_URL}/Communication/thread/${encodeURIComponent(threadId)}/release`, {}, ); } catch (error) { const conflict = readClaimConflict(error); if (conflict) return conflict; throw error; } }, [post]); const resolveFrontDeskThread = useCallback(async ( patientId: string, organizationId?: string, // The office aside is the same call against a different pool. Defaulted, so every existing // caller resolves the patient-facing thread exactly as before. targetPool: string = FRONT_DESK_POOL ): Promise => { const body = { patientId, targetPool, ...(organizationId ? { organizationId } : {}) }; return post(`${API_URL}/Communication/thread`, body); }, [post]); // A note about a patient for whoever is on the desk. Its own route, open to every staff role, // because handing the desk something is not working it; the server picks the aside pool. const resolveFrontDeskNoteThread = useCallback( (patientId: string): Promise => post(`${API_URL}/front-desk-note`, { patientId }), [post], ); /** * - OPEN AN ARRIVING REFERRAL IN THE FRONT-DESK QUEUE. * * IT MIRRORS `openFrontDeskThread` DELIBERATELY, INCLUDING THE POST. The point of re-resolving * rather than reading a cached claim is the one already argued: the request is idempotent * (the same id comes back) and **the claim is the SERVER's answer, expiry included** - a second * client-side notion of "is it still claimed" is exactly what that card refused to build. * * AND IT FETCHES NO MESSAGES, which is the one line where it differs. A referral item holds none - * the ruling makes the reply affordance absent, so there is nothing to load - and asking anyway would * be a round trip whose only possible answer is an empty bundle. */ const openFrontDeskReferralItem = useCallback(async ( referralId: string, organizationId?: string ): Promise => { const context = startAction(); const owns = () => actionCurrent(context); const thread = await post(`${API_URL}/Communication/thread`, { referralId, targetPool: FRONT_DESK_POOL, ...(organizationId ? { organizationId } : {}) }); if (!owns()) throw new SaveRetiredError(); const normalizedThread: DirectThread = { ...thread, organizationId: thread.organizationId || organizationId, // Never defaulted. A pool thread has no owner and a referral has no patient; inventing // either is the sentinel-account mistake arriving through the back door. staffAccountId: thread.staffAccountId ?? null, staffRole: thread.staffRole ?? null, threadType: 'pool', poolKey: thread.poolKey ?? FRONT_DESK_POOL, subjectType: 'referral', subjectId: thread.subjectId || referralId, }; const baseShell = createDirectThreadShellRequest(normalizedThread); const shell = await enrichDirectThreadShellRequest(baseShell); if (!owns()) throw new SaveRetiredError(); cacheDirectThread(normalizedThread, shell, context); upsertDirectThreadShellIntoState(normalizedThread, shell, owns); const claimSource = thread as Partial; const commThread: CommunicationThread = { ...createCommunicationThread(shell, [], normalizedThread.threadId), claim: { threadId: normalizedThread.threadId, claimed: Boolean(claimSource.claimed), claimedByAccountId: claimSource.claimedByAccountId ?? null, claimedAt: claimSource.claimedAt ?? null, claimExpiresAt: claimSource.claimExpiresAt ?? null, }, }; setCommunicationThreads(prev => { if (!owns()) return prev; const updated = new Map(prev); updated.set(thread.threadId, commThread); return updated; }); if (!owns()) throw new SaveRetiredError(); return commThread; }, [cacheDirectThread, createCommunicationThread, createDirectThreadShellRequest, enrichDirectThreadShellRequest, post, upsertDirectThreadShellIntoState, startAction, actionCurrent]); const openDirectThread = useCallback(async ( patientId: string, targetStaffAccountId: string, targetStaffRole: 'practitioner' | 'receptionist', organizationId?: string ): Promise => { const context = startAction(); const owns = () => actionCurrent(context); const thread = await resolveDirectThread(patientId, targetStaffAccountId, targetStaffRole, organizationId); if (!owns()) throw new SaveRetiredError(); const normalizedThread: DirectThread = { ...thread, patientId: thread.patientId || patientId, organizationId: thread.organizationId || organizationId, staffAccountId: thread.staffAccountId || targetStaffAccountId, staffRole: thread.staffRole || targetStaffRole }; // Independent reads, so neither waits on the other. const [bundle, shell] = await Promise.all([ fetchCommunicationByThread(normalizedThread.threadId), enrichDirectThreadShellRequest(createDirectThreadShellRequest(normalizedThread)), ]); const messages: Communication[] = unpackBundle(bundle); if (!owns()) throw new SaveRetiredError(); cacheDirectThread(normalizedThread, shell, context); upsertDirectThreadShellIntoState(normalizedThread, shell, owns); // The server's answer, plus anything we hold that it has not published yet - see // `keepLocalOnlyMessages`. Built inside the setter so the merge reads the CURRENT state. let commThread = createCommunicationThread(shell, messages, normalizedThread.threadId); setCommunicationThreads(prev => { if (!owns()) return prev; const updated = new Map(prev); commThread = createCommunicationThread( shell, keepLocalOnlyMessages(messages, prev.get(thread.threadId)), normalizedThread.threadId, ); updated.set(thread.threadId, commThread); return updated; }); if (!owns()) throw new SaveRetiredError(); return commThread; }, [cacheDirectThread, createCommunicationThread, createDirectThreadShellRequest, enrichDirectThreadShellRequest, fetchCommunicationByThread, keepLocalOnlyMessages, resolveDirectThread, unpackBundle, upsertDirectThreadShellIntoState, startAction, actionCurrent]); const openFrontDeskThread = useCallback(async ( patientId: string, organizationId?: string, // A "Staff only" row is the office aside about this patient; without it the patient's own thread opens. pool?: string ): Promise => { const context = startAction(); const owns = () => actionCurrent(context); const thread = await resolveFrontDeskThread(patientId, organizationId, pool); if (!owns()) throw new SaveRetiredError(); const normalizedThread: DirectThread = { ...thread, patientId: thread.patientId || patientId, organizationId: thread.organizationId || organizationId, // Deliberately NOT defaulted to anything. A pool thread has no owner, and inventing // one here would be the sentinel-account mistake arriving through the back door. staffAccountId: thread.staffAccountId ?? null, staffRole: thread.staffRole ?? null, threadType: 'pool', poolKey: thread.poolKey ?? pool ?? FRONT_DESK_POOL }; // Independent reads, so neither waits on the other. const [bundle, shell] = await Promise.all([ fetchCommunicationByThread(normalizedThread.threadId), enrichDirectThreadShellRequest(createDirectThreadShellRequest(normalizedThread)), ]); const messages: Communication[] = unpackBundle(bundle); if (!owns()) throw new SaveRetiredError(); cacheDirectThread(normalizedThread, shell, context); upsertDirectThreadShellIntoState(normalizedThread, shell, owns); // S5 - carry the claim the SERVER already decided, so the strip shows who is answering // the moment the conversation opens rather than a beat later. // Read off the response, never computed here: `claimed` already accounts for the expiry, and a // client that re-derived it would disagree with the server about whether Alice still holds it. const claimSource = thread as Partial; const claim = { threadId: normalizedThread.threadId, claimed: Boolean(claimSource.claimed), claimedByAccountId: claimSource.claimedByAccountId ?? null, claimedAt: claimSource.claimedAt ?? null, claimExpiresAt: claimSource.claimExpiresAt ?? null, }; // THE CLAIM REFRESH IS WHY THIS MATTERS MOST HERE. The pool broadcast re-opens the thread just // to re-read the claim, and doing so used to replace the messages with a search that had not // yet indexed the reply the desk had just sent - so the sender's own message vanished a moment // after appearing. Built inside the setter so the merge reads the CURRENT state. let commThread: CommunicationThread = { ...createCommunicationThread(shell, messages, normalizedThread.threadId), claim, }; setCommunicationThreads(prev => { if (!owns()) return prev; const updated = new Map(prev); commThread = { ...createCommunicationThread( shell, keepLocalOnlyMessages(messages, prev.get(thread.threadId)), normalizedThread.threadId, ), claim, }; updated.set(thread.threadId, commThread); return updated; }); if (!owns()) throw new SaveRetiredError(); return commThread; }, [cacheDirectThread, createCommunicationThread, createDirectThreadShellRequest, enrichDirectThreadShellRequest, fetchCommunicationByThread, keepLocalOnlyMessages, resolveFrontDeskThread, unpackBundle, upsertDirectThreadShellIntoState, startAction, actionCurrent]); // With a transport, every PUT is admitted by it right before it is sent, and one it stops is left out of the answer. const saveCommunicationBundle = useCallback(async (resources: Communication[], transport?: SaveTransport) => { if (!resources || resources.length === 0) { return []; } const updatePromises = resources.map((resource: Communication) => put(`${API_URL}/Communication/${resource.id}`, resource, transport ? { admit: transport.admit, signal: transport.signal, onSend: transport.onSend, dispatch: transport.dispatch } : {}) .catch(error => { console.error(`Error updating Communication/${resource.id}:`, error); return null; }) ); const results = await Promise.all(updatePromises); return results.filter(result => result !== null) as Communication[]; }, [put]); // Each line's current stored edition, every GET admitted and cancelled by `transport`. A line whose read fails, is // refused or stopped is left out: its absence proves nothing about what is stored. const readCommunications = useCallback(async (ids: string[], transport?: SaveTransport): Promise => { const reads = ids.filter((id) => FHIR_ID.test(id)).map((id) => get(`${API_URL}/Communication/${id}`, transport ? { admit: transport.admit, signal: transport.signal } : {}) .catch((error) => { console.error(`Error reading Communication/${id}:`, error); return null; })); return (await Promise.all(reads)).filter((read): read is Communication => read !== null); }, [get]); const initializeCommunicationData = useCallback(async (force = false): Promise => { // The login-gate stays on the raw currentUser (never patientState) -- this must never wait // on PatientService resolving anything, or an unauthenticated/pre-auth render could trip the // app-provider auth-gate reload loop (reference-app-provider-auth-gate.md). const currentUserId = currentUser?.fhir?.id; if (!currentUser?.fhir || !currentUserId) { return; } // But the "have we already initialized for this identity" tracking uses messagingIdentity, // mirroring the reset effect above -- so a Patient login whose patientState later resolves // from self to an act-as subject (or back) is treated as a genuinely different identity and // refetches, instead of being skipped because the raw login id never changed. const trackedId = messagingIdentity?.id ?? null; if (!messagingIdentity) { return; } // A refresh asked for by a live notice must actually refetch: without this the guard // below turned every pool ping into a no-op and the desk sat on stale threads. // Initialized means initialized for this identity, listing scope and lifetime; another scope's run, settled or // still out, neither answers for this one nor stops it. const previous = initializationRef.current; const continuing = runIsCurrent(previous); if (!force && continuing && previous.initialized) { return; } if (continuing && previous.initializing) { // Not a second load beside it, and not a lost one either: the running load reads again once it settles. if (force) previous.catchUp = true; return; } // Adopted before the first await, as a new object: a retired run's result, catch or finally touches only its // own run. A forced reread of the same scope keeps the list it already has on screen. const requestScope = liveListingScope(); const runLifetime = liveNameLifetime(); const run: typeof previous = { initialized: continuing && previous.initialized, initializing: true, userId: trackedId, scope: requestScope, lifetime: runLifetime, }; initializationRef.current = run; const runCacheKey = liveCacheKey.current; const publishListing = (state: ThreadListingState) => setThreadListing({ scope: requestScope, lifetime: runLifetime, state }); // A run that outlived its identity or viewing lifetime publishes and caches nothing, names included. const runRetired = () => initializationRef.current !== run || liveNameLifetime() !== runLifetime; try { let patientRequests: CommunicationRequest[] = []; let staffRequests: CommunicationRequest[] = []; if (currentUser.fhir.resourceType === 'Patient') { // messagingIdentity (not currentUser.fhir): fetches the act-as SUBJECT's own threads // while switched in, and the caller's own threads otherwise. This is the actual fix // for the reported bug -- previously this always fetched the caller's own thread. const bundle: Bundle = await fetchCommunicationRequestsForPatient(messagingIdentity as Patient); patientRequests = unpackBundle(bundle); } else if (currentUser.fhir.resourceType === 'RelatedPerson') { const bundle: Bundle = await fetchCommunicationRequestsForRelatedPerson(currentUser.fhir as RelatedPerson); patientRequests = unpackBundle(bundle); } else if (currentUser.fhir.resourceType === 'Practitioner') { const bundle: Bundle = await fetchCommunicationRequestsForPractitioner(currentUser.fhir as Practitioner); const allRequests = unpackBundle(bundle); const practitionerIds = Array.from(new Set( allRequests.flatMap((request) => getStaffParticipantIds(request)) )); const practitionerValidationResults = await Promise.all( practitionerIds.map(async (practitionerId) => { try { await get(`${PRACTITIONER_API_URL}/Practitioner/${practitionerId}`); return practitionerId; } catch (error) { // A 404 is "not there" - a dangling reference, and dropping it is what stops // a broken row. Any OTHER failure (5xx, network, throttle) is transient, and // dropping on one would silently hide a real conversation from the list, so // the participant is KEPT rather than let a blip remove a thread. // // A 403 IS NOT "NOT THERE". IT IS "I MAY NOT LOOK", AND IT USED TO DELETE // THE CONVERSATION. This branch read 403 as *exists, but no role in this org* // and dropped the reference; `hasOnlyValidStaffParticipants` below keeps a // thread only if EVERY participant survived, so one refused lookup removed the // whole thread from the inbox. // // MEASURED at Great River: a receptionist wrote to a Billing user, the server // returned that CommunicationRequest naming both of them, and the Billing // user's screen said "Inbox (0) - No conversations yet". A thread deleted from // an inbox is the strongest form of empty data there is: the reader is not told // a refusal happened, they are told nothing exists - about their own // correspondence, to the one person who cannot be misled by the truth. // // AND THE FAILURE SCALES WITH HOW LITTLE PERMISSION YOU HOLD. A caller who may // not read the staff directory at all gets 403 for every colleague, so this // did not degrade - it emptied the inbox completely. // // There is no confidentiality argument on the other side: the thread is // addressed to them and somebody chose to write to them. Withholding the name // protects nothing and costs them the message. const status = error instanceof HttpError ? error.status : undefined; if (status === 404) { console.warn(`Dropping unresolvable Practitioner reference in CommunicationRequest list: Practitioner/${practitionerId} (HTTP 404)`, error); return null; } if (status === 403) { console.warn(`Keeping Practitioner/${practitionerId} in the CommunicationRequest list: the caller may not read them (HTTP 403), which is not the same as the reference being invalid`, error); return practitionerId; } console.warn(`Could not validate Practitioner/${practitionerId} (transient error) -- keeping it in the list`, error); return practitionerId; } }) ); const validPractitionerIds = new Set( practitionerValidationResults.filter((id): id is string => !!id) ); const hasOnlyValidStaffParticipants = (request: CommunicationRequest): boolean => { const participants = getCommunicationRequestParticipants(request); return participants.length > 0 && participants.every((participant) => { const resourceType = getReferenceResourceType(participant); if (resourceType === 'Practitioner') { const practitionerId = getReferenceResourceId(participant); return !!practitionerId && validPractitionerIds.has(practitionerId); } return resourceType === 'PractitionerRole'; }); }; const rawPatientRequests = allRequests.filter((request) => classifyCommunicationRequest(request) === 'patient'); patientRequests = await Promise.all(rawPatientRequests.map((req) => enrichDirectThreadShellRequest(req, runLifetime))); staffRequests = allRequests.filter((request) => classifyCommunicationRequest(request) === 'staff' && hasOnlyValidStaffParticipants(request) ); } // From here every listing outcome, cache write and reconciliation belongs to this run's lifetime. if (runRetired()) return; const listsOwnThreads = currentUser.fhir.resourceType === 'Patient' || currentUser.fhir.resourceType === 'RelatedPerson'; if (listsOwnThreads && actingAs && !wpOrganization?.current?.id) { // Acting for someone with no practice selected, there is nothing to scope a listing to and // the server refuses an unscoped one, so none is asked for. The subject's threads that // arrive as requests above still load; nothing here is read as an empty inbox. publishListing('pending'); // With no listing to be refused, the helper's own link says whether messaging was withheld. const key = liveListingScope(); const link = actingAs.relatedPersonId; const seq = ++grantReadSeq.current; const settled = () => seq === grantReadSeq.current && liveListingScope() === key && !runRetired(); const unknown = () => { setListingRefusal((shown) => (shown?.key === key ? null : shown)); setNeutralGrant({ key, state: 'unknown' }); publishListing('unknown'); }; setNeutralGrant({ key, state: 'pending' }); void readMyProxies(get, undefined, currentUser.fhir.id) .then((rows) => { if (!settled()) return; const allowMessaging = rows.find((row) => row.relatedPersonId === link)?.allowMessaging; if (allowMessaging === false) { reconcileCachedThreads(new Set(), () => true, runCacheKey); setNeutralGrant(null); setListingRefusal({ key, messagingGranted: false }); publishListing('refused'); } else if (allowMessaging === true) { setNeutralGrant(null); setListingRefusal((shown) => (shown?.key === key ? null : shown)); publishListing('complete'); } else { unknown(); } }) .catch(() => { if (settled()) unknown(); }); } else if (listsOwnThreads) { const askedUnder = requestScope; try { const { threads: directThreads, complete } = await fetchDirectThreads(); if (runRetired()) return; const live = directThreads.filter(thread => !!thread.threadId && thread.isActive !== false); live.forEach(thread => cacheDirectThread(thread, undefined, { key: runCacheKey, scope: askedUnder })); // - and retire what the server no longer lists. This branch asks for EVERY // direct thread, so a COMPLETE answer is authoritative for what this scope listed. A partial one // retires nothing: an unlisted thread may be one the server could not read this time. if (complete) { reconcileCachedThreads( new Set(live.map(thread => thread.threadId as string)), (thread) => thread.listedIn === askedUnder, runCacheKey, ); } publishListing(complete ? 'complete' : 'partial'); if (liveListingScope() === askedUnder) setListingRefusal(null); } catch (error) { if (runRetired()) return; // An answer asked under another viewer, link or practice says nothing about this one. const inScope = liveListingScope() === askedUnder; publishListing(inScope && actingAs && isRefusedRead(error) ? 'refused' : 'failed'); if (inScope && actingAs && isRefusedRead(error)) { // Nothing of an inbox this helper may not read is kept on screen. reconcileCachedThreads(new Set(), () => true, runCacheKey); const key = askedUnder; const link = actingAs.relatedPersonId; setListingRefusal({ key }); const seq = ++grantReadSeq.current; void readMyProxies(get, undefined, currentUser.fhir.id) .then((rows) => { const messagingGranted = rows.find((row) => row.relatedPersonId === link)?.allowMessaging; if (seq !== grantReadSeq.current || liveListingScope() !== key || runRetired()) return; setListingRefusal((shown) => (shown?.key === key ? { key, messagingGranted } : shown)); }) .catch(() => undefined); } else if (inScope) { setListingRefusal(null); } // NO RECONCILE HERE, deliberately. A failed listing says nothing about what exists, // and treating it as "the server returned nothing" would empty the inbox on a blip. console.warn('Failed to list direct threads; continuing with locally cached threads only.', error); } } else { // - WITHOUT THIS, THE FRONT DESK IS UNREACHABLE FROM THE CLIENT. This listing // call was gated to Patient/RelatedPerson, so a STAFF caller never asked for their threads // at all - and the server's pool inbox (which returns a practice's front-desk threads to a // receptionist, and to nobody else) was deployed and never requested. Built and never // called, on both sides of one feature. // // POOL THREADS ONLY, and that is what makes this safe to add rather than a duplication // risk: a receptionist's OWN direct threads already arrive above as CommunicationRequests, // while a pool thread has no CommunicationRequest anywhere - the shell is minted on the // client. So this adds exactly the conversations that had no other way in, and can add a // second copy of nothing. // // Non-fatal by design, like the branch above: a staff member whose pool listing fails // still gets every patient conversation they are a named participant of. Losing the office // inbox is bad; losing the whole messages page because the office inbox failed is worse. // // Withheld is not failed here: colleague chat needs no messages grant // and arrives above as CommunicationRequests, so a role refused this listing simply has no // pool or patient threads, and any it cached earlier are retired. if (mayCall('GET', '/Communication/thread') === false) { reconcileCachedThreads( new Set(), (thread) => STAFF_INBOX_THREAD_TYPES.includes(thread.threadType as (typeof STAFF_INBOX_THREAD_TYPES)[number]), runCacheKey, ); publishListing('complete'); } else try { const { threads: listedThreads, complete } = await fetchDirectThreads(); if (runRetired()) return; // AND THE CALLER'S OWN PATIENT THREADS, WHICH THIS FILTER USED TO DROP ON THE FLOOR. // // The reasoning recorded above - that a staff member's own direct threads already // arrive as CommunicationRequests, so keeping pool threads only cannot duplicate // anything - is true of a thread STAFF started and false of a thread the PATIENT // started. The server mints a patient-initiated thread in the thread table alone, with // `legacyCommunicationRequestId` null and no CommunicationRequest anywhere behind it. // So the CommunicationRequest search returns nothing for it, this filter discarded it, // and the conversation reached no reader on this side at all: the patient saw a // healthy thread with their messages in it, and the clinician's inbox said nobody had // written to them. Nothing errored on either side, which is why it survived - an empty // inbox and a broken one render identically, and the empty reading is the reassuring // one. // // Measured against staging before changing anything: the server returned this // practitioner's two direct threads and their encounter thread, both direct threads // carrying an active participant row for them, while both halves of the // CommunicationRequest search came back as empty searchsets. Their OWN thread was // missing from the inbox too, so the assumption did not hold even in the case it was // written for. // // Encounter room threads are deliberately still excluded. They belong to the encounter // screen, which lists them itself; this is the Messages inbox, and putting them here // would add a surface rather than repair one. const live = listedThreads.filter(belongsInStaffInbox); live.forEach(thread => cacheDirectThread(thread, undefined, { key: runCacheKey, scope: requestScope })); /* * - AND A RETIRED POOL THREAD LEAVES THE DESK. * * This is where the referral queue's retire became visible. The server stops listing * a retired thread; without this the desk kept rendering it from cache, through a * reload and a fresh tab, until the key was deleted by hand. * * SCOPED TO EXACTLY WHAT THIS BRANCH CACHES, which is the property that matters and the * reason the predicate moved when the filter above did. Retiring on a wider scope than * you cache deletes live conversations to fix a stale one; retiring on a narrower scope * leaves rows nothing will ever clear. The two now name the same pair of thread types, * so neither can drift without the other. * * Safe to retire a direct thread here because the server lists a staff member's own * threads as well as the patient-started ones - the thread this branch was repaired for * and the practitioner's own thread beside it both came back in the same answer, so this * listing is authoritative for both and not merely for the pool. * * A legacy entry with no marker is still left alone, which under-removes rather than * over-removes - the recoverable direction. * * Only a COMPLETE answer retires, and only what this scope listed: a partial one may be missing exactly the * thread it would remove, and practice B's answer is no proof about practice A's rows. */ if (complete) { reconcileCachedThreads( new Set(live.map(thread => thread.threadId as string)), (thread) => thread.listedIn === requestScope && STAFF_INBOX_THREAD_TYPES.includes(thread.threadType as (typeof STAFF_INBOX_THREAD_TYPES)[number]), runCacheKey, ); } publishListing(complete ? 'complete' : 'partial'); } catch (error) { if (runRetired()) return; publishListing('failed'); // NO RECONCILE HERE - see the sibling branch above. A failed pool listing must never // be read as "the practice has no pool threads". console.warn('Failed to list front-desk pool threads; the rest of the inbox is unaffected.', error); } } // Retained is not shown: only a thread this scope listed or opened becomes a row here. Another practice's // entry, or one cached before provenance existed, waits in storage until a listing here names it. const storedDirectThreads = readCachedDirectThreads(runCacheKey); const cachedDirectThreads = storedDirectThreads.filter((thread) => thread.listedIn === requestScope); const cachedShellRequests = (await Promise.all( cachedDirectThreads.map(async (thread) => { // - RE-STAMPED, never trusted. A shell cached by an earlier build carries no // pool marker at all, and one cached by a later build could carry a stale one; both are // client state. The DirectThread beside it came from the server, so the marker is // re-derived from that on every load, in both directions. const baseRequest = withReferralSubject( withThreadPoolKey( thread.shellRequest || createDirectThreadShellRequest(thread), thread, ), thread, ); return await enrichDirectThreadShellRequest(baseRequest, runLifetime); }) )).filter((request): request is CommunicationRequest => !!request?.id); if (cachedShellRequests.length > 0 && !runRetired()) { const refreshed = new Map(cachedShellRequests.map((request) => [request.id as string, request])); const refreshedCachedThreads: CachedDirectThread[] = readCachedDirectThreads(runCacheKey).map((thread) => { const shellRequest = thread.listedIn === requestScope ? refreshed.get(thread.threadId as string) : undefined; return shellRequest ? { ...thread, shellRequest, cachedAt: new Date().toISOString() } : thread; }); writeCachedDirectThreads(refreshedCachedThreads, runCacheKey); } // A THREAD THAT ALSO HAS A CommunicationRequest MUST NOT ARRIVE TWICE. // // The dedupe below matches on `id` alone, and these two ids are different by construction: a // shell is keyed by its threadId, the real request by its FHIR id. So a conversation reachable // down both paths would render as two rows rather than one, and the staff inbox now walks both // paths where it used to walk one. // // Dropped only when the real request is ACTUALLY PRESENT, never merely because the thread // claims a legacy id. Suppressing the shell on the claim alone would re-lose exactly the // conversation this branch was repaired for, in the case where the request cannot be fetched - // trading a visible duplicate for another silent disappearance, which is the worse direction. // // Nothing on staging carries a legacy id today. That is a fact about the data, not a property // of the code, and it is not what the inbox should rest on. const shellIdsSupersededByRealRequests = new Set( cachedDirectThreads .filter((thread) => !!thread.legacyCommunicationRequestId && patientRequests.some((request) => request.id === thread.legacyCommunicationRequestId)) .map((thread) => thread.threadId as string) ); patientRequests = dedupeCommunicationRequests([ ...cachedShellRequests.filter((request) => !shellIdsSupersededByRealRequests.has(request.id as string)), ...patientRequests, ]); if (runRetired()) { return; } setPatientCommunicationRequests(patientRequests); setStaffCommunicationRequests(staffRequests); const allRequests = dedupeCommunicationRequests([...patientRequests, ...staffRequests]); const threadsMap = new Map(); const fetchPromises = allRequests .filter(request => request.id) .map(async (request) => { try { const requestId = request.id as string; let msgBundle: Bundle; try { msgBundle = await fetchCommunicationByThread(requestId); } catch (threadError) { console.warn(`Thread-based fetch failed for ${requestId}, falling back to CommunicationRequest lookup.`, threadError); msgBundle = await fetchCommunication(request); } let messages: Communication[] = unpackBundle(msgBundle); if (messages.length === 0) { try { const fallbackBundle = await fetchCommunication(request); const fallbackMessages: Communication[] = unpackBundle(fallbackBundle); messages = fallbackMessages.length > 0 ? fallbackMessages : messages; } catch (fallbackError) { console.warn(`CommunicationRequest fallback fetch failed for ${requestId}.`, fallbackError); } } const thread = createCommunicationThread(request, messages, requestId); threadsMap.set((thread.threadId || request.id) as string, thread); } catch (error) { console.error(`Error fetching messages for request ${request.id}:`, error); const thread = createCommunicationThread(request, [], request.id as string); threadsMap.set((thread.threadId || request.id) as string, thread); } }); await Promise.all(fetchPromises); if (runRetired()) { return; } // A re-initialisation right after a send re-reads every thread, and the search can lag the // write: the desk's own note vanished from a Staff only thread until a reload. So, as the // per-thread re-reads do, the server's answer wins for everything it returned and only what it // has not mentioned yet survives alongside it. setCommunicationThreads((prev) => { // Checked where React runs it, which can be after a successor run has published its own map. if (runRetired()) return prev; const merged = new Map(threadsMap); merged.forEach((thread, threadId) => { const kept = keepLocalOnlyMessages(thread.messages, prev.get(threadId)); if (kept !== thread.messages) { merged.set(threadId, createCommunicationThread(thread.request, kept, threadId)); } }); return merged; }); run.initialized = true; setCommunicationDataLoaded(true); } catch (error: unknown) { if (runRetired()) return; // Nothing could be read, so the inbox is unavailable rather than empty. publishListing('failed'); console.error('Error initializing communication data:', error); throw error; } finally { run.initializing = false; if (run.catchUp) { run.catchUp = false; if (!runRetired()) loadAgain.current(); } } }, [currentUser, messagingIdentity, actingAs, wpOrganization, fetchCommunicationRequestsForPatient, fetchCommunicationRequestsForRelatedPerson, fetchCommunicationRequestsForPractitioner, fetchCommunication, fetchCommunicationByThread, fetchDirectThreads, cacheDirectThread, unpackBundle, createCommunicationThread, keepLocalOnlyMessages, readCachedDirectThreads, createDirectThreadShellRequest, enrichDirectThreadShellRequest, writeCachedDirectThreads, reconcileCachedThreads, get, mayCall, liveListingScope, liveNameLifetime, runIsCurrent]); /* * THE FRONT-DESK PING IS HEARD BY THE SERVICE THAT HOLDS THE INBOX, NOT BY ONE SCREEN OF IT. * * Walked on staging: the server sent the ping to the receptionist's socket the moment a patient wrote, * but she was on Form Submissions. The only listener lived in the Messages panel, so the ping was * dropped, and back on Messages the page served the list it read at sign-in: no read for 34 minutes, * "No conversations yet" in the Inbox and no Messages badge until she replied. * * Staff only. A clinician's refetch simply gets no pool threads back, and working out who holds the * receptionist role here would be a second copy of the server's rule. The re-read schedule covers the * search index trailing the write. */ const socket = useOptionalWebSocketServiceContext(); const subscribeToSocket = socket?.subscribe; const unsubscribeFromSocket = socket?.unsubscribe; const listensForTheDesk = currentUser?.fhir?.resourceType === 'Practitioner'; const deskOrganizationId = wpOrganization?.current?.id; loadAgain.current = () => { // A failed catch-up read keeps the last inbox. initializeCommunicationData(true).catch(() => undefined); }; const rereadInbox = useRef<() => void>(() => undefined); rereadInbox.current = () => { // The next ping or re-read asks again; a failed refresh leaves the last list standing. initializeCommunicationData(true).catch(() => undefined); }; useEffect(() => { if (!subscribeToSocket || !unsubscribeFromSocket || !listensForTheDesk) return; const reread = createPoolPingReread(() => rereadInbox.current()); const onPoolChanged = (frame?: unknown) => { const notice = readFrontDeskPoolNotice(frame); if (notice?.organizationId && deskOrganizationId && notice.organizationId !== deskOrganizationId) return; rereadInbox.current(); reread.ping(); }; subscribeToSocket(FRONT_DESK_POOL_CHANNEL, onPoolChanged); return () => { unsubscribeFromSocket(FRONT_DESK_POOL_CHANNEL, onPoolChanged); reread.cancel(); }; }, [subscribeToSocket, unsubscribeFromSocket, listensForTheDesk, deskOrganizationId]); /* * A HELPER'S OPEN THREAD HEARS THE DESK. A reply addresses the patient a guardian or caregiver acts for, and their * socket is their own RelatedPerson, so the server tells them by the thread's id only (`message.thread.updated`). * Heard here, where the inbox lives, and answered with the same fenced read a reload does: what they see is what * they may read now. Only for a thread of the person they act for (one this inbox holds, or that person's office * thread); switching person or Exit ends the listener and its pending re-reads, and a read already started for * the old person publishes nothing (`runRetired`). */ const actingForSubject = currentUser?.fhir?.resourceType === 'Patient' && actingAs && messagingIdentity?.id === actingAs.subjectPatientId ? actingAs.subjectPatientId : undefined; // The listener's whole lifetime: viewer, link and practice as well as the person. Any change ends it with its re-reads. const helperListeningScope = actingForSubject ? `${liveListingScope()}|${actingForSubject}` : undefined; const heldThreadIds = useRef>(new Set()); heldThreadIds.current = new Set(communicationThreads.keys()); useEffect(() => { if (!subscribeToSocket || !unsubscribeFromSocket || !actingForSubject) return; const reread = createPoolPingReread(() => rereadInbox.current()); const onThreadUpdated = (notice?: unknown) => { const threadId = (notice as { threadId?: unknown } | undefined)?.threadId; if (typeof threadId !== 'string') return; const theirOffice = Boolean(parsePoolThreadId(threadId)) && threadId.startsWith(`direct#${actingForSubject}#`); if (!heldThreadIds.current.has(threadId) && !theirOffice) return; rereadInbox.current(); reread.ping(); }; subscribeToSocket(MESSAGE_THREAD_UPDATED, onThreadUpdated); return () => { unsubscribeFromSocket(MESSAGE_THREAD_UPDATED, onThreadUpdated); reread.cancel(); }; }, [subscribeToSocket, unsubscribeFromSocket, actingForSubject, helperListeningScope]); // A visit room's own patient and host hear its id-only notice too: a read receipt changes a line the inbox already // holds, so the inbox reads again, by the same fenced read, across the search lag. Helpers are heard above. const holdsARoom = [...communicationThreads.keys()].some((threadId) => isEncounterRoomThreadId(threadId)); const roomListeningScope = !actingForSubject && holdsARoom && messagingIdentity?.id ? `${liveListingScope()}|${messagingIdentity.id}` : undefined; useEffect(() => { if (!subscribeToSocket || !unsubscribeFromSocket || !roomListeningScope) return; const reread = createPoolPingReread(() => rereadInbox.current()); const onRoomUpdated = (notice?: unknown) => { const threadId = (notice as { threadId?: unknown } | undefined)?.threadId; if (typeof threadId !== 'string' || !isEncounterRoomThreadId(threadId) || !heldThreadIds.current.has(threadId)) return; rereadInbox.current(); reread.ping(); }; subscribeToSocket(MESSAGE_THREAD_UPDATED, onRoomUpdated); return () => { unsubscribeFromSocket(MESSAGE_THREAD_UPDATED, onRoomUpdated); reread.cancel(); }; }, [subscribeToSocket, unsubscribeFromSocket, roomListeningScope]); const getCommunicationThread = useCallback((threadOrRequestId: string): CommunicationThread | undefined => { return communicationThreads.get(threadOrRequestId); }, [communicationThreads]); const getMessagesForRequest = useCallback((request: CommunicationRequest): Communication[] => { if (!request.id) return []; const thread = communicationThreads.get(request.id); return thread?.messages || []; }, [communicationThreads]); const getMessagesForThread = useCallback((threadId: string): Communication[] => { const thread = communicationThreads.get(threadId); return thread?.messages || []; }, [communicationThreads]); const getUnreadCountForRequest = useCallback((threadOrRequestId: string): number => { const thread = communicationThreads.get(threadOrRequestId); return thread?.unreadCount || 0; }, [communicationThreads]); const markThreadAsRead = useCallback((threadOrRequestId: string) => { setCommunicationThreads(prev => { const updated = new Map(prev); const thread = updated.get(threadOrRequestId); if (thread) { const updatedThread = { ...thread, unreadCount: 0 }; updated.set(threadOrRequestId, updatedThread); } return updated; }); }, []); const setActiveThread = useCallback((threadOrRequestId: string | null) => { const previousActiveThread = activeThreadId.current; activeThreadId.current = threadOrRequestId; if (threadOrRequestId) { markThreadAsRead(threadOrRequestId); } if (previousActiveThread || threadOrRequestId) { setCommunicationThreads(prev => { const updated = new Map(prev); if (previousActiveThread) { const prevThread = updated.get(previousActiveThread); if (prevThread) { const recalculatedThread = createCommunicationThread( prevThread.request, prevThread.messages, prevThread.threadId || previousActiveThread ); updated.set(previousActiveThread, recalculatedThread); } } if (threadOrRequestId) { const newThread = updated.get(threadOrRequestId); if (newThread) { const recalculatedThread = createCommunicationThread( newThread.request, newThread.messages, newThread.threadId || threadOrRequestId ); updated.set(threadOrRequestId, recalculatedThread); } } return updated; }); } }, [markThreadAsRead, createCommunicationThread]); const addCommunicationRequestToState = useCallback((request: CommunicationRequest) => { if (!request.id) return; const requestId = request.id as string; const requestType = getRequestType(request); if (requestType === 'patient') { setPatientCommunicationRequests(prev => { const existingIndex = prev.findIndex(req => req.id === requestId); if (existingIndex === -1) { return [request, ...prev]; } return prev.map(req => req.id === requestId ? request : req); }); setStaffCommunicationRequests(prev => prev.filter(req => req.id !== requestId) ); } else if (requestType === 'staff') { setStaffCommunicationRequests(prev => { const existingIndex = prev.findIndex(req => req.id === requestId); if (existingIndex === -1) { return [request, ...prev]; } return prev.map(req => req.id === requestId ? request : req); }); setPatientCommunicationRequests(prev => prev.filter(req => req.id !== requestId) ); } setCommunicationThreads(prev => { const updated = new Map(prev); const existingThread = updated.get(requestId); const preservedMessages = existingThread?.messages || []; const newThread = createCommunicationThread(request, preservedMessages, requestId); updated.set(requestId, newThread); return updated; }); }, [createCommunicationThread, getRequestType]); const openStaffDirectThread = useCallback(async ( currentStaff: Practitioner, targetStaff: Practitioner, ): Promise => { if (!currentStaff?.id || !targetStaff?.id) { throw new Error('Both staff participants are required to open a staff thread.'); } if (currentStaff.id === targetStaff.id) { throw new Error('Cannot open a staff thread with yourself.'); } const existingRequest = staffCommunicationRequests.find((request) => classifyCommunicationRequest(request) === 'staff' && isSameStaffPair(request, currentStaff.id as string, targetStaff.id as string) ); if (existingRequest?.id) { const existingThread = communicationThreads.get(existingRequest.id); if (existingThread) { return existingThread; } const fallbackThread = createCommunicationThread( existingRequest, [], existingRequest.id as string, ); setCommunicationThreads(prev => { const updated = new Map(prev); updated.set(existingRequest.id as string, fallbackThread); return updated; }); return fallbackThread; } const request = addThreadTypeExtensionToRequest({ resourceType: 'CommunicationRequest', status: 'active', authoredOn: new Date().toISOString(), sender: createReferenceObject('Practitioner', currentStaff.id as string, formatFhirHumanName(currentStaff.name)), recipient: [ createReferenceObject('Practitioner', targetStaff.id as string, formatFhirHumanName(targetStaff.name)), ], ...(wpOrganization?.current?.id ? { requester: createReferenceObject('Organization', wpOrganization.current.id as string, wpOrganization.current.name) } : {}), } as CommunicationRequest, 'staff_direct'); const createdRequest = await createCommunicationRequest(request); addCommunicationRequestToState(createdRequest); const thread = createCommunicationThread(createdRequest, [], createdRequest.id as string); setCommunicationThreads(prev => { const updated = new Map(prev); updated.set(createdRequest.id as string, thread); return updated; }); return thread; }, [ addCommunicationRequestToState, communicationThreads, createCommunicationRequest, createCommunicationThread, createReferenceObject, staffCommunicationRequests, wpOrganization, ]); const updateCommunicationRequestInState = useCallback((request: CommunicationRequest) => { if (!request.id) return; const requestId = request.id as string; const requestType = getRequestType(request); if (requestType === 'patient') { setPatientCommunicationRequests(prev => { const existingIndex = prev.findIndex(req => req.id === requestId); if (existingIndex === -1) { return [request, ...prev]; } return prev.map(req => req.id === requestId ? request : req); }); setStaffCommunicationRequests(prev => prev.filter(req => req.id !== requestId) ); } else if (requestType === 'staff') { setStaffCommunicationRequests(prev => { const existingIndex = prev.findIndex(req => req.id === requestId); if (existingIndex === -1) { return [request, ...prev]; } return prev.map(req => req.id === requestId ? request : req); }); setPatientCommunicationRequests(prev => prev.filter(req => req.id !== requestId) ); } setCommunicationThreads(prev => { const updated = new Map(prev); const existingThread = updated.get(requestId); const preservedMessages = existingThread?.messages || []; const updatedThread = createCommunicationThread(request, preservedMessages, requestId); updated.set(requestId, updatedThread); return updated; }); }, [createCommunicationThread, getRequestType]); const deleteCommunicationRequestFromState = useCallback((requestId: string) => { setPatientCommunicationRequests(prev => prev.filter(req => req.id !== requestId)); setStaffCommunicationRequests(prev => prev.filter(req => req.id !== requestId)); setCommunicationThreads(prev => { const updated = new Map(prev); updated.delete(requestId); return updated; }); const cachedThreads = readCachedDirectThreads(); if (cachedThreads.some(thread => thread.threadId === requestId)) { writeCachedDirectThreads(cachedThreads.filter(thread => thread.threadId !== requestId)); } if (activeThreadId.current === requestId) { activeThreadId.current = null; } }, [readCachedDirectThreads, writeCachedDirectThreads, get]); const addCommunicationToState = useCallback((message: Communication) => { if (!message.id) return; const threadId = getCommunicationThreadId(message); if (!threadId) return; setCommunicationThreads(prev => { const updated = new Map(prev); const existingThread = updated.get(threadId); if (existingThread) { const updatedMessages = [...existingThread.messages]; const existingIndex = updatedMessages.findIndex(msg => msg.id === message.id); if (existingIndex === -1) { updatedMessages.push(message); const updatedThread = createCommunicationThread(existingThread.request, updatedMessages, threadId); updated.set(threadId, updatedThread); const senderReference = message.sender?.reference; const currentUserReference = `${currentUser?.fhir?.resourceType}/${currentUser?.fhir?.id}`; const isFromOther = !senderReference?.includes(currentUserReference); if (isFromOther) { const requestType = getRequestType(existingThread.request); if (requestType === 'patient') { setPatientCommunicationRequests(prev => { const filtered = prev.filter(req => req.id !== existingThread.request.id); return [existingThread.request, ...filtered]; }); } else if (requestType === 'staff') { setStaffCommunicationRequests(prev => { const filtered = prev.filter(req => req.id !== existingThread.request.id); return [existingThread.request, ...filtered]; }); } } } } else { const shell = shellForArrivingMessage(threadId, message); const thread = createCommunicationThread(shell, [message], threadId); updated.set(threadId, thread); setPatientCommunicationRequests(prev => dedupeCommunicationRequests([shell, ...prev.filter(req => req.id !== threadId)])); } return updated; }); }, [createCommunicationThread, currentUser, getCommunicationThreadId, getRequestType]); const updateCommunicationInState = useCallback((message: Communication) => { if (!message.id) return; const threadId = getCommunicationThreadId(message); if (!threadId) return; setCommunicationThreads(prev => { const updated = new Map(prev); const existingThread = updated.get(threadId); if (existingThread) { const updatedMessages = existingThread.messages.map(msg => msg.id === message.id ? message : msg ); const updatedThread = createCommunicationThread(existingThread.request, updatedMessages, threadId); updated.set(threadId, updatedThread); } return updated; }); }, [createCommunicationThread, getCommunicationThreadId]); const deleteCommunicationFromState = useCallback((message: Communication) => { if (!message.id) return; const threadId = getCommunicationThreadId(message); if (!threadId) return; setCommunicationThreads(prev => { const updated = new Map(prev); const existingThread = updated.get(threadId); if (existingThread) { const updatedMessages = existingThread.messages.filter(msg => msg.id !== message.id); const updatedThread = createCommunicationThread(existingThread.request, updatedMessages, threadId); updated.set(threadId, updatedThread); } return updated; }); }, [createCommunicationThread, getCommunicationThreadId]); const transformToFhirCommunicationRequest = useCallback((input: CommunicationRequestFormInput, sender?: Practitioner, recipient?: Patient | Practitioner, organization?: Organization, encounterState?: Encounter): CommunicationRequest => { const { status, priority, category, authoredOn, medium, reasonCode, reasonReference, replaces, encounter, note } = input; const transformedReasonReference = transformToReferenceCollection(reasonReference); return { resourceType: 'CommunicationRequest', status: status ?? 'active', ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(category.length !== 0 && { category: transformToCodeableConcept(category) as CodeableConcept[] }), ...(priority && { priority: priority }), ...(authoredOn && { authoredOn: formatDateTime(authoredOn) }), ...(medium && medium.length !== 0 && { medium: transformToCodeableConcept(medium) as CodeableConcept[] }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) as CodeableConcept[] }), ...(note && note.length !== 0 && { note: note }), // (design-pass finding B33) - DERIVE THE TYPE, NEVER HARDCODE IT. // Staging carries `CommunicationRequest/889891d5...` whose `sender.reference` reads // `Practitioner/f0e77739...` while its own `sender.display` reads **"Patient One"** - that id is // a Patient. The record contradicts itself, which is what proves the TYPE is wrong rather than // the id. The practitioner day board then 404s that reference on every load and drops the name. // // No live caller can produce another one today: this parameter is typed `Practitioner` and both // call sites pass one. But `CommunicationRequest.sender` legitimately admits a Patient in FHIR, // patients do send messages, and the day this signature widens a hardcoded 'Practitioner' // silently mints the same broken record again - with nothing red anywhere. // // The sibling writer already gets this right: `transformToFhirCommunication` passes // `sender.resourceType`. This one now matches it, so the two cannot drift. ...(sender && { sender: createReferenceObject(sender.resourceType, sender.id as string, formatFhirHumanName(sender.name)) }), ...(recipient && { recipient: [createReferenceObject(`${recipient.resourceType}`, recipient.id as string, formatFhirHumanName(recipient.name))] }), ...(organization && { requester: createReferenceObject('Organization', organization.id as string, organization.name) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(replaces && replaces.length !== 0 && { replaces: replaces }) }; }, [createReferenceObject, transformToCodeableConcept, transformToReferenceCollection, formatDateTime, getEncounterDisplay]); const transformFromFhirCommunicationRequest = useCallback((communicationRequest: CommunicationRequest): CommunicationRequestFormInput => { const { status, priority, category, authoredOn, medium, reasonCode, reasonReference, replaces, encounter, note, meta } = communicationRequest; const formData = { meta: meta, status: status, priority: priority, authoredOn: formatDateTimeForInput(authoredOn), category: transformFromCodeableConcept(category ?? []), medium: transformFromCodeableConcept(medium ?? []), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), replaces: replaces, encounter: encounter, note: note ?? [] }; return formData; }, [transformFromCodeableConcept, transformFromReferenceCollection, formatDateTimeForInput]); const transformToFhirCommunication = useCallback((message: string, communicationRequest: CommunicationRequest, sender: Practitioner | Patient, attachment?: Attachment, references?: Reference[]): Communication => { const { category, priority, medium, sender: originalSender, recipient: originalRecipient } = communicationRequest; const currentSenderReference = createReferenceObject(sender.resourceType, sender.id as string, formatFhirHumanName(sender.name)); const originalSenderId = extractReferenceId(originalSender); const isSenderSameAsOriginal = sender.id === originalSenderId; const communication: Communication = { resourceType: 'Communication', status: 'in-progress', ...(category?.length !== 0 && { category: category }), ...(priority && { priority: priority }), ...(medium?.length !== 0 && { medium: medium }), sent: new Date().toISOString(), sender: currentSenderReference, recipient: isSenderSameAsOriginal ? originalRecipient : [originalSender as Reference], payload: [], basedOn: [createReferenceObject('CommunicationRequest', communicationRequest.id as string)] }; if (message.trim()) { communication.payload?.push({ contentString: message }); } if (attachment) { communication.payload?.push({ contentAttachment: attachment }); } if (references && references.length > 0) { communication.payload?.push({ contentReference: references.length === 1 ? references[0] : references } as NonNullable[number]); } return communication; }, [createReferenceObject, extractReferenceId]); const createCommunicationForThread = useCallback(( message: string, threadId: string, sender: Practitioner | Patient, recipientReferences: Reference[], attachment?: Attachment, references?: Reference[] ): Communication => { const communication: Communication = { resourceType: 'Communication', status: 'in-progress', sent: new Date().toISOString(), sender: createReferenceObject(sender.resourceType, sender.id as string, formatFhirHumanName(sender.name)), recipient: recipientReferences, payload: [], basedOn: [{ reference: `Thread/${threadId}` }] }; if (message.trim()) { communication.payload?.push({ contentString: message }); } if (attachment) { communication.payload?.push({ contentAttachment: attachment }); } if (references && references.length > 0) { communication.payload?.push({ contentReference: references.length === 1 ? references[0] : references } as NonNullable[number]); } return addOrUpdateThreadIdExtension(communication, threadId); }, [addOrUpdateThreadIdExtension, createReferenceObject]); const getCommunicationRequestDisplay = useCallback((communicationRequest: CommunicationRequest): string => { // messagingIdentity, not currentUser.fhir: this labels a request from the DISPLAYED // patient's perspective ("who is the other party in this thread"). CommunicationRequest // sender/recipient always reference the Patient (self, or the act-as subject) -- never a // RelatedPerson -- since createDirectThreadShellRequest always builds the patient side of // the shell from thread.patientId, so comparing against the displayed patient is correct // here (unlike the message-level sender checks below, which are left on currentUser.fhir). if (!communicationRequest || !messagingIdentity) { return 'Unknown Communication Request'; } const currentUserReference = `${messagingIdentity.resourceType}/${messagingIdentity.id}`; let displayName = 'Unknown Person'; if (communicationRequest.sender?.reference?.includes(currentUserReference)) { if (communicationRequest.recipient && communicationRequest.recipient.length > 0) { displayName = communicationRequest.recipient[0].display || communicationRequest.recipient[0].reference || 'Unknown Recipient'; } } else { displayName = communicationRequest.sender?.display || communicationRequest.sender?.reference || 'Unknown Sender'; } // - the human label, never the code. `statusLabel` returns `undefined` for a code we // have no words for, and this omits the status entirely in that case rather than falling back to // the raw value - the fallback IS the defect. `utils/fhirStatusVocabulary.ts` const statusText = statusLabel(communicationRequest.status); const status = statusText ? ` ยท ${statusText}` : ''; const prioritySuffix = communicationRequest.priority && communicationRequest.priority !== 'routine' ? ` - Priority: ${communicationRequest.priority}` : ''; let dateSuffix = ''; const communicationRequestDisplay = communicationRequest as CommunicationRequest & CommunicationRequestDisplayFields; if (communicationRequestDisplay.occurrenceDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(communicationRequestDisplay.occurrenceDateTime)})`; } else if (communicationRequestDisplay.occurrencePeriod) { dateSuffix = ` (${formatFhirPeriod(communicationRequestDisplay.occurrencePeriod)})`; } else if (communicationRequest.authoredOn) { dateSuffix = ` (${formatDateTimeForDisplay(communicationRequest.authoredOn)})`; } return `${displayName}${status}${prioritySuffix}${dateSuffix}`; }, [messagingIdentity, formatDateTimeForDisplay]); const renderScope = liveListingScope(); const renderLifetime = liveNameLifetime(); const currentRun = runIsCurrent(initializationRef.current); // Changes with every identity, scope or lifetime, so the manager is woken for a new scope with nothing else moving. const listingRunKey = `${liveTrackedId.current ?? ''}|${renderScope}#${renderLifetime}`; const threadListingState: ThreadListingState = threadListing && threadListing.scope === renderScope && threadListing.lifetime === renderLifetime ? threadListing.state : 'unknown'; return { // Exported so message CONSUMERS resolve "who am I, in this conversation" from the SAME value // the service scopes threads by. Two components (dashboard/Messages.tsx, MessagesPage.tsx) // each had their own `currentUser.fhir`-based copy of that rule and so kept labelling the // acted-as subject as their own counterparty (finding B12). Re-deriving the rule per call // site is exactly how those two drifted; there is now one definition. messagingIdentity, patientCommunicationRequests, staffCommunicationRequests, communicationThreads, shouldReload, setShouldReload, lastUpserted, setLastUpserted, patientUnreadMessages, staffUnreadMessages, initializeCommunicationData, // Both flags speak only for the run of this identity, scope and lifetime: another practice's run, settled or // still out, reads as neither, so nothing waits on it and the manager starts this scope's own read. isInitialized: currentRun && initializationRef.current.initialized, listingRunKey, threadListingState, // NOT a duplicate of `isInitialized` above, which reads the REF during render and therefore only // changes when something else re-renders this provider. A consumer that must distinguish "no // conversations" from "not asked yet" needs a value that CAUSES the re-render. communicationDataLoaded, // TRUE MEANS "COULD NOT ASK", WHICH IS NOT THE SAME CLAIM AS "NOTHING IS THERE". See its // declaration: a consumer that renders an empty state must say the first one when this is set, // because the two are indistinguishable on screen and only one of them is honest. threadListingFailed: threadListingState === 'failed' || threadListingState === 'refused', threadListingRefused: listingRefusedHere, // A no-practice helper's grant still being read ('pending') or not settled by its read ('unknown'). helperGrantState: neutralGrantHere, participantNameRead, refusedMessagingGrant: listingRefusedHere ? listingRefusal?.messagingGranted : undefined, isInitializing: currentRun && initializationRef.current.initializing, fetchCommunicationRequestsForPractitioner, fetchCommunicationRequestsForPatient, fetchCommunicationRequestsForRelatedPerson, fetchCommunication, fetchCommunicationByThread, saveCommunicationBundle, readCommunications, createCommunication, closeSendOperation, createCommunicationRequest, updateCommunicationRequest, resolveDirectThread, resolveFrontDeskThread, resolveFrontDeskNoteThread, openDirectThread, openFrontDeskThread, // - the same queue, opened by referral rather than by patient. openFrontDeskReferralItem, // S5 - the soft claim. Exported beside the opener because they are the same feature seen // from two ends: one gets you into the office's conversation, these two say who is answering it. claimFrontDeskThread, releaseFrontDeskThread, openStaffDirectThread, getCommunicationThread, getMessagesForRequest, getMessagesForThread, getUnreadCountForRequest, markThreadAsRead, setActiveThread, addCommunicationRequestToState, updateCommunicationRequestInState, deleteCommunicationRequestFromState, addCommunicationToState, updateCommunicationInState, deleteCommunicationFromState, transformToFhirCommunicationRequest, transformFromFhirCommunicationRequest, transformToFhirCommunication, createCommunicationForThread, getCommunicationThreadId, getCurrentUserReference, getReceiptReference, hasReadReceiptForReference, addReadReceiptForReference, newMessageIndicatorStyle, getCommunicationRequestDisplay }; }; const CommunicationServiceContext = createContext | undefined>(undefined); export const CommunicationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const communicationService = useCommunicationService(); return ( {children} ); }; /** The service when a provider is above, else undefined: for a reader that can do without it. */ export const useOptionalCommunicationServiceContext = () => useContext(CommunicationServiceContext); export const useCommunicationServiceContext = () => { const context = useContext(CommunicationServiceContext); if (context === undefined) { throw new Error('useCommunicationServiceContext must be used within a CommunicationServiceProvider'); } return context; };