import type { Communication } from 'fhir/r4'; /** * - what a patient's own message is actually DOING, derived from read receipts. * * THE DEFECT THIS REPLACES: there is exactly one `CheckCheck` in the client and nothing about read * state gates it, so every message a patient sends is marked with the universal "somebody has read this" * the instant they press send. Nobody has. The receipt data exists, is written and is persisted - the * indicator simply never looked at it. * * Why it is worth more than a cosmetic fix, in one sentence Spencer's ruling turns on: **a patient who * believes they have been read does not write again and does not phone.** The cost is not confusion; it is * a patient waiting quietly on an answer nobody knows they are owed. * * NOTHING HERE MAY INFER "viewed" FROM ANYTHING BUT A RECEIPT - not thread state, not a claim, not an * unread counter. Inferring it from something adjacent is exactly how the present lie was built. */ /** * Every dialect a stored receipt may carry. * * FOUR URLS AND THREE VALUE TYPES, AND THAT IS NOT DEFENSIVENESS - IT IS THE DIFFERENCE BETWEEN THE * TRUTH AND THE SAME LIE INVERTED. A resolver that recognised only the canonical url and `valueInstant` * would see NO receipts on a message stamped by an older build, and "no receipts" renders as *queued* - * telling the patient nobody has read it while somebody has. Both halves are copied from the reader that * already ships (`Chat.tsx`), which accepts all of these. * Chat.tsx now imports this list instead of holding its own copy. A THIRD copy still lives in * `CommunicationService.tsx`; it is outside this card's lane, and the day the two disagree is the day a * receipt is written in a dialect the reader does not accept. */ export const READ_RECEIPT_EXTENSION_URLS = [ 'https://akashichealthrecords.com/fhir/StructureDefinition/read-receipt', 'https://akashic.health/fhir/StructureDefinition/read-receipt', 'urn:akashic:read:receipt', 'akashic-read-receipt', ]; export type MessageReader = { reference: string; readAt: string }; export type MessageStatus = | { kind: 'queued' } | { kind: 'viewed'; readers: MessageReader[] } | { kind: 'answered' } | { kind: 'failed' }; const readReceiptsOf = (message: Communication): MessageReader[] => (message.extension ?? []) .filter((extension) => !!extension?.url && READ_RECEIPT_EXTENSION_URLS.includes(extension.url)) .map((extension) => { const children = extension.extension ?? []; const reference = children.find((child) => child.url === 'recipient')?.valueReference?.reference; const readAtChild = children.find((child) => child.url === 'readAt'); const readAt = readAtChild?.valueInstant || readAtChild?.valueDateTime || readAtChild?.valueString; return reference && readAt ? { reference, readAt } : undefined; }) .filter((reader): reader is MessageReader => Boolean(reader)); /** * The status of the patient's LATEST outbound message, or `null` when they have sent nothing. * * ONE STATUS LINE, ON THE LAST THING THEY SAID, AND ONLY WHILE NOTHING HAS ANSWERED IT. Three questions * in a row produce one line under the last, not three - and the moment any staff reply lands the line * disappears, because a reply says everything the line was there to say. * * ORDER COMES FROM THE ARRAY, not from `sent`. This is handed the same list the pane renders, in the * order it renders it, so "after" here means exactly what the reader sees as after. Sorting by `sent` * independently would let the indicator and the transcript disagree about which message is last, and the * two would drift apart on any message with a missing or skewed timestamp. */ export const resolveLatestOutboundStatus = ( messages: Communication[], selfReference: string, failedIds?: Set, ): { messageId: string; status: MessageStatus } | null => { let latestIndex = -1; for (let index = 0; index < messages.length; index += 1) { if (messages[index]?.sender?.reference === selfReference) latestIndex = index; } if (latestIndex === -1) return null; const latest = messages[latestIndex]; const messageId = latest.id ?? ''; // FAILED IS CHECKED FIRST, and it has to be: a send that never reached the server has no id the // server would recognise and no receipt it could ever gain, so every other branch would quietly // classify it as `queued` - a tick, on a message that does not exist. Today failure and success are // indistinguishable on screen, which is the same lie with an extra step. if (failedIds?.has(messageId)) return { messageId, status: { kind: 'failed' } }; const answered = messages .slice(latestIndex + 1) .some((message) => !!message?.sender?.reference && message.sender.reference !== selfReference); if (answered) return { messageId, status: { kind: 'answered' } }; // The patient's own receipt does not count. A patient reading their own message back is not // somebody having read it, and their own client stamps one on open like any other participant's. const readers = readReceiptsOf(latest) .filter((reader) => reader.reference !== selfReference) // Ordered by WHEN THEY LOOKED, so the ladder stacks in the order people actually did. .sort((left, right) => left.readAt.localeCompare(right.readAt)); return readers.length > 0 ? { messageId, status: { kind: 'viewed', readers } } : { messageId, status: { kind: 'queued' } }; }; /** * What to call a reader on a patient-facing screen. * * NEVER THE RAW REFERENCE, and never the word "Unknown". *"Viewed by Practitioner/47bcb7b4-..."* is not * an answer, and *"Viewed by Unknown"* is worse than saying nothing - it reads as though the practice does * not know who its own staff are. * An unresolvable reader falls back to the PRACTICE, which is true and useful: somebody at the practice * has read it, and that is the fact the patient wanted. And if even the practice name is unknown, this * returns an empty string so the caller can render *"Viewed"* alone - still true, and still not an id. */ export const readerLabel = ( reference: string, nameByReference: Record, practiceName: string, ): string => (nameByReference[reference] ?? '').trim() || (practiceName ?? '').trim();