import { useCallback, useEffect, useRef, useState } from "react"; import { useHttpService } from "../services/HttpService.tsx"; import { useStaffPermissionServiceContext } from "../services"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { useListOwner, type ListOwner } from "./useListOwner"; /** * Lab results that came back over a practice's own connection and could not be matched to an order on their own. The * server decides which orders a result may answer and offers only those; this never searches for a patient or order. * A match is a person's act, recorded as theirs, and a filed result reaches no patient until it is released here. */ /** What a person may do with a held item, as the server says. */ export type HeldAction = "resolve" | "read-again" | "dismiss"; export interface HeldLabResult { id: string; /** A report; a report kept apart under another laboratory address or account; or an order whose results could not be searched for. */ kind?: "report" | "scope-conflict" | "order-read"; actions?: HeldAction[]; /** A read asked for earlier that has not finished. */ readAgain?: "queued"; /** A read asked for whose send was never confirmed: offered again under this same operation, never a new one. */ pendingRead?: { operationId: string }; connectionId: string; sourceType?: "DiagnosticReport" | "Observation"; sourceId?: string; sourceVersion?: string; /** For an order whose results could not be searched for: the order, and why. */ orderId?: string; reason?: string; receivedAt: string; heldReason?: string; heldDetail?: string; /** What the laboratory sent, enough to recognise it; never its values. */ report?: { code?: string; status?: string; issued?: string; subject?: { reference?: string; display?: string }; basedOn?: string[]; results?: number; performer?: string[]; }; /** Kept, but could not be read back as kept: shown as such, never in part. */ unreadable?: boolean; } export interface HeldResultCandidate { orderId: string; test?: string; requested?: string; status?: string; patient?: { reference?: string; name?: string }; } export interface HeldResultCandidates { orders: HeldResultCandidate[]; /** False when the server's search for them was cut short: there may be others. */ complete: boolean; reason?: string; } export interface ReadAgainAnswer { /** * Queued, or sent without a confirmed answer: never that it was read or delivered. An operation asked for before is * answered as what it already is: under way, or finished. */ queued: "queued" | "unknown" | "reading" | "finished"; message?: string; } /** Whether the server offers this action on this item; an answer without actions predates them and offers match and set aside. */ export const offers = (item: HeldLabResult, action: HeldAction): boolean => (item.actions ?? (item.kind ? ["dismiss"] : ["resolve", "dismiss"])).includes(action); export interface ResolvedHeldResult { outcome: "filed" | "held" | "superseded" | "moved-on"; reportId?: string; reason?: string; detail?: string; } // The held-results routes are served by the clinical stack. const API = environment.fhirClinicalApi; export const NO_LONGER_ALLOWED = "You can no longer match or set aside laboratory results here."; /** An ask begun for one viewer, acted-as subject or practice, refused once any of them changed. */ export class HeldOpeningRetiredError extends Error { constructor() { super("This page now belongs to another person or practice."); this.name = "HeldOpeningRetiredError"; } } /** * One opening of this list: who it belongs to, the effect setup it was begun in, and the authority it was begun under. * A setup retired at its cleanup and an authority withdrawn stay retired, whatever comes back after. */ export interface HeldOpening { owner: ListOwner; setup: object | null; authority: number; } /** One id per attempt, kept across retries of the same attempt so a resent request is recognised, not repeated. */ export const newOperationId = (): string => (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `op-${Date.now()}-${Math.random().toString(36).slice(2)}`) .replace(/[^A-Za-z0-9.-]/g, "").slice(0, 64); export const useHeldLabResults = () => { const { get, post } = useHttpService(); const { canManage } = useStaffPermissionServiceContext(); // Read at send, after the sign-in and proof waits, so an action started before `manage` was taken away cannot // still write. A write already sent is not disowned when its answer comes back. const mayAct = useRef(false); const allowed = canManage("diagnostics"); mayAct.current = allowed; // Each change of `manage` starts a new authority, so an action begun before it was withdrawn never writes once it // is given back; only a new action may. const authority = useRef({ allowed, generation: 0 }); if (authority.current.allowed !== allowed) authority.current = { allowed, generation: authority.current.generation + 1 }; const admit = useCallback((phase?: "answer") => { if (phase !== "answer" && !mayAct.current) throw new Error(NO_LONGER_ALLOWED); }, []); // Who this opening belongs to. Read again is admitted at send, and its answer published, only while it still is. const { owner, live: ownerLive } = useListOwner(post); // Every effect setup gets its own identity, retired for good at its cleanup: a development replay of the effects // never lets the first setup's answers through again. const setup = useRef(null); useEffect(() => { const mine = {}; setup.current = mine; return () => { if (setup.current === mine) setup.current = null; }; }, []); const opening = useCallback((): HeldOpening => ({ owner, setup: setup.current, authority: authority.current.generation }), [owner]); const live = useCallback((of: HeldOpening) => of.setup !== null && setup.current === of.setup && authority.current.generation === of.authority && ownerLive(of.owner), [ownerLive]); const admitFor = useCallback((of: HeldOpening) => (phase?: "answer") => { if (authority.current.generation !== of.authority) throw new Error(NO_LONGER_ALLOWED); if (!live(of)) throw new HeldOpeningRetiredError(); admit(phase); }, [live, admit]); const [items, setItems] = useState([]); const [after, setAfter] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const load = useCallback(async (page?: string) => { const of = opening(); const here = () => of.setup !== null && setup.current === of.setup && ownerLive(of.owner); setLoading(true); setError(undefined); try { const query = new URLSearchParams({ state: "held", ...(page ? { after: page } : {}) }).toString(); const found = await get<{ items?: HeldLabResult[]; after?: string }>(`${API}/lab-result-intake?${query}`); if (!here()) return; setItems((held) => (page ? [...held, ...(found?.items ?? [])] : found?.items ?? [])); setAfter(found?.after); } catch (failure) { if (!here()) return; console.error("Error loading held laboratory results:", failure); setError(relayServerFailure(failure, "Could not load the laboratory results waiting to be matched. Please try again.")); } finally { if (here()) setLoading(false); } }, [get, opening, ownerLive]); // A new viewer or practice starts from nothing: another's list is never shown or acted on. useEffect(() => { setItems([]); setAfter(undefined); void load(); }, [load]); const candidates = useCallback(async (id: string): Promise => { const found = await get>(`${API}/lab-result-intake/${encodeURIComponent(id)}/candidates`); return { orders: found?.orders ?? [], complete: found?.complete !== false, ...(found?.reason ? { reason: found.reason } : {}) }; }, [get]); // A result leaves this list only when the answer says it left the server's: filed, or a newer edition stands. One // still held stays with its new reason; after any other answer the list is read again. const resolve = useCallback(async (id: string, orderId: string, operationId: string) => { const result = await post(`${API}/lab-result-intake/${encodeURIComponent(id)}/resolve`, { orderId, operationId }, { admit }); if (result?.outcome === "filed" || result?.outcome === "superseded") setItems((held) => held.filter((each) => each.id !== id)); else if (result?.outcome === "held") { setItems((held) => held.map((each) => (each.id === id ? { ...each, heldReason: result.reason, heldDetail: result.detail } : each))); } else void load(); return result; }, [post, load, admit]); const dismiss = useCallback(async (id: string, reason: string, operationId: string) => { const result = await post<{ state?: string }>(`${API}/lab-result-intake/${encodeURIComponent(id)}/dismiss`, { reason, operationId }, { admit }); if (result?.state === "dismissed") setItems((held) => held.filter((each) => each.id !== id)); else void load(); return result; }, [post, load, admit]); // The item stays held either way; a confirmed queue marks it as waiting, an unconfirmed one leaves it to try again // under the same operation, and one already finished reads the list again. Only for the opening it was asked in. const readAgain = useCallback(async (id: string, operationId: string, of: HeldOpening = opening()) => { const admitHere = admitFor(of); const result = await post(`${API}/lab-result-intake/${encodeURIComponent(id)}/reread`, { operationId }, { admit: admitHere }); admitHere("answer"); if (result?.queued === "queued" || result?.queued === "reading") { setItems((held) => held.map((each) => (each.id === id ? { ...each, readAgain: "queued", pendingRead: undefined, actions: each.actions?.filter((a) => a !== "read-again") } : each))); } else if (result?.queued === "finished") void load(); return result; }, [post, opening, admitFor, load]); return { items, loading, error, more: Boolean(after), owner, authority: authority.current.generation, opening, live, reload: () => load(), loadMore: () => (after ? load(after) : Promise.resolve()), candidates, resolve, dismiss, readAgain, }; };