/** * WHICH SERVER-LISTED THREADS BELONG IN A STAFF MEMBER'S MESSAGES INBOX. * * THE DEFECT IT CLOSES, measured on staging 2026-09-04: a patient replied into their direct thread with a * named clinician, and that clinician's inbox said "No conversations yet" while their owed-work panel said * nothing was waiting. Nothing errored on either side, so neither party could tell. * * The cause was this rule, written inline and keeping pool threads only. The reasoning recorded beside it * was that a staff member's own direct threads arrive separately as CommunicationRequests, so listing them * here could only duplicate them. That holds for a thread STAFF opened through the old path and fails for a * thread the PATIENT opened: the server mints that one in the thread table alone, with no * CommunicationRequest behind it. So the CommunicationRequest search returned nothing for it, this filter * discarded it, and a live clinical conversation reached no reader at all. * * It is a named, pure rule rather than an inline filter because of how it failed. An excluded thread type * produces an inbox that is merely EMPTY - indistinguishable from a quiet one, invisible in every * screenshot, and written off as "no test data" twice before it was caught. A rule that cannot be stated * on its own cannot be tested on its own, and this one needs to be. */ /** Only the fields the rule reads. Structural on purpose: a listed thread carries far more than this. */ export interface StaffInboxThread { threadId?: string; threadType?: string; isActive?: boolean; } /** * The thread types the staff Messages inbox is the reader for. * * `pool` is the practice's front desk and `direct` is a named conversation this staff member is a * participant of - the server only lists the latter to people who actually are. * * `encounter-room` WAS DELIBERATELY ABSENT, AND THE REASON WRITTEN HERE WAS NOT TRUE. It read: *"those * threads belong to the encounter screen, which lists them itself; admitting them here would add a * surface rather than repair one."* Nothing lists them. The only reader of a room thread on this side is * the meeting window, which closes with the call - so a clinician who was in the room could reach the * conversation NOWHERE afterwards: not the inbox, not the appointment, not the chart. * * MEASURED on 2026-09-13 as the practitioner who held the visit and wrote in the thread, five hours * after it ended. The patient kept it and could read it. The person they were talking to could not. * * AND THE DESIGN ALREADY SAID SO, which is why this is a correction rather than a new feature. The room * thread's own design document gives storing the membership - rather than deriving it from the call - * these two reasons: *"a derived thread never appears in anyone's message list. That list is built from * stored membership rows"*, and, under what being stored buys for free, *"it appears in every * participant's message list."* A staff participant is a participant. * * The server needed nothing: the roster row is written at admission and `GET /Communication/thread` * already returns the thread to whoever holds one. This filter was the whole of it. */ export const STAFF_INBOX_THREAD_TYPES = ['pool', 'direct', 'encounter-room'] as const; /** * True when a thread the server listed belongs in the staff inbox. * * A thread with no id is refused: it cannot be cached, matched, or opened, and passing it on only moves the * failure somewhere with less context. * * `isActive` is compared against `false` rather than tested for truthiness, so a listing that omits the * field is treated as live. Absence is not a retirement - and the recoverable direction here is showing a * conversation that has ended, never hiding one that has not. */ export const belongsInStaffInbox = (thread: StaffInboxThread): boolean => !!thread.threadId && thread.isActive !== false && (STAFF_INBOX_THREAD_TYPES as readonly string[]).includes(thread.threadType || '');