import type { CommunicationRequest, Patient, Practitioner } from 'fhir/r4'; import { isPoolThreadRequest } from '../../../utils/messaging/poolThread'; import { isEncounterRoomThreadRequest } from '../../../utils/messaging/roomThread'; /** * Whether a message may be sent on this thread, and who it is addressed to.. * * THE DEFECT THIS CLOSES: a patient's message to the front desk never left the browser. The send path * returned early when there was no named practitioner - and a POOL THREAD HAS NONE BY DESIGN ("there is no * one receptionist who owns the office's conversation"). It returned NORMALLY rather than throwing, so the * chat pane's rollback never ran and the optimistic bubble stayed on screen wearing a green double tick. * A swallowed write that reported success. * * WHY THESE TWO RULES ARE A MODULE rather than two conditions inside a 1,700-line page: they are the * whole defect, and neither could be tested where it lived. The page pulls six service contexts, so the * only thing that ever exercised this branch was a human in a browser - which is exactly how it shipped. */ /** * `hasContent` is a BOOLEAN, not the message text, because a message is not the only thing a send can * carry: an attachment or a set of references is a legitimate send with an empty body. The page already * computes that, and passing the raw string here would have re-introduced the "empty body refuse" bug * for every attachment-only send. */ export const shouldSendOnThread = ( hasContent: boolean, // `null` as well as `undefined`: the page holds the selected thread as `CommunicationRequest | null`, // and a signature that took only `undefined` would push a cast onto every call site. request: CommunicationRequest | null | undefined, practitioner: Practitioner | null | undefined, ): boolean => { if (!hasContent) return false; if (!request) return false; // A pool has no owner and never will - that is the feature, not a load that has not finished. if (isPoolThreadRequest(request)) return true; // A visit's room thread has no one person either: the server addresses it to the admitted roster, during // the call and after it ends. if (isEncounterRoomThreadRequest(request ?? undefined)) return true; // And a NAMED thread with no resolved practitioner is still a genuine "not ready": sending there // would address a one-to-one message to nobody. return Boolean(practitioner); }; /** * Whose record the message is addressed to - the named practitioner, or, on a pool thread, the SENDER'S * OWN record. * * A POOL MESSAGE IS NOT ADDRESSED TO NOBODY, AND THIS IS THE PART THAT WOULD HAVE SHIPPED WRONG. * The obvious reading of *"a pool has no owner"* is an empty recipient list - and the server **rejects * that with a 400**: `Communication/post.ts` requires at least one recipient for every thread that is not * a collaboration team. An empty list turns a silent discard into a loud failure, which is better and * is still broken. * **The convention already exists on the server and is documented in its own comment:** *"a pool * thread's recipient list is `[Patient/]` and nothing else, because a pool has no one owner to * name."* The office is reached by the pool key via the front-desk notice, not by the recipient list - * so the recipient carries the PATIENT, which is also what makes the message visible to them. * * A POOL THREAD NEVER RESOLVES TO THE PRACTITIONER, even when one is in hand. That is the sharp case: * the patient may have had a provider's chat open a moment earlier, so the page's `practitioner` can be * STALE and non-null while the SELECTED thread is the office. Addressing the office's message to whoever * was on screen last would deliver a patient's message about their health to one named clinician instead * of the front desk - silently, and plausibly. * * It returns the RESOURCE rather than a FHIR `Reference` because `createReferenceObject` is supplied by * `useFhirServiceContext()` - a hook, not a module - so a pure decision module cannot build one. */ export const recipientForThread = ( // `null` as well as `undefined`: the page holds the selected thread as `CommunicationRequest | null`, // and a signature that took only `undefined` would push a cast onto every call site. request: CommunicationRequest | null | undefined, practitioner: Practitioner | null | undefined, self: Patient | null | undefined, ): Practitioner | Patient | null => { // `?? undefined` because the shared predicate takes `CommunicationRequest | undefined` and this module // accepts `null` too. Not a cast - a `null` thread is genuinely "not a pool", which is what a // widening cast would have asserted without checking. if (isPoolThreadRequest(request ?? undefined)) return self ?? null; // No recipient on a room thread: the server replaces the list with the room's roster, and a practitioner // still on the page from another chat must not be named. if (isEncounterRoomThreadRequest(request ?? undefined)) return null; return practitioner ?? null; };