import type { CommunicationRequest, Extension, Reference } from 'fhir/r4'; import { isEncounterRoomThreadRequest } from './roomThread'; /** * A conversation with an OFFICE rather than a person - front-desk pool, and fix. * * WHY THIS EXISTS AT ALL. Every client rule about a conversation was written assuming the other party * is a PERSON: the inbox names a counterparty, the chat pane renders only once a practitioner is * resolved, and a thread earns its row by having messages. A pool thread breaks all three at once - it * has no owner by design (naming whichever receptionist answers would tell the patient they are writing * to a person), and it is created BEFORE its first message so the patient has somewhere to write. The * server already learned this: its pool index keys on `createdAt`, not `lastMessageAt`. This module is * how the client learns the same thing, in ONE place, because the "has messages" rule alone already * existed in two copies and that is why one defect had two faces. * * The fact travels on the thread SHELL (a `CommunicationRequest`), because the shell is the only thing * the inbox and the chat pane are ever handed. It is stamped from the `DirectThread` the SERVER returned, * and re-stamped on every load rather than trusted from the cached copy - client state that can only be * added to would let a one-to-one thread go on claiming to be the front desk after the server stopped * saying so. */ export const FRONT_DESK_POOL_KEY = 'front-desk'; /** * The office side of the same desk: a colleague note ABOUT a patient. Separate from the pool above * because the patient is a participant of that one and reads it, and a note to the desk is not a * message to them. */ export const FRONT_DESK_ASIDE_POOL_KEY = 'front-desk-aside'; /** * The socket channel the server pings when anything about the pool changes - a patient's message * arriving, and every claim or release. * * THE SERVER HALF WAS BUILT AND NOTHING LISTENED. `utils/frontDeskFanout.ts` has emitted this since * S6, and `FrontDeskPool` appeared nowhere in this client - and the socket dispatches strictly by * channel name, so the event was dropped on the floor. The result was the exact defect that file's own * header says it was written to close: *"no screen anywhere changed until somebody reloaded."* * * THE NAME IS DERIVED, NOT INVENTED. The api builds every channel as * `com.akashichealthrecords.fhir.${resourceType.toLowerCase()}.${operation}` (`utils/ws.ts`), and the pool * notice uses the subject `FrontDeskPool` with the action `updated`. A typo here fails SILENTLY - an * unknown channel is simply never delivered - which is why a test pins the string against that recipe. * * THE EVENT CARRIES NO PHI: `{ organizationId, poolKey }` and nothing else - no thread, patient or * message id, because a pool thread's id contains the patient's. A handler must REFETCH; it can never * patch state straight out of the payload, and authorization stays entirely on the read. */ export const FRONT_DESK_POOL_CHANNEL = 'com.akashichealthrecords.fhir.frontdeskpool.updated'; /** Everything the pool notice says. Kept narrow so nothing can widen it into a PHI carrier unnoticed. */ export interface FrontDeskPoolNotice { organizationId?: string; poolKey?: string; } // The socket hands over the whole event, so the notice sits under its data. Reading only the top // level found nothing, and the practice filter below it never fired. export const readFrontDeskPoolNotice = (frame?: unknown): FrontDeskPoolNotice => { const outer = (frame ?? {}) as FrontDeskPoolNotice & { data?: FrontDeskPoolNotice }; if (outer.organizationId || outer.poolKey) return outer; return outer.data ?? {}; }; /** * Older marker urls stay readable, newest first. Same shape as the read-receipt and thread-type * extensions in `CommunicationService` - a shell cached by a previous build must still be recognisable. */ const POOL_KEY_EXTENSION_URLS = [ 'https://akashichealthrecords.com/fhir/StructureDefinition/thread-pool-key', 'https://akashic.health/fhir/StructureDefinition/thread-pool-key', 'urn:akashic:thread:pool-key', ]; /** * Never the raw key. A pool key is routing (`pool:front-desk` is a partition of a DynamoDB index); a * label is something a patient reads. An unrecognised pool gets a name that is generic and TRUE rather * than one that is specific and invented. */ const POOL_LABELS: Record = { [FRONT_DESK_POOL_KEY]: 'Front desk', [FRONT_DESK_ASIDE_POOL_KEY]: 'Front desk - about a patient', }; /** * The line UNDER the name. It says what the office does, so it is the honest fallback for the row's * second line when the practice's own name has not resolved - repeating "Front desk" twice tells the * reader nothing, and an empty line reads as a half-loaded row. */ const POOL_DESCRIPTIONS: Record = { [FRONT_DESK_POOL_KEY]: 'Reception, scheduling and billing', }; const UNKNOWN_POOL_LABEL = 'Shared inbox'; const UNKNOWN_POOL_DESCRIPTION = 'Answered by the practice'; export const poolThreadLabel = (poolKey: string | null | undefined): string => (poolKey && POOL_LABELS[poolKey]) || UNKNOWN_POOL_LABEL; export const poolThreadDescription = (poolKey: string | null | undefined): string => (poolKey && POOL_DESCRIPTIONS[poolKey]) || UNKNOWN_POOL_DESCRIPTION; const isPoolKeyExtension = (extension: Extension | undefined): boolean => !!extension?.url && POOL_KEY_EXTENSION_URLS.includes(extension.url); /** The pool this thread belongs to, or `undefined` for an ordinary one-to-one conversation. */ export const getThreadPoolKey = (request: CommunicationRequest | undefined): string | undefined => { const extension = request?.extension?.find(isPoolKeyExtension) as | (Extension & { valueString?: string; valueCode?: string }) | undefined; return extension?.valueString || extension?.valueCode || undefined; }; /** True when this conversation is with an office rather than with one named person. */ export const isPoolThreadRequest = (request: CommunicationRequest | undefined): boolean => !!getThreadPoolKey(request); const referenceId = (reference: Reference | undefined): string | undefined => reference?.reference?.split('/')[1] || undefined; /** * The reference that NAMES the other party, from the viewer's side.. * * THE DEFECT THIS CLOSES: the front desk's inbox called a pooled conversation **"Unknown"**, though the * thread carried the patient id and the patient was readable. Every surface resolves a counterparty by the * same swap - *"if I am the sender, the other party is the recipient; otherwise it is the sender"* - and on * a POOL thread the staff side of the shell **is not populated at all**: `staffAccountId` is null by * design, so `createDirectThreadShellRequest` builds no staff reference. The swap therefore lands on * `undefined`, and the row falls through to its last-resort label. * **Nothing was missing from the data.** The patient reference is right there, already display-enriched; * the rule for choosing between the two parties simply had no case for a thread where one of them is a * ROLE rather than a person. * * It returns the party who is NOT the viewer, rather than "the recipient", so it stays correct from both * ends: a receptionist gets the patient, and a patient gets nothing - which is right, because the patient's * own surfaces name the office from `poolThreadLabel` and must never name a receptionist. * * THIRD TIME this same fact has had to be taught to a client surface - after the inbox filter and the * conversation pane's render gate - which is why it lives here rather than in a panel. */ export const counterpartyReference = ( request: CommunicationRequest | undefined, viewerId: string | undefined, ): Reference | undefined => { if (!request) return undefined; const sender = request.sender; const recipient = request.recipient?.[0]; // A VISIT'S THREAD IS THE SAME QUESTION WITH A DIFFERENT ABSENCE, so it takes the same answer. // The room has no one person to name either - its roster is whoever was admitted - and the only // nameable party on it is likewise the PATIENT. Measured in the clinician's own inbox once room // threads started being listed: the row read "Unknown" until it was opened, because the swap below // had nothing to land on. The guard that follows carries across unchanged and is what keeps the // PATIENT's own side naming the visit rather than a clinician. if (isPoolThreadRequest(request) || isEncounterRoomThreadRequest(request)) { // THE OFFICE HAS NO PERSON TO NAME, so the only nameable party on a pool thread is the PATIENT // - whichever side of the shell happens to carry them. Deliberately NOT "the party who is not // the viewer": that reads the same on today's shells and would hand a PATIENT a receptionist's // name the moment a shell carried both sides, destroying the one-directional anonymity the pool // exists for. const patientParty = [sender, recipient].find((party) => party?.reference?.startsWith('Patient/'), ); // And never to the patient themselves - their own surfaces name the office from the pool label. // No `if (!patientParty) return undefined` guard: mutation proved it EQUIVALENT - with no patient // party this line already yields `undefined`. A line that reads as load-bearing and is not is worse // than no line. return !patientParty || referenceId(patientParty) === viewerId ? undefined : patientParty; } return referenceId(sender) === viewerId ? recipient : sender; }; // Listed office and visit rooms are usable before their first message; direct shells are not. export const threadBelongsInInbox = ( request: CommunicationRequest, messageCount: number, ): boolean => isPoolThreadRequest(request) || isEncounterRoomThreadRequest(request) || messageCount > 0; /** * Stamp (or clear) the pool marker on a thread shell from the thread the server described. * * It CLEARS as well as sets, and that direction is the load-bearing one: this runs over shells read * back out of the local cache, so a marker left behind by an older build has to be able to go away. A * stamp-only version would be indistinguishable from correct until the day it was wrong. */ export const withThreadPoolKey = ( request: CommunicationRequest, // WIDE ON PURPOSE. This only ever asks whether the thread IS a pool thread, so every other value is // one answer - and a union that lists them exhaustively has to be revisited each time the server grows // a thread type, for a function whose behaviour does not change. thread: { threadType?: string | null; poolKey?: string | null } | undefined, ): CommunicationRequest => { const poolKey = thread?.threadType === 'pool' ? thread.poolKey || FRONT_DESK_POOL_KEY : undefined; const others = (request.extension || []).filter((extension) => !isPoolKeyExtension(extension)); if (!poolKey) { return others.length === (request.extension || []).length ? request : { ...request, extension: others }; } return { ...request, extension: [...others, { url: POOL_KEY_EXTENSION_URLS[0], valueString: poolKey } as Extension], }; }; /** * A pool thread's id names what it is: `direct###pool:` (the server's * threadKeys.buildThreadId). Read it when nothing else is known yet, such as a reply arriving over the * socket on a thread this app has not loaded. Anything else is not a pool thread by its id. */ export const parsePoolThreadId = ( threadId: string | undefined, ): { organizationId: string; poolKey: string } | undefined => { const match = /^direct#[^#]+#([^#]+)#pool:([^#]+)$/.exec(threadId ?? ''); return match ? { organizationId: match[1], poolKey: match[2] } : undefined; }; /** * Who a reply on a thread is addressed to: its counterparty, or, on an office thread a patient opened, the patient's * own record. A pool has no owner to name, and the server refuses a message addressed to nobody. */ export const replyRecipientsFor = ( request: CommunicationRequest | undefined, counterparty: Reference | undefined, selfReference: string | null | undefined, ): Reference[] => { if (counterparty) return [counterparty]; return isPoolThreadRequest(request) && selfReference?.startsWith('Patient/') ? [{ reference: selfReference }] : []; };