import { useCallback, useEffect, useRef, useState } from "react"; import { useHttpService } from "../services/HttpService.tsx"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; /** * The laboratories a practice sends to, and the three things it does to that list. * * NOTHING HERE HOLDS A CREDENTIAL. The server answers only whether one has been saved; the secret * half lives in Secrets Manager and is never written to FHIR, never returned and never logged. A * screen that could read one back is a screen a shared front-desk machine can leak. * * A WRITE UPDATES THE LIST FROM WHAT THE WRITE RETURNED, rather than re-reading it. The list is a * SEARCH, and a search is eventually consistent -- about a minute behind a write. Re-reading here * would blank the row somebody just added and then bring it back on its own, which is the shape this * bench has paid for four times now. The server returns the stored connection precisely so the * screen does not have to ask again. * * SWITCHING ONE OFF IS AN UPDATE, not a delete, and the hook has no delete to offer. A laboratory a * practice has stopped using keeps its account number and the history of what went through it -- and * that account number is what the practice needs when the laboratory rings about work sent last year. */ // Another practice on Akashic, linked in-network. Named by its NPI alone: the server finds it, names it and // links it, and the link is never switched to or from a transport afterwards. export const AKASHIC_LAB_CONNECTION = "akashic"; // `sends`: whether Akashic sends orders over the transport at all. The others are recorded for the requisition, and // the server puts their orders on paper. export const LAB_CONNECTION_TYPES = [ { id: AKASHIC_LAB_CONNECTION, label: "On Akashic", sends: true, note: "Another practice on Akashic. Its NPI is all it takes; Akashic names it." }, { id: "hl7v2-mllp", label: "HL7 v2 over MLLP", sends: false, note: "A host and port. Akashic does not send over it yet, so orders go on paper." }, { id: "hl7-fhir-rest", label: "FHIR REST", sends: true, note: "A base URL. Orders for paired tests are sent once a sign-in is saved." }, { id: "secure-email", label: "Secure email", sends: false, note: "An address. Akashic does not send over it yet, so orders go on paper." }, { id: "direct-project", label: "Direct", sends: false, note: "A Direct address. Akashic does not send over it yet, so orders go on paper." }, ] as const; export type LabConnectionType = (typeof LAB_CONNECTION_TYPES)[number]["id"]; /** The reshapings a laboratory field can need. Fixed: the server will refuse any other. */ export const LAB_FORMATS = [ { id: "hl7-datetime", label: "HL7 date and time" }, { id: "hl7-date", label: "HL7 date" }, { id: "digits", label: "Digits only" }, { id: "upper", label: "UPPERCASE" }, { id: "lower", label: "lowercase" }, ] as const; /** * The values a laboratory usually asks for, named so nobody has to type a path. * * WHY THIS LIST EXISTS. The expression is a plain FHIRPath and the server validates it, but a * person configuring a laboratory should not have to know that `order.code.coding[0].code` is where * the test code lives. These are the ones every laboratory wants; anything else is typed, and the * typing box is deliberately the second option rather than the only one. */ export const COMMON_SOURCES = [ { label: "Test code", path: "order.code.coding[0].code" }, { label: "Test name", path: "order.code.text" }, { label: "Patient date of birth", path: "patient.birthDate" }, { label: "Patient sex", path: "patient.gender" }, { label: "Patient record number", path: "patient.identifier[0].value" }, { label: "Patient family name", path: "patient.name[0].family" }, { label: "Patient given name", path: "patient.name[0].given[0]" }, { label: "When it was ordered", path: "order.authoredOn" }, { label: "Priority", path: "order.priority" }, { label: "Our account number with them", path: "account.accountNumber" }, { label: "Our practice name", path: "practice.name" }, { label: "Ordering clinician", path: "requester.name[0].family" }, ] as const; export interface LabFieldMapping { /** Where it goes in the laboratory's own message. Their words, not ours. */ target: string; /** Which value to take. Exactly one of these two. */ read?: string; constant?: string; format?: string; /** Value translation, e.g. male to M. */ map?: Record; whenMissing?: "omit" | "refuse"; } export interface LabProfile { npi: string; name?: string; /** Bumped on every save, so a practice that adopted it is not moved without noticing. */ version: number; fields: LabFieldMapping[]; } export interface LabConnection { id?: string; name?: string; /** How the laboratory is keyed, and what makes a profile shareable between practices. */ npi?: string; connectionType?: LabConnectionType; /** Where it sends. A URL, a host and port, or an address, depending on the transport. */ address?: string; /** The practice's own client account number with that laboratory. */ accountNumber?: string; /** Whether a credential has been saved. Never the credential. */ credentialsConfigured?: boolean; active?: boolean; /** An Akashic laboratory, reached in-network rather than over this transport. */ akashic?: boolean; /** Whether the server would send an order here electronically. The picker reads only this. */ electronic?: boolean; /** How this laboratory wants an order said, if the practice has described it. */ profile?: LabProfile; /** Our tests paired with their order codes. Diagnostics readers get only reviewed ones, with no reviewer. */ testMappings?: LabTestMapping[]; /** What the laboratory's own server says about returning results. Configuration readers only. */ resultReturn?: ResultReturnView; } export type ResultReturnGap = | "capability-statement" | "fhir-r4" | "report-read" | "report-search" | "search-based-on" | "search-last-updated" | "result-read" | "account-scope-unconfirmed" | "account-parameter"; export interface ResultReturnView { state: "not-applicable" | "unchecked" | "unavailable" | "unsupported" | "supported"; accountParameter?: string; checkedAt?: string; missing?: ResultReturnGap[]; reason?: string; ready: boolean; /** How the last read of the practice's own account went, at this address and parameter. */ lastRead?: { at: string; outcome: "read" | "partial" | "unavailable"; reason?: string }; } export interface LabTestMapping { system: string; code: string; theirCode: string; /** Set when a person confirmed the pairing; only a reviewed pairing sends. */ reviewedAt?: string; reviewedBy?: string; } /** One pairing to add, change, confirm or remove. Pairings a save does not name are kept. */ export interface LabTestMappingChange { system: string; code: string; theirCode?: string; confirm?: boolean; remove?: boolean; } /** Write-only. The server answers whether one is saved and never hands one back. */ export interface LabCredentials { apiKey?: string; username?: string; password?: string; clientCertificate?: string; clientKey?: string; sendingFacility?: string; } export interface LabConnectionDraft { name: string; npi: string; connectionType: LabConnectionType | ""; address: string; accountNumber?: string; } const ENDPOINT = `${environment.fhirBaseApi}/lab-connection`; /** * The same rules the server holds, asked before the call. * * The client is never the boundary and the server keeps its own copy of every one of these. Saying * so while somebody is still typing beats a round trip that comes back refused. * * THE ADDRESS IS NOT CHECKED BEYOND BEING PRESENT, deliberately: a URL, a host and port and a Direct * address are three different shapes, and a checker that understood only the one we thought of would * refuse a laboratory for having an ordinary system. */ export const connectionProblem = (draft: LabConnectionDraft): string | undefined => { if (draft.connectionType === AKASHIC_LAB_CONNECTION) { return /^\d{10}$/.test(draft.npi.trim()) ? undefined : "A ten-digit NPI identifies the laboratory"; } if (!draft.name.trim()) return "Name the laboratory"; if (!/^\d{10}$/.test(draft.npi.trim())) return "A ten-digit NPI identifies the laboratory"; if (!draft.connectionType) return "Choose how this laboratory is connected"; if (!draft.address.trim()) return "Say where this connection sends"; return undefined; }; export const useLabConnections = () => { const { get, post, put } = useHttpService(); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); // The practice's account numbers and sign-ins could not be read, so neither is stated. const [accountsUnreadable, setAccountsUnreadable] = useState(false); // A write's confirmed answer outranks any list read begun before it, which can still show the row as it was; and // only the newest list read is published. const writes = useRef(0); const confirmedAt = useRef(new Map()); const loads = useRef(0); const confirmed = useCallback((id: string) => { writes.current += 1; confirmedAt.current.set(id, writes.current); }, []); // A quiet reload keeps the list on screen, so a dialog open over it stays open. const load = useCallback(async (quiet = false) => { const mine = ++loads.current; const since = writes.current; if (!quiet) setLoading(true); setError(undefined); try { const response = await get<{ connections?: LabConnection[]; accountsUnreadable?: boolean }>(ENDPOINT); if (mine !== loads.current) return; const newer = (id: string | undefined) => (confirmedAt.current.get(id ?? "") ?? 0) > since; setConnections((current) => { const listed = (response?.connections ?? []).map((row) => (newer(row.id) ? current.find((each) => each.id === row.id) ?? row : row)); const kept = current.filter((each) => newer(each.id) && !listed.some((row) => row.id === each.id)); return [...listed, ...kept].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")); }); setAccountsUnreadable(response?.accountsUnreadable === true); } catch (failure) { if (mine !== loads.current) return; console.error("Error loading laboratory connections:", failure); setError(relayServerFailure(failure, "Could not load the laboratories. Please try again.")); } finally { if (mine === loads.current) setLoading(false); } }, [get]); useEffect(() => { void load(); }, [load]); /** Whatever comes back replaces the row it matches, or joins the list. Sorted as the server sorts. */ const absorb = useCallback((connection: LabConnection | undefined) => { if (!connection?.id) return; confirmed(connection.id); setConnections((current) => { const without = current.filter((each) => each.id !== connection.id); return [...without, connection].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")); }); }, [confirmed]); const addConnection = useCallback(async (draft: LabConnectionDraft) => { const akashic = draft.connectionType === AKASHIC_LAB_CONNECTION; const response = await post<{ connection?: LabConnection }>(ENDPOINT, { name: akashic ? undefined : draft.name.trim(), npi: draft.npi.trim(), connectionType: draft.connectionType, address: akashic ? undefined : draft.address.trim(), accountNumber: draft.accountNumber?.trim() || undefined, }); absorb(response?.connection); }, [post, absorb]); const saveConnection = useCallback(async (id: string, draft: LabConnectionDraft) => { const response = await put<{ connection?: LabConnection }>(`${ENDPOINT}/${id}`, { name: draft.name.trim(), connectionType: draft.connectionType, address: draft.address.trim(), accountNumber: draft.accountNumber?.trim() || undefined, }); absorb(response?.connection); }, [put, absorb]); /** * How the laboratory wants an order said. * * THE VERSION IS BUMPED HERE rather than asked of the person saving. It exists so a practice that * adopted somebody else's profile is not moved by a later edit without noticing, which is a fact * about the edit rather than a field anybody should be typing. */ const saveProfile = useCallback(async (id: string, profile: LabProfile) => { const response = await put<{ connection?: LabConnection }>(`${ENDPOINT}/${id}`, { profile: { ...profile, version: (profile.version ?? 0) + 1 }, }); absorb(response?.connection); }, [put, absorb]); const setActive = useCallback(async (id: string, active: boolean) => { const response = await put<{ connection?: LabConnection }>(`${ENDPOINT}/${id}`, { active }); absorb(response?.connection); }, [put, absorb]); /** Refused whole when any row is wrong; the error body lists each problem by row. */ const saveTestMappings = useCallback(async (id: string, testMappings: LabTestMappingChange[]) => { const response = await put<{ connection?: LabConnection }>(ENDPOINT + '/' + id, { testMappings }); absorb(response?.connection); return response?.connection; }, [put, absorb]); // The answer's own sending decision replaces the list's, which the change made stale; none in it means unknown. const markCredentials = useCallback((id: string, answer: { credentialsConfigured?: boolean; electronic?: boolean } | undefined) => { confirmed(id); setConnections((current) => current.map((each) => ( each.id === id ? { ...each, credentialsConfigured: answer?.credentialsConfigured === true, electronic: answer?.electronic } : each ))); }, [confirmed]); const saveCredentials = useCallback(async (id: string, credentials: LabCredentials) => { const response = await put<{ credentialsConfigured?: boolean; electronic?: boolean }>(`${ENDPOINT}/${id}/credentials`, credentials); markCredentials(id, response); }, [put, markCredentials]); const removeCredentials = useCallback(async (id: string) => { const response = await put<{ credentialsConfigured?: boolean; electronic?: boolean }>(`${ENDPOINT}/${id}/credentials`, { remove: true }); markCredentials(id, response); }, [put, markCredentials]); /** The search parameter that limits the laboratory's reports to this practice's account; null clears it. */ const saveAccountParameter = useCallback(async (id: string, parameter: string | null) => { const response = await put<{ connection?: LabConnection }>(`${ENDPOINT}/${id}`, { resultAccountParameter: parameter }); absorb(response?.connection); }, [put, absorb]); /** Asks for the laboratory to be read. The answer is recorded later, so this says only whether it was asked. */ const checkResultReturn = useCallback(async (id: string): Promise => { // Served by the clinical stack, not beside the other laboratory routes. const response = await post<{ queued?: boolean }>(`${environment.fhirClinicalApi}/lab-connection/${id}/result-return-check`, {}); return response?.queued === true; }, [post]); /** Asks for the laboratory's reports to be read now. Only queued: what it returns is filed or held later. */ const readResults = useCallback(async (id: string): Promise => { const response = await post<{ queued?: boolean }>(`${environment.fhirClinicalApi}/lab-connection/${id}/result-read`, {}); return response?.queued === true; }, [post]); return { connections, loading, error, accountsUnreadable, reload: load, addConnection, saveConnection, saveProfile, saveTestMappings, setActive, saveCredentials, removeCredentials, saveAccountParameter, checkResultReturn, readResults, }; };