import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Communication, CommunicationRequest, Practitioner, Reference } from 'fhir/r4'; import { useAuthServiceContext, useCommunicationServiceContext, useCollaborationTeamServiceContext, usePatientServiceContext, useFhirServiceContext, } from '../services'; import { useCommunicationManager } from './useCommunicationManager'; import { useCapabilityServiceContext } from '../services/CapabilityService'; import { useInboxThreads } from './useInboxThreads'; import { formatFhirHumanName } from '../utils/fhirHumanName'; import { counterpartyRowName, NAME_LOADING, NAME_NOT_RECORDED, unnamedAuthorOf } from '../utils/messaging/counterpartyRowName'; import { summarizeThread } from '../utils/threadSummary'; import { fetchCollabTeamSummaries, type CollabTeamInput, type CollabTeamSummary } from '../utils/collabTeamSummaries'; import { buildOverviewFeed, type OverviewMessageRow, type OverviewRawRow, type OverviewThreadRef } from '../utils/overviewMessageFeed'; import { buildCollabTeamThreadId, buildCollabTeamAsideThreadId } from '../utils/collaborationTeamThread'; import { counterpartyReference, replyRecipientsFor } from '../utils/messaging/poolThread'; // - the FRONT DESK's rule, imported rather than re-derived. It lives under the // receptionist page today because that is where it was first needed; two copies of "who owes an answer" // is how one surface comes to disagree with another about whether somebody is waiting. import { isSelf, latestMessageIn, threadNeedsMyReply } from '../pages/DashboardPage/ReceptionistHomePage/frontDeskRail'; import { isDismissedBy, markNoReplyNeeded } from '../utils/messageDismissal'; import { namedSend, type SendOperation } from '../utils/messaging/sendOperation'; export const useOverviewMessageFeed = () => { const { currentUser } = useAuthServiceContext(); const { createCommunication, createCommunicationForThread, fetchCommunicationByThread, getCurrentUserReference, getReceiptReference, hasReadReceiptForReference, saveCommunicationBundle, participantNameRead, } = useCommunicationServiceContext(); const { listTeams } = useCollaborationTeamServiceContext(); // The team list is for clinical and patient-side roles. Every staff dashboard runs this feed, so asking // without checking drew a 403 on each load for an administrator, the desk, billing or HR. A role whose // capabilities leave the route out is not asked and has no team threads. While they are still loading the feed // waits (asking then drew the same 403 on the first load); capabilities that could not be read still ask. const { mayCall, state: capabilityState } = useCapabilityServiceContext(); const teamsReadable = mayCall('GET', '/collaboration-team') !== false; const { fetchPatient } = usePatientServiceContext(); const { extractReferenceId } = useFhirServiceContext(); const { patientCommunicationRequests, staffCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, loading: directLoading, } = useCommunicationManager(); const selfId = currentUser?.fhir?.id; const selfRef = getCurrentUserReference(); const receiptRef = getReceiptReference(); // --- direct rows (patient + colleague) ------------------------------------------------------- // The shared resolver, as the inbox uses: a front-desk pool or visit-room shell has no sender to swap, and its // only nameable party is the patient, whichever side carries them. const counterparty = useCallback( (request: CommunicationRequest): Reference | undefined => counterpartyReference(request, selfId), [selfId], ); const getCounterpartyId = useCallback( (request: CommunicationRequest): string | undefined => extractReferenceId(counterparty(request)) as string | undefined, [counterparty, extractReferenceId], ); const patientInbox = useInboxThreads(patientCommunicationRequests, getCounterpartyId, { getMessagesForRequest, getUnreadCountForRequest }); const staffInbox = useInboxThreads(staffCommunicationRequests, getCounterpartyId, { getMessagesForRequest, getUnreadCountForRequest }); const directRows = useMemo(() => { const toRow = (kind: 'patient' | 'colleague') => (request: CommunicationRequest): OverviewRawRow => { const ref = counterparty(request); // Named, withheld, unread or loading - never the counterparty's id. const id = extractReferenceId(ref) as string | undefined; const rowName = counterpartyRowName({ display: ref?.display, id, resourceType: ref?.reference?.split('/')[0], read: participantNameRead(id), }); const name = rowName.kind === 'pending' ? NAME_LOADING : rowName.text; const messages = getMessagesForRequest(request) as Communication[]; const { preview, timestamp } = summarizeThread(messages, selfRef, hasReadReceiptForReference, { unnamedAuthor: unnamedAuthorOf(rowName, ref?.reference), }); const threadRef: OverviewThreadRef = { kind, threadId: request.id as string, requestId: request.id as string, recipientReferences: replyRecipientsFor(request, ref, selfRef), }; return { key: `${kind}:${request.id}`, kind, counterpartyName: name, preview, timestamp: timestamp || (request.authoredOn ? new Date(request.authoredOn).getTime() : 0), unread: getUnreadCountForRequest(request.id as string), // - REUSED, never re-derived. `threadAwaitsOurReply` is the front desk's rule // and two hand-kept copies of "who owes an answer" is exactly how one surface comes to // disagree with another about whether a patient is waiting. // `isSelf`, not `isStaffSide`: these are the clinician's OWN threads, not a pooled desk. A // colleague answering a thread addressed to me does not clear my obligation. // - minus my own bookkeeping. A thread I have marked "no reply needed" is still // awaiting a reply as a fact about the conversation; it is no longer awaiting MINE. And it // comes back by itself the moment the patient writes again, because the dismissal is on the // message that was latest when I read it, not on the thread. awaitingReply: threadNeedsMyReply( request, messages, isSelf(selfRef ?? undefined), (message) => isDismissedBy(message, selfRef ?? undefined), ), // What this row was built from. `dismissThread` refuses if the thread has moved past it. observedLatestMessageId: latestMessageIn(messages)?.id, threadRef, }; }; return [ ...patientInbox.inboxThreads.map(toRow('patient')), ...staffInbox.inboxThreads.map(toRow('colleague')), ]; }, [patientInbox.inboxThreads, staffInbox.inboxThreads, counterparty, extractReferenceId, getMessagesForRequest, getUnreadCountForRequest, hasReadReceiptForReference, selfRef, participantNameRead]); // --- collab rows (team + aside) -------------------------------------------------------------- const [summaries, setSummaries] = useState([]); const [collabLoading, setCollabLoading] = useState(true); // Some team reads failed, so owed replies may be missing from the count. Set per run: a newer run // cancels an older one, so only the latest reads decide it. const [collabIncomplete, setCollabIncomplete] = useState(false); const [patientNames, setPatientNames] = useState>({}); const [nonce, setNonce] = useState(0); // bump to refetch collab summaries const refresh = useCallback(() => setNonce((n) => n + 1), []); useEffect(() => { let cancelled = false; const run = async () => { if (capabilityState === 'loading') return; if (!teamsReadable) { setSummaries([]); setCollabIncomplete(false); setCollabLoading(false); return; } setCollabLoading(true); try { const teams = await listTeams(); const callerRef = selfId ? `Practitioner/${selfId}` : undefined; const active: CollabTeamInput[] = teams .filter((t) => t.members.find((m) => m.memberRef === callerRef)?.state === 'active') .map((t) => ({ teamId: t.teamId, patientId: t.patientId, name: t.name, closed: t.status === 'dissolved' })); const result = await fetchCollabTeamSummaries(active, { fetchByThread: fetchCommunicationByThread, selfRef, receiptRef, hasReadReceipt: hasReadReceiptForReference, }); if (!cancelled) { setSummaries(result); setCollabIncomplete(result.some((summary) => summary.unreadable)); } } catch { if (!cancelled) { setSummaries([]); setCollabIncomplete(true); } } finally { if (!cancelled) setCollabLoading(false); } }; void run(); return () => { cancelled = true; }; }, [listTeams, capabilityState, teamsReadable, fetchCommunicationByThread, hasReadReceiptForReference, selfRef, receiptRef, selfId, nonce]); // resolve each team's patient display name (deduped, cached, best-effort) const neededPatientIds = useMemo(() => Array.from(new Set(summaries.map((s) => s.patientId))), [summaries]); const requestedNamesRef = useRef>(new Set()); useEffect(() => { let cancelled = false; neededPatientIds.forEach((pid) => { if (requestedNamesRef.current.has(pid)) return; requestedNamesRef.current.add(pid); fetchPatient(pid) .then((p) => { if (!cancelled && p) setPatientNames((prev) => ({ ...prev, [pid]: formatFhirHumanName(p.name) || NAME_NOT_RECORDED })); }) .catch(() => { requestedNamesRef.current.delete(pid); }); // allow a later refresh to retry }); return () => { cancelled = true; }; }, [neededPatientIds, fetchPatient]); const collabRows = useMemo(() => { const rows: OverviewRawRow[] = []; // A team thread owes ME an answer when the last word was not mine. A CLOSED team owes nothing - // it is read-only, so listing it as work would be asking for a reply the product refuses to send. // And an unattributable last word counts as owed, matching the direct rule: an extra row is noise, // a missing one is somebody waiting on nobody. const awaits = (summary: { lastSenderReference?: string }, closed: boolean): boolean => !closed && summary.lastSenderReference !== selfRef; summaries.forEach((s) => { const patientName = patientNames[s.patientId]; if (s.public.timestamp > 0) rows.push({ key: `team:${s.teamId}`, kind: 'team', patientName, teamName: s.teamName, preview: s.public.preview, timestamp: s.public.timestamp, unread: s.public.unread, awaitingReply: awaits(s.public, s.closed), threadRef: { kind: 'team', threadId: buildCollabTeamThreadId(s.teamId), teamId: s.teamId, variant: 'public', recipientReferences: [], closed: s.closed }, }); if (s.aside.timestamp > 0) rows.push({ key: `aside:${s.teamId}`, kind: 'aside', patientName, teamName: s.teamName, preview: s.aside.preview, timestamp: s.aside.timestamp, unread: s.aside.unread, awaitingReply: awaits(s.aside, s.closed), threadRef: { kind: 'aside', threadId: buildCollabTeamAsideThreadId(s.teamId), teamId: s.teamId, variant: 'aside', recipientReferences: [], closed: s.closed }, }); }); return rows; }, [summaries, patientNames, selfRef]); const feed = useMemo(() => buildOverviewFeed([...directRows, ...collabRows]), [directRows, collabRows]); // --- send ------------------------------------------------------------------------------------ // The reply box holds the send's name and settles it; this only sends it under that name. const sendReply = useCallback(async (row: OverviewMessageRow, text: string, send?: SendOperation) => { if (!text.trim() || row.threadRef.closed) return; const self = currentUser?.fhir as Practitioner; const built = createCommunicationForThread(text, row.threadRef.threadId, self, row.threadRef.recipientReferences); await createCommunication(namedSend(built, send), send); if (row.kind === 'team' || row.kind === 'aside') refresh(); // direct threads live-update via CommunicationService }, [currentUser, createCommunicationForThread, createCommunication, refresh]); /** * - take a thread out of my owed list without saying anything. * * It writes to the message that is latest RIGHT NOW, read from the manager rather than from the * row the screen rendered. If a patient message arrived since the render, this marks THAT one - and * the practitioner is about to see it, because the row re-renders from the same source. * * IT THROWS ON FAILURE, and that is deliberate: `saveCommunicationBundle` swallows the error and * returns the resources it managed to write, so a silent failure would leave the practitioner * believing they had cleared a thread that is still owed. The panel reports it the same way a failed * reply is reported. */ const dismissThread = useCallback(async (row: OverviewMessageRow) => { if (row.kind !== 'patient' && row.kind !== 'colleague') return; const request = [...patientCommunicationRequests, ...staffCommunicationRequests] .find((candidate) => candidate.id === row.threadRef.requestId); if (!request) throw new Error('That conversation could not be found.'); const messages = getMessagesForRequest(request) as Communication[]; const latest = latestMessageIn(messages); // Nothing to mark. A thread whose first words have not arrived as a `Communication` yet is the // newest request there is, and quietly reporting success would drop it off the only list showing it. if (!latest?.id) throw new Error('There is nothing to dismiss in that conversation yet.'); if (!selfRef) throw new Error('We could not tell who you are signed in as.'); // - YOU MAY ONLY DISMISS THE MESSAGE YOU ACTUALLY SAW. // // The row carries the id that was latest when it rendered. If the thread has moved past it, a // patient wrote between the render and this click, and marking the CURRENT latest would clear a // message nobody has read - taking the thread out of the owed count while somebody waits on an // answer that is now counted nowhere. Refusing is cheap and self-correcting: the feed re-renders // with the new message and the practitioner decides again, having seen it. // // Deliberately compared by ID rather than by count or timestamp. A count cannot tell an arrival // from a deletion, and two messages can share a `sent` instant; the id is the only value that // answers "is this still the thing I was looking at". // // The guard lives here rather than in the panel because this is the single place the write is // made - a check beside the button is one new caller away from being skipped. if (row.observedLatestMessageId && row.observedLatestMessageId !== latest.id) { throw new Error('A newer message arrived in that conversation. Have a look before dismissing it.'); } const marked = markNoReplyNeeded(latest, selfRef, new Date().toISOString()); // Already dismissed by me - idempotent, and no write worth making. if (marked === latest) return; const saved = await saveCommunicationBundle([marked]); if (saved.length === 0) throw new Error('That could not be saved.'); }, [ patientCommunicationRequests, staffCommunicationRequests, getMessagesForRequest, saveCommunicationBundle, selfRef, ]); return { rows: feed.rows, totalUnread: feed.totalUnread, // - over EVERY thread, not the six the feed shows, and slice 4's panel lists the // very same rows so the count and the list cannot describe different sets. awaitingReplyRows: feed.awaitingReplyRows, awaitingReplyCount: feed.awaitingReplyCount, // Reported as a distinct state, never folded into a `0`. "Nothing awaits your reply" and "we have // not finished asking" are different sentences, and the owed card may only print the first. loading: directLoading || collabLoading, teamReadsIncomplete: collabIncomplete, sendReply, dismissThread, refresh, }; };