// Sends to the front-desk pool and never reads it: a sender without a messages grant cannot open the // thread, so the dialog says no reply will appear here. Renders nothing without a patient. import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Practitioner } from 'fhir/r4'; import { toast } from 'sonner'; import { useAuthServiceContext, useCommunicationServiceContext, useFhirServiceContext, usePractitionerServiceContext, } from '../../services'; import { useCapabilityServiceContext } from '../../services/CapabilityService'; import { organizationIdsWithFrontDesk } from '../../utils/patientMessagingDirectory'; import { mayOfferFrontDeskDoor } from '../../utils/messaging/frontDeskDoor'; import { Button } from '../ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; import { Textarea } from '../ui/textarea'; import { namedSend } from '../../utils/messaging/sendOperation'; import { MAY_HAVE_BEEN_SENT } from '../../hooks/useHeldSendOperations'; import { useScopedSendRecovery } from '../../hooks/useScopedSendRecovery'; import EarlierSendCheck from '../messages/EarlierSendCheck'; interface TellTheFrontDeskProps { /** The patient the sender already has in front of them. Nothing renders without one. */ patientId?: string; organizationId?: string; } const FrontDeskNoteDoor = ({ patientId, organizationId }: TellTheFrontDeskProps & { patientId: string }) => { const { currentUser, userRole, USER_ROLES } = useAuthServiceContext(); const { practitionersList } = usePractitionerServiceContext(); const { extractReferenceId } = useFhirServiceContext(); const { mayCall } = useCapabilityServiceContext(); const { resolveFrontDeskNoteThread, createCommunicationForThread, createCommunication } = useCommunicationServiceContext(); const [open, setOpen] = useState(false); // Every open and close starts a new dialog lifetime: a send pressed in an earlier one publishes nothing here. const lifetime = useRef(0); const changeOpen = (next: boolean) => { lifetime.current += 1; setOpen(next); }; const [note, setNote] = useState(""); const [sending, setSending] = useState(false); const recovery = useScopedSendRecovery('front-desk-note', `frontdesk:${patientId}`); useLayoutEffect(() => { setSending(false); return recovery.reopen(); }, [open, patientId, organizationId, recovery.reopen]); // Bumped on every keystroke: an answer clears only the draft it was sent from. const revision = useRef(0); // A practice with no receptionist has nobody on the desk, so the door is not offered there. const orgIdsWithFrontDesk = useMemo( () => organizationIdsWithFrontDesk(practitionersList, (role) => { const id = extractReferenceId(role.organization) as string | undefined; return id || role.organization?.display?.trim(); }), [practitionersList, extractReferenceId], ); const offered = mayOfferFrontDeskDoor({ // An empty list here is a refusal, not a practice with no desk: this seat cannot // read staff at all. Unknown offers, and the server refuses if nobody is on the desk. organizationHasFrontDesk: practitionersList.length === 0 ? undefined : (organizationId ? orgIdsWithFrontDesk.has(organizationId) : orgIdsWithFrontDesk.size > 0), callerIsReceptionist: userRole === USER_ROLES.RECEPTIONIST, patientId, mayOpenThread: mayCall('POST', '/front-desk-note'), }); // No patient, no desk, or the caller is the desk: the control is absent rather than disabled. if (!offered) return null; const send = async () => { const text = note.trim(); if (!text || sending || recovery.waits) return; const sentIn = lifetime.current; const attempt = recovery.begin(undefined, () => lifetime.current === sentIn); const drafted = revision.current; setSending(true); const result = await recovery.run(attempt, `frontdesk:${patientId}|${text}`, async (operation, leaving) => { // RESOLVE, never open. openFrontDeskThread also FETCHES the conversation, and a sender who // only holds this door is refused that read - correctly. Asking anyway would turn a // working send into an error on screen. // THE ASIDE POOL, not the patient one. The patient-facing thread is a conversation they are // a party to; a note about them is not, and sharing that thread made it a disclosure. const thread = await resolveFrontDeskNoteThread(patientId); attempt.admit(); // Addressed to the OFFICE. Naming the patient here is what put the note in their inbox - they // are the subject of this thread, which the thread record already carries. const message = createCommunicationForThread(text, thread.threadId, currentUser?.fhir as Practitioner, [ { reference: `Organization/${thread.organizationId ?? organizationId}` }, ]); leaving(); return createCommunication(namedSend(message, operation), operation); }); if (lifetime.current !== sentIn || result.state === 'retired') return; setSending(false); if (result.state === 'sent') { if (revision.current === drafted) { setNote(""); changeOpen(false); } // It says WHO has it, because the sender cannot open the thread to see for themselves. toast.success("The front desk has it"); return; } if (result.state === 'not-sent') { toast.error(result.reason); return; } if (result.unsure) { toast.error("Not confirmed: the front desk may already have it. " + MAY_HAVE_BEEN_SENT); return; } const said = (result.error as { body?: { message?: string } })?.body?.message; // The server denial is named in its own response. The send stays held until Check answers whether // an earlier attempt of it arrived, so this screen never claims that nothing was sent. toast.error(said ?? "That did not reach the front desk. Check before sending it again."); }; return ( <> Tell the front desk The front desk team receives this message. Replies will not appear here.