import { useCallback } from "react"; import type { SaveTransport } from "../utils/saveOperation"; import type { RequestAdmission } from "../services/HttpService.tsx"; import { useHttpService } from "../services/HttpService.tsx"; import { environment } from "../environments/environment.ts"; import type { DiagnosticOrderState } from "../utils/diagnosticOrderQueue"; /** * Recording what came back. * * ON THE BASE API, where 02-fhir-base.yaml declares the diagnostic-order routes -- unlike * ServiceRequest itself, which lives on the clinical gateway. The two halves of this feature sit on * different gateways and the placing hook records what happens when that is guessed wrong. * * NOTHING ABOUT THE PATIENT OR THE VISIT IS SENT. The server copies subject, encounter and basedOn * from the order it is filing against, deliberately: a person retyping either is a person who can * file a result into the wrong chart. So this request carries only what came back. * * AND IT IS ITS OWN ROUTE RATHER THAN AN OBSERVATION WRITE. POST /R4/Observation admits patients, * correctly -- a home blood pressure reading is a real Observation. If recording a result were just * an Observation naming the order in basedOn, a patient could file a self-authored value as a * clinical result and advance the order to `resulted`, so a real pending lab would read as returned. */ /** One measurement as a person typed it. Converted to FHIR at the edge, never in the form. */ export interface ResultObservationInput { /** The catalogue analyte's code. The server finds the range and derives the flag by it. */ code?: string; name: string; value: string; unit?: string; } import type { EntryBasis } from "../utils/resultEntryOperation"; export { newEntryOperation, type EntryBasis } from "../utils/resultEntryOperation"; export interface RecordDiagnosticResultCommand { resumeStored?: false; orderId: string; /** Names this entry, so a retry after a lost answer finishes it rather than filing a second result. */ operationId: string; basis: EntryBasis; supersedes?: string; observations: ResultObservationInput[]; sourceAssertion?: 'measured-here' | 'my-own-report' | 'from-another-record'; /** Imaging impression, the line a reader acts on. A study's narrative is its result: it has no analyte and value. */ conclusion?: string; /** Imaging findings, filed beside the impression. */ findings?: string; /** * The study this report reads. * * SENT AS AN ID THE SERVER THEN PROVES. It is the one field on this route that names another * resource -- everything else is copied from the order precisely so a caller cannot point it * elsewhere -- so the server reads the study and refuses one whose subject is not the order's * patient. Choosing from the order's own study list does not skip that check, and is not what * makes this safe. */ imagingStudyId?: string; /** * The laboratory's own report, for a test sent out and returned on paper. * * A KEY, NEVER BYTES. The file is already in storage by the time this is sent; the record holds * its key and every reader presigns it, so a faxed PDF is not carried inside a resource that every * reader of this patient's results then fetches. */ report?: { key: string; contentType: string; title?: string }; } export interface ResumeStoredDiagnosticResultRequest { orderId: string; operationId: string; basis: EntryBasis; resumeStored: true; } export type RecordDiagnosticResultRequest = RecordDiagnosticResultCommand | ResumeStoredDiagnosticResultRequest; export type EntryOutcome = "completed" | "not-admitted" | "open-own" | "open-other" | "superseded" | "refused" | "changed"; export interface RecordedDiagnosticResult { orderId?: string; reportId?: string; observationIds?: string[]; state?: DiagnosticOrderState; entryOutcome?: EntryOutcome; operationId?: string; /** The entry had already completed; this is what it saved. */ replayed?: boolean; } /** * A NUMBER BECOMES A QUANTITY; ANYTHING ELSE STAYS TEXT. * * "Positive", "No growth after 48h" and "Trace" are ordinary lab results, so refusing them -- or * coercing them to NaN -- would refuse real work. A value that IS a number becomes a Quantity * because that is the form a reference range and a trend can later be read against; text cannot be * compared to anything. */ const valueOf = (observation: ResultObservationInput) => { const raw = observation.value.trim(); const unit = observation.unit?.trim(); const numeric = Number(raw); // `Number("")` is 0, so an empty string would otherwise pass as a measured zero. if (raw !== "" && Number.isFinite(numeric)) { return { valueQuantity: { value: numeric, ...(unit ? { unit } : {}) } }; } return { valueString: raw }; }; export const useRecordDiagnosticResult = () => { const { post } = useHttpService(); return useCallback( async (request: RecordDiagnosticResultRequest, transport?: Partial | RequestAdmission): Promise => post( `${environment.fhirBaseApi}/diagnostic-order/${request.orderId}/result`, { operationId: request.operationId, basis: request.basis, ...(request.resumeStored === true ? { resumeStored: true } : { ...(request.sourceAssertion ? { sourceAssertion: request.sourceAssertion } : {}), ...(request.supersedes ? { supersedes: request.supersedes } : {}), // FHIR omits empty repeating fields, including narrative-only imaging results. ...(request.observations.length ? { observations: request.observations.map((observation) => ({ code: { text: observation.name.trim(), // No system, matching the coding the catalogue stores on the analyte. ...(observation.code ? { coding: [{ code: observation.code, display: observation.name.trim() }] } : {}), }, ...valueOf(observation), })), } : {}), ...(request.conclusion?.trim() ? { conclusion: request.conclusion.trim() } : {}), // Sent even when empty: its presence is what marks the report as findings and impression. ...(request.findings !== undefined ? { findings: request.findings.trim() } : {}), ...(request.imagingStudyId ? { imagingStudyId: request.imagingStudyId } : {}), ...(request.report ? { report: request.report } : {}), }), }, ...(transport ? [typeof transport === "function" ? { admit: transport } : { ...transport }] : []), ), [post], ); };