import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import type { SaveTransport } from '../utils/saveOperation'; import { olderThan, withNewer } from '../utils/versionOrder'; import { createRecordFloor, RecordReadBehindError, type FloorEntry } from '../utils/recordPublicationFloor'; import { holdWrite, keepSearched, overlayHeldBundle, startWrite, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useMemo, useRef } from 'react'; import { Bundle, MedicationStatement, Patient, Reference, Encounter, Dosage } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { medicationNameOf } from '../utils/medicationName'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useMedicationStatementService = () => { const { currentUser } = useAuthServiceContext(); const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, transformToFhirRatio, transformFromFhirRatio, transformToReferenceCollection, transformFromReferenceCollection, transformToFhirDosage, transformFromFhirDosage, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [medicationStatementsList, setMedicationStatementsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [medicationStatementsBundle, setMedicationStatementsBundle] = useState | null>(null); const medicationStatementsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); const statementPatientId = useCallback((statement: MedicationStatement): string | undefined => { const extracted = extractReferenceId(statement.subject); return Array.isArray(extracted) ? extracted[0] : extracted; }, [extractReferenceId]); // The newest copy known of each medication, per patient: searches, save answers and broadcasts all pass it, so an // older or removed copy never replaces a newer one. Rows a lagging search misses are the shared discovery's. const medicationFloors = useRef(new Map>>()); const medicationFloor = useMemo( () => createRecordFloor(medicationFloors.current, statementPatientId, { requireUsableVersion: true, olderIsUnresolved: true }), [statementPatientId], ); // THE ONE CACHED BUNDLE: every publisher changes it from its latest contents, at once, so two publications in one // render turn never rebuild it from the same earlier copy, and a cache hit answers what the list shows. const publishBundle = useCallback((change: (cached: Bundle | null) => Bundle | null) => { const next = change(medicationStatementsBundleRef.current); medicationStatementsBundleRef.current = next; setMedicationStatementsBundle(next); }, []); // WHAT AN OPEN DETAIL BELONGS TO: it was opened under this scope, and is retired (never revived) once the viewer or // practice is replaced, or a current read cannot say what this viewer may be shown now. The page binds the patient. const displayScopeRef = useRef(0); const [displayScope, setDisplayScope] = useState(0); const retireDisplays = useCallback(() => { displayScopeRef.current += 1; setDisplayScope(displayScopeRef.current); }, []); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. // Which full list read is published, for whom and when it started: a reader asking whether its list was read under // a newer entitlement looks here, never at its own request (a superseded read resolves without publishing). const [listRead, setListRead] = useState<{ patientId?: string; startedAt: number } | undefined>(undefined); const cacheOwner = useCacheOwner('MedicationStatement', () => { medicationFloors.current.clear(); setLastUpserted(null); publishBundle(() => null); setMedicationStatementsList([]); lastFetchTimeRef.current = 0; retireDisplays(); }); useEffect(() => { if (!patientState) { medicationFloors.current.clear(); publishBundle(() => null); setMedicationStatementsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release, publishBundle]); const fetchMedicationStatements = useCallback(async (patient: Patient): Promise> => { const bundle = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/MedicationStatement?${params.toString()}`); }); const behind = () => new RecordReadBehindError('medication'); const searched = (bundle?.entry ?? []).map((entry) => entry.resource).filter(Boolean) as MedicationStatement[]; const overlaid = overlayHeldBundle('MedicationStatement', bundle, (statement) => statementPatientId(statement) === patient.id) as Bundle; const entries = overlaid.entry ?? []; const answered = new Map(searched.map((statement) => [statement.id, statement])); // A held copy replacing a row this read answered is a body it did not serve: unresolved, not shown. A save of // this viewer's is not proof of what this read may show now. (A held delete only hides a row.) if (entries.some((entry) => entry.resource?.id && answered.has(entry.resource.id) && answered.get(entry.resource.id) !== entry.resource)) { throw behind(); } // Never published below the floor: an older copy gives way to the newer one known, or the read says it is behind. const settled = medicationFloor.settle( entries.map((entry) => entry.resource).filter(Boolean) as MedicationStatement[], behind, ); const current = { ...overlaid, entry: settled.map((resource) => entries.find((entry) => entry.resource === resource) ?? { resource }) }; return keepSearched(current, overlaid); }, [get, currentUser, statementPatientId, medicationFloor]); // THE AUTHORIZED SEARCH AS SERVED, for history discovery (the snapshot): every id the server returned under this read's // own policy, before any current-list floor. What the current list suppresses never decides what history may ask for. const searchMedicationStatementsForHistory = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { const bundle = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/MedicationStatement?${params.toString()}`, options); }); return overlayHeldBundle('MedicationStatement', bundle, (statement) => statementPatientId(statement) === patient.id) as Bundle; }, [get, statementPatientId]); const fetchMedicationStatementsWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. const cached = medicationStatementsBundleRef.current; // Only while every cached copy is still what is known: one the floor has since moved past is not answered. const cachedCurrent = (cached?.entry ?? []).every((entry) => !entry.resource || medicationFloor.admits(entry.resource)); if (!forceReload && !shouldReload && !writtenSince('MedicationStatement', lastFetchTimeRef.current) && cached && cachedCurrent && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return cached; } const operation = cacheOwner.begin(); /* * WHAT USED TO BE HERE AND WHY IT COULD NOT WORK - see writeThroughHold. A retry loop * polled this search up to ten times at three seconds, waiting for the id just written to turn * up, and served the list without it when the thirty seconds ran out. * * A LONGER LOOP IS NOT THE FIX: the lag is not a constant to tune against. Measured on * 2026-08-31, a RelatedPerson was findable inside 14.4 seconds and a Task was still missing at * 2m47s. Holding what the write returned is right at both ends. */ let published: Bundle; try { published = await fetchMedicationStatements(patient); } catch (error) { // Unresolved: what this read may show is not known, so nothing held before stays published as if it were. // An open detail of it is retired too: no copy this read may not show stays on screen. if (error instanceof RecordReadBehindError && cacheOwner.publishes(operation)) { publishBundle(() => null); setMedicationStatementsList([]); lastFetchTimeRef.current = 0; retireDisplays(); } throw error; } if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, published); publishBundle(() => published); setMedicationStatementsList(unpackBundle(published).filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); setListRead({ patientId: patient.id, startedAt: now }); return published; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchMedicationStatements, unpackBundle, statementPatientId, shouldReload, medicationFloor, publishBundle, retireDisplays]); // One current copy into the list and the cached bundle together, each changed from its latest contents. Whether // it belongs is decided once for both: a record the cached search holds (archived rows included, which the list // leaves out) or one being added. `add`: a record neither holds yet joins them. const showCurrent = useCallback((statement: MedicationStatement, gone: boolean, add: boolean) => { const searched = (medicationStatementsBundleRef.current?.entry ?? []).some((entry) => entry.resource?.id === statement.id); setMedicationStatementsList((prev) => (gone ? prev.filter((p) => p.id !== statement.id) : add || searched || prev.some((p) => p.id === statement.id) ? withNewer(prev, statement) : prev)); publishBundle((cached) => { if (!cached?.entry) return cached; const others = cached.entry.filter((entry) => entry.resource?.id !== statement.id); if (gone) return { ...cached, entry: others }; return searched || add ? { ...cached, entry: [...others, { resource: statement }] } : cached; }); }, [publishBundle]); // `fresh`: the record as stored now, never the cached copy - what a refused save shows as "saved now". const fetchMedicationStatement = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const listed = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? medicationStatementsList.find(statement => statement.id === id) : undefined; const cachedStatement = listed && medicationFloor.admits(listed) ? listed : undefined; if (cachedStatement) { return cachedStatement; } const read = await get(`${API_URL}/MedicationStatement/${id}`, { ...transport }); // A fresh read, admitted for its save, is what is known; a cached or retired one teaches nothing. transport?.admit(); // Refused (older than, or unordered against, what is known): unresolved, not this answer. if (fresh && !medicationFloor.learn(read)) throw new RecordReadBehindError('medication'); // What it raised the floor to is what the list and the cached bundle answer from now, never the copy below it. if (fresh) showCurrent(read, isArchived(read), false); return read; }, [get, medicationStatementsList, medicationFloor, showCurrent]); const fetchMedicationStatementHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/MedicationStatement/${id}/_history`, options); }, [get]); // `transport`: the save this belongs to. Its answer is published to the list only while that save still belongs. const createMedicationStatement = useCallback(async (medicationStatement: MedicationStatement, transport?: SaveTransport): Promise => { const params = new URLSearchParams(); params.append('subject', extractReferenceId(medicationStatement.subject) as string); const createdMedicationStatement = await post(`${API_URL}/MedicationStatement?${params.toString()}`, medicationStatement, { ...transport }); transport?.admit(); if (createdMedicationStatement?.resourceType === 'MedicationStatement' && createdMedicationStatement.id && medicationFloor.learn(createdMedicationStatement)) { setMedicationStatementsList(prev => (prev.some(existingStatement => existingStatement.id === createdMedicationStatement.id) ? withNewer(prev, createdMedicationStatement) : [createdMedicationStatement, ...prev])); publishBundle((cached) => (cached ? { ...cached, entry: [{ resource: createdMedicationStatement }, ...(cached.entry || []).filter(entry => entry.resource?.id !== createdMedicationStatement.id)] } : cached)); setLastUpserted(createdMedicationStatement.id); setShouldReload(false); } else { setShouldReload(true); } return createdMedicationStatement; }, [post, extractReferenceId, medicationFloor, publishBundle]); // CONDITIONAL: written only over `basedOn`, the version the editor opened on (or reviewed). A refusal is a // RecordChangedError: 412 another write landed first, 428 this page named no version. `transport`: the save this // belongs to - its answer is put in this service's lists only while that save still belongs on screen. const updateMedicationStatement = useCallback(async (medicationStatement: MedicationStatement, basedOn: string | undefined, transport?: SaveTransport): Promise => { const medicationStatementId = medicationStatement.id; if (!medicationStatementId) { throw new Error('Cannot update MedicationStatement without an id.'); } if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const startedIn = startWrite(); const responseMedicationStatement = await put(`${API_URL}/MedicationStatement/${medicationStatementId}`, medicationStatement, { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); const updatedMedicationStatement: MedicationStatement = responseMedicationStatement?.resourceType === 'MedicationStatement' && responseMedicationStatement.id ? responseMedicationStatement : medicationStatement; // Superseded, archived or deleted meanwhile: nothing of this answer is published or held. if (!medicationFloor.learn(updatedMedicationStatement)) return updatedMedicationStatement; // A save that answered with no resource is held as what was sent. holdWrite(updatedMedicationStatement, Date.now(), startedIn); // Only forward: a version already superseded in the list is not put back. setMedicationStatementsList(prev => prev.map(existingStatement => existingStatement.id === medicationStatementId && !olderThan(updatedMedicationStatement, existingStatement) ? updatedMedicationStatement : existingStatement ) ); publishBundle((cached) => (cached?.entry ? { ...cached, entry: cached.entry.map(entry => ({ ...entry, resource: entry.resource?.id === medicationStatementId && !olderThan(updatedMedicationStatement, entry.resource as MedicationStatement) ? updatedMedicationStatement : entry.resource })) } : cached)); setLastUpserted(updatedMedicationStatement.id || medicationStatementId); setShouldReload(false); return updatedMedicationStatement; }, [put, medicationFloor, publishBundle]); const deleteMedicationStatement = useCallback(async (medicationStatement: MedicationStatement): Promise => { const deletedMedicationStatement = await del(`${API_URL}/MedicationStatement/${medicationStatement.id}`); // Deleted: no later copy of it is published again. medicationFloor.remove(medicationStatement, true); setMedicationStatementsList(prev => prev.filter(existingStatement => existingStatement.id !== medicationStatement.id) ); publishBundle((cached) => (cached?.entry ? { ...cached, entry: cached.entry.filter(entry => entry.resource?.id !== medicationStatement.id) } : cached)); setShouldReload(false); return deletedMedicationStatement; }, [del, medicationFloor, publishBundle]); // Archiving writes the whole record as held, so it too is written only over the version held. `transport`: the // archive this belongs to; the list drops the record only while it still belongs. const archiveMedicationStatement = useCallback(async (medicationStatement: MedicationStatement, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => { const basedOn = medicationStatement.meta?.versionId; if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const archivedMedicationStatement = await put(`${API_URL}/MedicationStatement/${medicationStatement.id}`, applyArchive(medicationStatement, reason, note), { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); // Removed at the version answered, and only if no newer active version is already known (that row stays). if (!medicationFloor.remove(archivedMedicationStatement?.id ? archivedMedicationStatement : applyArchive(medicationStatement, reason, note))) { return archivedMedicationStatement; } setMedicationStatementsList(prev => prev.filter(existingStatement => existingStatement.id !== medicationStatement.id) ); publishBundle((cached) => (cached?.entry ? { ...cached, entry: cached.entry.filter(entry => entry.resource?.id !== medicationStatement.id) } : cached)); setShouldReload(false); return archivedMedicationStatement; }, [put, medicationFloor, publishBundle]); // A broadcast, admitted by the same floor: an older or removed copy never replaces the one shown. const publishMedicationStatement = useCallback((statement: MedicationStatement, kind: 'created' | 'updated' | 'deleted') => { const gone = kind === 'deleted' || isArchived(statement); if (kind === 'deleted') medicationFloor.remove(statement, true); else if (!medicationFloor.learn(statement)) return; // The cached bundle a polling read answers with moves with the list, so neither returns a copy the floor refused. showCurrent(statement, gone, kind === 'created'); }, [medicationFloor, showCurrent]); // An event for an open medication's detail, admitted (and taught) by the same floor, and put in the list and cached // bundle with it, so no other reader still answers the copy below it. Answers whether to show it. const admitMedicationEvent = useCallback((statement: MedicationStatement, kind: 'updated' | 'deleted'): boolean => { const gone = kind === 'deleted' || isArchived(statement); if (!(kind === 'deleted' ? medicationFloor.remove(statement, true) : medicationFloor.learn(statement))) return false; showCurrent(statement, gone, false); return true; }, [medicationFloor, showCurrent]); const transformToFhirMedicationStatement = useCallback((item: any, patient: Patient, encounterState?: Encounter): MedicationStatement => { const { status, statusReason, category, medicationCodeableConcept, effectiveType, effectiveDateTime, dateAsserted, reasonCode, reasonReference, partOf, basedOn, note, dosage, context } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const transformedContext = transformToReferenceCollection(context); const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedPartOf = transformToReferenceCollection(partOf); const transformedBasedOn = transformToReferenceCollection(basedOn); const transformedDosage: Dosage[] = transformToFhirDosage(dosage) as Dosage[]; const medicationStatement: MedicationStatement = { resourceType: 'MedicationStatement', status: status, subject: patientReference, ...(encounterState && { context: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(transformedContext && transformedContext.length !== 0 && { context: transformedContext[0] }), ...(statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(category && { category: transformToCodeableConcept(category) }), ...(medicationCodeableConcept && { medicationCodeableConcept: transformToCodeableConcept(medicationCodeableConcept) }), ...(effectiveType === 'dateTime' && effectiveDateTime && { effectiveDateTime: formatDateTime(effectiveDateTime) }), ...(dateAsserted && { dateAsserted: formatDateTime(dateAsserted) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(transformedPartOf && transformedPartOf.length !== 0 && { partOf: transformedPartOf }), ...(transformedBasedOn && transformedBasedOn.length !== 0 && { basedOn: transformedBasedOn }), ...(note && note.length !== 0 && { note: note }), ...(transformedDosage && transformedDosage.length !== 0 && { dosage: transformedDosage }) }; if (effectiveType === 'period') { delete medicationStatement.effectiveDateTime; return addFhirPeriodIfApplicable(medicationStatement, item, 'effectivePeriod'); } return medicationStatement; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, addFhirPeriodIfApplicable, transformToFhirQuantity, transformToFhirRatio, getEncounterDisplay, transformToReferenceCollection, transformToFhirDosage ]); const transformFromFhirMedicationStatement = useCallback((medicationStatement: MedicationStatement): any => { const { status, statusReason, category, medicationCodeableConcept, effectiveDateTime, effectivePeriod, dateAsserted, informationSource, reasonCode, reasonReference, partOf, basedOn, note, dosage, context } = medicationStatement; const formData = { status: status, statusReason: transformFromCodeableConcept(statusReason) ?? null, category: transformFromCodeableConcept(category) ?? null, medicationCodeableConcept: transformFromCodeableConcept(medicationCodeableConcept) ?? null, effectiveType: effectiveDateTime ? 'dateTime' : effectivePeriod ? 'period' : '', effectiveDateTime: formatDateTimeForInput(effectiveDateTime), dateAsserted: formatDateTimeForInput(dateAsserted), informationSource: informationSource ?? null, reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), partOf: transformFromReferenceCollection(partOf ?? []), basedOn: transformFromReferenceCollection(basedOn ?? []), note: note ?? [], context: transformFromReferenceCollection(context ? [context] : []), dosage: transformFromFhirDosage(dosage) }; return addPeriodControlsIfApplicable(formData, medicationStatement, 'effectivePeriod'); }, [ transformFromCodeableConcept, transformFromFhirQuantity, transformFromFhirRatio, transformFromReferenceCollection, transformFromFhirDosage, addPeriodControlsIfApplicable, formatDateTimeForInput ]); const getMedicationStatementDisplay = (medicationStatement: MedicationStatement): string => { if (!medicationStatement) { return 'Unknown Medication Statement'; } const medicationName = medicationNameOf(medicationStatement) ?? 'Unknown Medication'; // - the human label, never the code. `statusLabel` returns `undefined` for a code we // have no words for, and this omits the status entirely in that case rather than falling back to // the raw value - the fallback IS the defect. `utils/fhirStatusVocabulary.ts` const statusText = statusLabel(medicationStatement.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (medicationStatement.effectiveDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(medicationStatement.effectiveDateTime)})`; } else if (medicationStatement.effectivePeriod) { dateSuffix = ` (${formatFhirPeriod(medicationStatement.effectivePeriod)})`; } return `${medicationName}${status}${dateSuffix}`; }; return { medicationStatementsBundle, // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, listRead, medicationStatementsList, setMedicationStatementsList, publishMedicationStatement, admitMedicationEvent, displayScope, searchMedicationStatementsForHistory, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchMedicationStatements, fetchMedicationStatementsWithPolling, fetchMedicationStatement, fetchMedicationStatementHistory, createMedicationStatement, updateMedicationStatement, deleteMedicationStatement, archiveMedicationStatement, transformToFhirMedicationStatement, transformFromFhirMedicationStatement, getMedicationStatementDisplay }; }; const MedicationStatementServiceContext = createContext | undefined>(undefined); export const MedicationStatementServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const medicationStatementService = useMedicationStatementService(); return ( {children} ); }; export const useMedicationStatementServiceContext = () => { const context = useContext(MedicationStatementServiceContext); if (context === undefined) { throw new Error('useMedicationStatementServiceContext must be used within a MedicationStatementServiceProvider'); } return context; };