import { useCallback, useEffect, useRef, useState } from "react"; import { useHttpService } from "../services/HttpService.tsx"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { isSaveRetired, SaveRetiredError } from "../utils/saveOperation"; import { onViewerChange } from "../utils/writeThroughHold"; import { readReferralContacts } from "../utils/referralContactHistory"; import type { ReferralContactAttempt } from "../services/ReferralWorkbenchService"; import { isReferralState, type ReferralDirection, type ReferralState, } from "../utils/referralQueue"; /** * - the coordinator's queue, from the one endpoint that can list it. * * IT IS NOT A TASK SEARCH, and it cannot be. Every other Task read in this product is patient-scoped: * `GET /R4/Task` refuses a search without a `patient`, deliberately, as a disclosure fence. A * coordinator's queue is org-wide by definition - every referral this practice is working, across * patients - so it has its own route, which rebuilds the org fence rather than relaxing that one. * * The server returns a PROJECTION: what is waiting and where it got to, and none of the clinical * detail hanging off each referral. This hook does not enrich it. A queue is not a record. */ export interface ReferralQueueRow { contacts?: ReferralContactAttempt[]; taskId?: string; /** Typed in here and not yet matched to a patient, so it has no order or Task; it opens the match step. */ loggedReferralId?: string; /** A logged referral that arrived by fax; only a held document can be read. */ fax?: ReferralFax; orderId?: string; state?: ReferralState; performerDisplay?: string; /** * The recipient's NPI, when the referral was addressed from the directory rather than typed by hand. * * It is what makes the shared referral-contact book reachable: that book is keyed on NPI, because a * display string identifies nobody - two clinics share a name and one clinic is spelled three ways. * Absent is ordinary and must stay usable; a hand-typed recipient has no NPI and never will. */ performerNpi?: string; /** Absent when the recipient is not on Akashic - the case the sender closes itself. */ performerReference?: string; requesterReference?: string; /** The practice that asked, resolved server-side through a PractitionerRole requester. */ requesterOrganizationId?: string; /** - who SENT it. On an inbound row the performer is us, so this is the readable half. */ requesterDisplay?: string; authoredOn?: string; /** * WHY IT WAS CLOSED - present only on a referral the receiving practice actually closed with a reason. * * A fixed code, never free text, because it crosses an organization boundary: the receiving practice * writes it and the sending practice reads it. A referral that expired on the clock carries none, which * is what lets the two be told apart. */ closureReason?: string; /** * WHO THE REFERRAL IS FOR - Spencer, 2026-08-26: *"What if someone asks 'has Steve Smith's * referral been sent?', how would they know if they can't see the names on the referrals they're * sending out?"* The one question this queue is actually asked, and it could not answer it. */ patientDisplay?: string; /** Why it was placed - what a coordinator relays when they ring the receiving practice. */ reasonText?: string; patientBookedAt?: string; /** * How delivery to a practice on Akashic went, absent for a referral never routed in-system. The order * is saved before delivery runs and its state stays `sent` either way, so this is the only thing that * tells a referral the receiver has from one it cannot see. */ delivery?: ReferralDelivery; /** The sender withdrew it (referrals.html#s-withdraw-referral): why, when, and who, by name. */ withdrawal?: ReferralWithdrawal; } export type ReferralWithdrawalReason = "wrong-practice" | "placed-in-error" | "other"; export const REFERRAL_WITHDRAWAL_REASONS: readonly ReferralWithdrawalReason[] = ["wrong-practice", "placed-in-error", "other"]; export interface ReferralWithdrawal { reason: ReferralWithdrawalReason; at: string; byDisplay?: string; } /** A withdrawal with a reason this build does not know reads as none, like an unknown state. */ const toWithdrawal = (raw: unknown): ReferralWithdrawal | undefined => { const w = raw as { reason?: unknown; at?: unknown; byDisplay?: unknown } | undefined; if (!w || typeof w.at !== "string" || !REFERRAL_WITHDRAWAL_REASONS.includes(w.reason as ReferralWithdrawalReason)) return undefined; return { reason: w.reason as ReferralWithdrawalReason, at: w.at, ...(typeof w.byDisplay === "string" && w.byDisplay ? { byDisplay: w.byDisplay } : {}), }; }; export type ReferralDelivery = "pending" | "delivered" | "failed"; const DELIVERIES: readonly string[] = ["pending", "delivered", "failed"]; const isReferralDelivery = (value: unknown): value is ReferralDelivery => typeof value === "string" && DELIVERIES.includes(value); export interface ReferralFax { pages?: number; document: "held" | "too-large" | "missed" | "unreadable"; } const FAX_DOCUMENTS: readonly ReferralFax["document"][] = ["held", "too-large", "missed", "unreadable"]; const toFax = (raw?: { pages?: unknown; document?: unknown }): ReferralFax | undefined => raw && FAX_DOCUMENTS.includes(raw.document as ReferralFax["document"]) ? { document: raw.document as ReferralFax["document"], ...(typeof raw.pages === "number" ? { pages: raw.pages } : {}) } : undefined; export interface ReferralQueueResponse { referrals?: { contacts?: unknown; taskId?: string; loggedReferralId?: string; fax?: { pages?: unknown; document?: unknown }; orderId?: string; state?: string; performerDisplay?: string; performerNpi?: string; performerReference?: string; requesterReference?: string; /** The practice that asked, resolved server-side through a PractitionerRole requester. */ requesterOrganizationId?: string; requesterDisplay?: string; authoredOn?: string; patientDisplay?: string; reasonText?: string; closureReason?: string; patientBookedAt?: string; delivery?: string; withdrawal?: unknown; }[]; /** * The route could not read the whole queue. Sent only when it is true, and it is the reason this * hook does not simply hand back a list: a COUNT taken from an incomplete read is a confident wrong * number, and a referral missing from it looks like a referral nobody placed. */ truncated?: boolean; /** At least one referral's order could not be read, so its state and patient are unknown. */ unreadable?: boolean; } /** * A state the server sends that this build does not know reads as NO state rather than as itself. * Rendering an unknown code would put a word on screen that means nothing to the coordinator and that no * control can act on. */ export const toRow = (raw: NonNullable[number]): ReferralQueueRow => ({ contacts: readReferralContacts(raw.contacts), taskId: raw.taskId, loggedReferralId: raw.loggedReferralId, ...(toFax(raw.fax) ? { fax: toFax(raw.fax) } : {}), orderId: raw.orderId, state: isReferralState(raw.state) ? raw.state : undefined, performerDisplay: raw.performerDisplay, performerNpi: raw.performerNpi, performerReference: raw.performerReference, requesterReference: raw.requesterReference, requesterOrganizationId: raw.requesterOrganizationId, requesterDisplay: raw.requesterDisplay, authoredOn: raw.authoredOn, patientDisplay: raw.patientDisplay, reasonText: raw.reasonText, closureReason: raw.closureReason, patientBookedAt: raw.patientBookedAt, delivery: isReferralDelivery(raw.delivery) ? raw.delivery : undefined, withdrawal: toWithdrawal(raw.withdrawal), }); /** * WHICH COLUMN, decided by who PLACED the order and never by who is looking. * An unknown requester reads as INBOUND, and that is the safe direction: most referrals arriving today * are typed in by hand from a fax because the sender is not on Akashic, so "somebody sent this to us and * we do not know who" is the ordinary case. Defaulting to outbound would put another practice's referral * in the list this practice believes it sent. */ export const directionOf = ( row: ReferralQueueRow, organizationId: string | undefined, ): ReferralDirection => { if (!organizationId) return "inbound"; // THE PRACTICE THE REQUESTER BELONGS TO, when the server could resolve it. An outgoing referral names // the ordering PractitionerRole rather than the practice, and a role cannot be resolved from here. if (row.requesterOrganizationId) { return row.requesterOrganizationId === organizationId ? "outbound" : "inbound"; } if (!row.requesterReference) return "inbound"; return row.requesterReference === `Organization/${organizationId}` ? "outbound" : "inbound"; }; /** What a quiet re-read came to: listed, a read to try again, or a refusal that retrying will not fix. */ export type QueueRefresh = "read" | "retry" | "refused"; /** How long a quiet re-read may take before it is cancelled and its callers are told to try again. */ export const QUIET_READ_DEADLINE_MS = 20_000; const refusedOutright = (err: unknown) => [401, 403].includes((err as { status?: number })?.status ?? 0); // `enabled`: false when the server has said this person does not work referrals; nothing is read then. export const useReferralQueue = (organizationId?: string, enabled = true) => { const { get } = useHttpService(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [truncated, setTruncated] = useState(false); // The server refused this person the queue. What was listed is cleared, not kept for a retry. const [refused, setRefused] = useState(false); // Read on every entry point, so a callback held by a dialog or a timer cannot read a queue that is off. const enabledRef = useRef(enabled); enabledRef.current = enabled; // Reads can finish out of order. Each takes a number when it starts, and only a read newer than the // last one applied may replace the list; a read from before a practice switch or unmount applies nothing. const started = useRef(0); const applied = useRef(0); const scope = useRef(0); const lastLoad = useRef(0); // Reads still in flight, cancelled the moment the practice, the viewer or the permission to read moves on. const inFlight = useRef(new Set()); const practice = useRef(organizationId); practice.current = organizationId; // Every outcome, a failure too, retires the reads started before it, and is applied in the same step // that checks it is still the newest in this practice. const read = useCallback(async (asLoad: boolean, controller = new AbortController()) => { if (!enabledRef.current) return "disabled" as const; const ticket = ++started.current; const inScope = scope.current; const newest = () => inScope === scope.current && ticket > applied.current; if (asLoad) lastLoad.current = ticket; const settleLoading = () => { if (asLoad && lastLoad.current === ticket) setLoading(false); }; // Admitted for this scope only: checked before the request is prepared and again just before it is sent. inFlight.current.add(controller); const admit = () => { if (inScope !== scope.current || !enabledRef.current) throw new SaveRetiredError(); }; try { const response = await get(`${environment.fhirBaseApi}/referral/queue`, { admit, signal: controller.signal, organizationId: practice.current, }); // A read cancelled at its deadline publishes nothing, whether or not a newer read has answered yet. if (!newest() || controller.signal.aborted) { if (inScope === scope.current) settleLoading(); return "stale" as const; } applied.current = ticket; setRows((response?.referrals ?? []).map(toRow)); setTruncated(response?.truncated === true); setRefused(false); setError(undefined); setLoading(false); return "read" as const; } catch (err) { if (!newest() || isSaveRetired(err) || controller.signal.aborted) { if (inScope === scope.current) settleLoading(); return "stale" as const; } applied.current = ticket; console.error("Error reading the referral queue:", err); if (refusedOutright(err)) { // An authoritative refusal: every read prepared before it is retired with it, and none starts on its own. scope.current += 1; applied.current = started.current; inFlight.current.forEach((other) => { if (other !== controller) other.abort(); }); // Nothing listed before it stays on screen or openable. setRows([]); setTruncated(false); setRefused(true); setError(undefined); setLoading(false); return "refused" as const; } if (asLoad) setError(relayServerFailure(err, "Could not load referrals. Please try again.")); settleLoading(); return "failed" as const; } finally { inFlight.current.delete(controller); } }, [get]); const load = useCallback(async () => { if (!enabledRef.current) return; setLoading(true); await read(true); }, [read]); /** Asks again without the loading state; a failed read keeps the list that is on screen. One read per call. */ const refresh = useCallback(async (): Promise => { const outcome = await read(false); if (outcome === "refused" || outcome === "disabled") return "refused"; return outcome === "read" ? "read" : "retry"; }, [read]); // A background re-read already under way in this scope answers every background caller that asks meanwhile: an // arrival poll, a return to the page and another tab's claim landing together make one read, not three. const quiet = useRef<{ inScope: number; answer: Promise; deadline: () => void }>(); /** * The background re-read: shared while one is under way, and bounded. A read that has not answered by its * deadline is cancelled and answers "retry", so it never holds the shared entry for good, and if it answers later * anyway it publishes nothing. An explicit `refresh` is never folded into it. */ const refreshQuietly = useCallback((): Promise => { if (quiet.current?.inScope === scope.current) return quiet.current.answer; const controller = new AbortController(); let deadline: ReturnType | undefined; const late = new Promise<"late">((resolve) => { deadline = setTimeout(() => resolve("late"), QUIET_READ_DEADLINE_MS); }); const answer = Promise.race([read(false, controller), late]).then((outcome): QueueRefresh => { clearTimeout(deadline); if (outcome === "late") controller.abort(); if (quiet.current?.answer === answer) quiet.current = undefined; if (outcome === "refused" || outcome === "disabled") return "refused"; return outcome === "read" ? "read" : "retry"; }); quiet.current = { inScope: scope.current, answer, deadline: () => clearTimeout(deadline) }; return answer; }, [read]); // A new scope: every read started before it is cancelled and lands nowhere, and the list it showed is cleared. const startScope = useCallback(() => { quiet.current?.deadline(); quiet.current = undefined; scope.current += 1; applied.current = started.current; inFlight.current.forEach((controller) => controller.abort()); inFlight.current.clear(); setRows([]); setTruncated(false); setRefused(false); setError(undefined); if (enabledRef.current) void load(); else setLoading(false); }, [load]); useEffect(() => { // The server takes the organization from the authorizer context. A switch of practice, of the person viewing, // or of the permission to read starts a new scope rather than showing the old list under the new one. startScope(); const stopWatching = onViewerChange(startScope); return () => { stopWatching(); quiet.current?.deadline(); quiet.current = undefined; scope.current += 1; inFlight.current.forEach((controller) => controller.abort()); inFlight.current.clear(); }; }, [startScope, organizationId, enabled]); return { rows, loading, error, truncated, refused, reload: load, refresh, refreshQuietly, organizationId }; };