import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Observation } from 'fhir/r4'; import { recoveryOwner } from '../utils/messaging/sendRecovery'; import { isSaveRetired, SaveRetiredError, startSaveOperation, type SaveOperation, type SaveTransport } from '../utils/saveOperation'; import { onViewerChange, viewerEpoch } from '../utils/writeThroughHold'; import { isWithheldSave, mintOperationId, observationOperations, operationScopeDigest, operationUnconfirmed, type OperationStanding, type WithheldSave, } from '../utils/observationOperation'; /** Who is saving, for whom: the viewer parts of an operation scope, read from the auth context by the caller. */ export interface OperationParties { patient: string | undefined; account: string | undefined; relatedPersonId: string | undefined; subjectPatientId: string | undefined; practice: string | undefined; } interface SignedIn { currentUser?: { fhir?: { id?: string } } | null; currentOrganization?: { id?: string } | null; wpOrganization?: { current?: { id?: string } | null } | null; actingAs?: { relatedPersonId?: string; subjectPatientId?: string } | null; } export const operationPartiesOf = (signedIn: SignedIn | undefined, patient: string | undefined): OperationParties => ({ patient, account: signedIn?.currentUser?.fhir?.id, relatedPersonId: signedIn?.actingAs?.relatedPersonId, subjectPatientId: signedIn?.actingAs?.subjectPatientId, practice: signedIn?.currentOrganization?.id ?? signedIn?.wpOrganization?.current?.id, }); /** The two operation routes, from the Observation service the editor already uses. */ export interface OperationRoutes { fetchObservationOperation?: (operationId: string, patientId: string, transport?: SaveTransport) => Promise; finishObservationOperation?: (operationId: string, patientId: string, transport?: SaveTransport, continuation?: string) => Promise; } /** * An earlier save of this editor, by this person for this patient, whose answer was never confirmed: being read * (`checking`), held unfinished by the server (`pending`), unreadable or unknown to the server (`unconfirmed`), or * not ruled out because this tab cannot read its record (`unavailable`). Only a check or a chosen new reading moves on. */ export type EarlierSave = | { kind: 'checking' } | { kind: 'pending'; operationId: string; observation?: Observation; withheld: boolean; continuation?: string } | { kind: 'unconfirmed'; operationId?: string; notFound?: boolean } | { kind: 'unavailable' }; /** What finishing or checking an earlier save came to. */ export type EarlierOutcome = { saved: true; observation?: Observation } | { saved: false; reason?: string } | undefined; /** * The operation an Observation editor saves under. `opening()` checks an earlier unsettled save of the same scope first; * `begin()` gives each save its id, the same on every resend, kept in the tab before anything leaves; `settled()` * once the answer is confirmed. An unconfirmed answer keeps it. */ export const useObservationOperation = (editor: string, parties: OperationParties, routes: OperationRoutes) => { const { patient, account, relatedPersonId, subjectPatientId, practice } = parties; const { fetchObservationOperation, finishObservationOperation } = routes; const scope = useMemo(() => patient ? { account, relatedPersonId, subjectPatientId, practice, patient, editor } : undefined, [account, relatedPersonId, subjectPatientId, practice, patient, editor]); const scopeKey = scope ? JSON.stringify(scope) : undefined; const [earlier, setEarlier] = useState(); // Each opening is its own generation; its functions, answers and sends belong to it alone. const generation = useRef(0); const [openedAs, setOpenedAs] = useState(0); // The save in progress in this opening: the id every resend of it carries, until that exact operation is settled. const held = useRef<{ id: string; generation: number }>(); // A begin still preparing its id: a second press in the meantime gets the same one. const preparing = useRef<{ promise: Promise; generation: number }>(); // Status and finish requests still out: retiring the opening cancels any not yet sent. const live = useRef(new Set()); // Each check or finish within an opening: only the latest one's answer is shown, even under the same operation. const attempt = useRef(0); const retire = useCallback(() => { generation.current += 1; held.current = undefined; preparing.current = undefined; live.current.forEach((operation) => operation.retire()); live.current.clear(); setOpenedAs(generation.current); setEarlier(undefined); }, []); useEffect(() => onViewerChange(retire), [retire]); useEffect(() => retire, [scopeKey, retire]); // Belongs to the opening, viewer and scope it was started in. const current = () => { const mine = generation.current; const epoch = viewerEpoch(); return () => generation.current === mine && viewerEpoch() === epoch; }; const latest = (ours: () => boolean) => { const mine = ++attempt.current; return () => ours() && attempt.current === mine; }; const owned = (ours: () => boolean): SaveOperation => { const operation = startSaveOperation(ours); live.current.add(operation); operation.transport.signal.addEventListener('abort', () => live.current.delete(operation)); return operation; }; // Only this exact operation: a newer save held by this or a later opening is never touched. const settle = (operationId: string) => { try { observationOperations().settle(operationId); } catch { // Left in the tab, it is checked at the next opening and found settled there. } if (held.current?.id === operationId) held.current = undefined; }; const standingOf = useCallback(async (operationId: string, ours: () => boolean, shown: () => boolean = ours): Promise => { if (!patient || !fetchObservationOperation) { if (shown()) setEarlier({ kind: 'unconfirmed', operationId }); return undefined; } const operation = owned(ours); let standing; try { standing = await operation.step(() => fetchObservationOperation(operationId, patient, operation.transport)); } catch (error) { if (!isSaveRetired(error) && shown()) setEarlier({ kind: 'unconfirmed', operationId }); return undefined; } finally { operation.retire(); } if (!ours()) return undefined; // A confirmed commit or refusal settles this exact operation; only the latest check shows what it found. if (standing.state === 'committed' || standing.state === 'refused') { settle(operationId); if (!shown()) return undefined; setEarlier(undefined); return standing.state === 'committed' ? { saved: true, observation: standing.resource } : { saved: false, reason: standing.reason }; } if (!shown()) return undefined; if (standing.state === 'pending') { setEarlier({ kind: 'pending', operationId, observation: standing.observation, withheld: standing.withheld, continuation: standing.continuation }); return undefined; } setEarlier({ kind: 'unconfirmed', operationId, notFound: true }); return undefined; // eslint-disable-next-line react-hooks/exhaustive-deps }, [patient, fetchObservationOperation]); /** The editor opened: any earlier unsettled save of this scope is checked before a new one is offered. */ const opening = useCallback(async () => { retire(); if (!scope) return; const ours = current(); // Nothing held in this tab, the usual case: nothing to check, and the editor is offered at once. try { if (observationOperations().isEmpty()) return; } catch { setEarlier({ kind: 'unavailable' }); return; } setEarlier({ kind: 'checking' }); let oldest: string | undefined; try { const digest = await operationScopeDigest(scope); oldest = observationOperations().unresolved(digest)[0]?.operationId; } catch { if (ours()) setEarlier({ kind: 'unavailable' }); return; } if (!ours()) return; if (!oldest) { setEarlier(undefined); return; } await standingOf(oldest, ours); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeKey, retire, standingOf]); /** * The id of this save: the held one on a resend, the one being prepared on a second press, else a new one kept in * the tab before it is returned. Refused (SaveRetiredError) when its opening retired while it was prepared. */ const begin = useCallback((): Promise => { const mine = generation.current; if (held.current?.generation === mine) return Promise.resolve(held.current.id); if (preparing.current?.generation === mine) return preparing.current.promise; if (!scope) return Promise.reject(new Error('No patient is open.')); const ours = current(); const owner = recoveryOwner(); const promise = (async () => { const digest = await operationScopeDigest(scope); if (!ours()) throw new SaveRetiredError(); const operationId = mintOperationId(); observationOperations().reserve(digest, operationId, owner); held.current = { id: operationId, generation: mine }; return operationId; })(); preparing.current = { promise, generation: mine }; const done = () => { if (preparing.current?.promise === promise) preparing.current = undefined; }; promise.then(done, done); return promise; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeKey]); // The save of this opening, or of the operation named: never one a later opening holds. const target = (operationId: string | undefined, openedAt: number) => operationId ?? (held.current?.generation === openedAt ? held.current.id : undefined); /** That save has its answer: saved, or refused with nothing written. Only that operation is settled. */ const settled = useCallback((operationId?: string) => { const answered = target(operationId, openedAs); if (answered) settle(answered); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openedAs]); /** That save was sent and its answer is not confirmed: kept, and offered to check while its opening is current. */ const unconfirmed = useCallback((operationId?: string) => { const answered = target(operationId, openedAs); if (!answered || held.current?.id !== answered || held.current.generation !== generation.current) return; setEarlier({ kind: 'unconfirmed', operationId: answered }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openedAs]); /** Reads where the save in question stands now. */ const check = useCallback(async (): Promise => { const operationId = earlier && 'operationId' in earlier ? earlier.operationId : held.current?.id; if (!operationId) return undefined; const ours = current(); const shown = latest(ours); setEarlier({ kind: 'checking' }); return standingOf(operationId, ours, shown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [earlier, standingOf]); /** * Finishes a pending save with what the server admitted for it, sent only while its opening is current and * presenting the continuation its last status handed out. An unconfirmed answer leaves it pending; a confirmed one * settles that operation even if the opening moved on. */ const finish = useCallback(async (): Promise => { if (earlier?.kind !== 'pending' || !patient || !finishObservationOperation) return undefined; const { operationId, continuation } = earlier; const ours = current(); const shown = latest(ours); setEarlier({ kind: 'checking' }); const operation = owned(ours); let answer: Observation | WithheldSave; try { answer = await operation.step(() => continuation ? finishObservationOperation(operationId, patient, operation.transport, continuation) : finishObservationOperation(operationId, patient, operation.transport), true); } catch (error) { if (isSaveRetired(error) || !ours()) return undefined; if (operationUnconfirmed(error)) { if (shown()) setEarlier({ kind: 'unconfirmed', operationId }); return undefined; } // A refused finish does not prove the save was never made: its standing is read again. return standingOf(operationId, ours, shown); } finally { operation.retire(); } settle(operationId); if (!shown()) return undefined; setEarlier(undefined); return { saved: true, observation: isWithheldSave(answer) ? undefined : answer }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [earlier, patient, finishObservationOperation, standingOf]); /** * A deliberate new reading instead of the earlier save. The earlier one is no longer offered in this tab; the * server keeps it as it stands. */ const startNew = useCallback(() => { const operationId = earlier && 'operationId' in earlier ? earlier.operationId : undefined; if (operationId) settle(operationId); retire(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [earlier, retire]); return { earlier, opening, begin, settled, unconfirmed, check, finish, startNew, retire }; };