import type { Communication } from "fhir/r4"; /** * The front-desk claim, as the client sees it. S5. * * THE SHAPE IS THE SERVER'S, DERIVED THERE AND NEVER RE-DERIVED HERE. A claim expires by the clock, and * a client that re-implemented the timeout would drift from the server about who is answering a patient - * so `claimed` arrives already decided (`utils/thread-claim.ts`, infra) and this file only reads it. */ export type ThreadClaimState = { threadId: string; claimed: boolean; claimedByAccountId: string | null; claimedAt: string | null; claimExpiresAt: string | null; }; /** * A reply on an unclaimed front-desk conversation asks for it: whoever answered is handling it. * * Walked on staging, the strip still said "Nobody is replying yet" after the desk had replied, which * tells the next receptionist the patient is still waiting. The SERVER still decides - this asks the way * "I'll take this" asks, and a colleague who got there first comes back as the answer to show. * * Undefined ownership is not unclaimed, so it asks nothing; and a claim that cannot be made leaves the * reply standing, because the reply is what mattered. */ export const claimAfterReply = async ( claim: ThreadClaimState | undefined, claimThread: (threadId: string) => Promise, ): Promise => { if (!claim || claim.claimed) return undefined; try { return await claimThread(claim.threadId); } catch { return undefined; } }; /** * A 409 from claim/release is an ANSWER, not a failure: it carries the receptionist who already holds the * conversation. Returning it lets the screen say *"Alice is replying"*; throwing it would make the client * treat "someone else has it" as "the request broke", which is how a soft lock turns into a dead button. * * Deliberately tolerant about the error's shape. The HTTP layer wraps failures differently depending on * the path, and a claim that silently reads as "free" because a body was nested one level deeper is worse * than one that reports nothing - so this returns undefined unless it can actually see a claim state, and * the caller rethrows. */ export const readClaimConflict = (error: unknown): ThreadClaimState | undefined => { const candidate = error as | { status?: number; statusCode?: number; body?: unknown; data?: unknown; response?: { status?: number; data?: unknown } } | undefined; const status = candidate?.status ?? candidate?.statusCode ?? candidate?.response?.status; if (status !== 409) return undefined; for (const payload of [candidate?.body, candidate?.data, candidate?.response?.data]) { const state = payload as Partial | undefined; if (state && typeof state.claimed === "boolean" && typeof state.threadId === "string") { return { threadId: state.threadId, claimed: state.claimed, claimedByAccountId: state.claimedByAccountId ?? null, claimedAt: state.claimedAt ?? null, claimExpiresAt: state.claimExpiresAt ?? null, }; } } return undefined; }; /** * What the composer should say about someone else's claim. * * IT NEVER BLOCKS. Spencer's ruling is a SOFT lock: the message names who is replying and the composer * stays usable, because a hard block cannot distinguish a colleague mid-sentence from a colleague whose * browser crashed - and at a front desk the second happens often enough that an inescapable lock generates * more phone calls than the duplicate replies it prevents. */ export const claimNotice = ( claim: ThreadClaimState | undefined, viewerAccountId: string | undefined, nameFor: (accountId: string) => string | undefined, vocabulary: ClaimVocabulary = CONVERSATION_CLAIM_VOCABULARY, ): string | undefined => { if (!claim?.claimed || !claim.claimedByAccountId) return undefined; // Your own claim is not news to you. if (claim.claimedByAccountId === viewerAccountId) return undefined; // A name we cannot resolve degrades to "someone", never to the raw account id - an id on screen tells // a receptionist nothing and looks like a fault. return vocabulary.heldByOther(nameFor(claim.claimedByAccountId) ?? "Someone"); }; /** * - THE SAME CLAIM, TWO JOBS, SO TWO SETS OF WORDS. * * Spencer, 2026-08-23, on an arriving referral: *"we can leverage '[receptionist name] is reading it' * and '[receptionist name] triaged it' system we already have there."* * * THE MECHANICS ARE IDENTICAL AND ARE NOT DUPLICATED - first write wins, expiry by the clock, claiming * marks rather than hides. Only the sentence changes, because a referral is a **work item** and nobody * ever replies to it: *"Nobody is replying yet"* on a queue of referrals describes a reply that is not * merely absent but impossible, and it is the kind of wrong wording a receptionist reads as a broken * screen. * * AND THERE IS DELIBERATELY NO "TRIAGED" STATE HERE. Spencer named one, and the claim model does not * have it: claim and release are the only two transitions that exist. Inventing a third in the client * would be a state the server cannot store and nothing could expire it is recorded as the open piece on * rather than faked. */ export type ClaimVocabulary = { heldByOther: (name: string) => string; heldByMe: string; unclaimed: string; take: string; taking: string; }; export const CONVERSATION_CLAIM_VOCABULARY: ClaimVocabulary = { heldByOther: (name) => `${name} is replying`, // receptionist.html#rec-message-phone's words for the desk's own claim. heldByMe: "You’re handling this", // Unclaimed is the NORMAL resting state of a pool conversation, not an error state. unclaimed: "Nobody is replying yet", take: "I'll take this", taking: "Claiming…", }; export const TRIAGE_CLAIM_VOCABULARY: ClaimVocabulary = { heldByOther: (name) => `${name} is reading it`, heldByMe: "You are working this", unclaimed: "Nobody has picked this up yet", take: "I'll take this", taking: "Claiming…", }; /** * Does this conversation wait on the desk? Yes when the latest message is the patient's, or someone * writing for them. Read by `sent` rather than list order, because a re-read can hand messages back in * any order. The deck says "Awaiting your reply" under the desk's own claim in exactly this case. */ export const awaitsDeskReply = (messages: Communication[]): boolean => { const latest = messages.reduce((last, message) => !last || Date.parse(message.sent ?? "") >= Date.parse(last.sent ?? "") ? message : last, undefined); const sender = latest?.sender?.reference ?? ""; return sender.startsWith("Patient/") || sender.startsWith("RelatedPerson/"); };