import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, MedicationAdministration, Patient, Encounter, Reference, MedicationAdministrationDosage, ProcedurePerformer } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; 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 useMedicationAdministrationService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [medicationAdministrationsList, setMedicationAdministrationsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [medicationAdministrationsBundle, setMedicationAdministrationsBundle] = useState | null>(null); const medicationAdministrationsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { medicationAdministrationsBundleRef.current = medicationAdministrationsBundle; }, [medicationAdministrationsBundle]); // 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. const cacheOwner = useCacheOwner('MedicationAdministration', () => { setLastUpserted(null); setMedicationAdministrationsBundle(null); setMedicationAdministrationsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setMedicationAdministrationsBundle(null); setMedicationAdministrationsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchMedicationAdministrations = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('MedicationAdministration', await fetchAllPagesAsBundle((pageParam) => { let 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}/MedicationAdministration?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchMedicationAdministrationsWithPolling = 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. if (!forceReload && !shouldReload && !writtenSince('MedicationAdministration', lastFetchTimeRef.current) && medicationAdministrationsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return medicationAdministrationsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: MedicationAdministration[]; do { bundle = await fetchMedicationAdministrations(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('MedicationAdministration', lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setMedicationAdministrationsBundle(bundle); setMedicationAdministrationsList(entries.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); return bundle; }, [fetchMedicationAdministrations, unpackBundle, lastUpserted, shouldReload]); const fetchMedicationAdministration = useCallback(async (id: string): Promise => { const cachedAdministration = cacheOwner.isCurrent(cacheOwner.claimed) ? medicationAdministrationsList.find(administration => administration.id === id) : undefined; if (cachedAdministration) { return cachedAdministration; } return get(`${API_URL}/MedicationAdministration/${id}`); }, [get, medicationAdministrationsList]); const fetchMedicationAdministrationHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/MedicationAdministration/${id}/_history`, options); }, [get]); const createMedicationAdministration = useCallback(async (medicationAdministration: MedicationAdministration): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(medicationAdministration.subject) as string); return post(`${API_URL}/MedicationAdministration?${params.toString()}`, medicationAdministration); }, [post, extractReferenceId]); const updateMedicationAdministration = useCallback(async (medicationAdministration: MedicationAdministration): Promise => { return put(`${API_URL}/MedicationAdministration/${medicationAdministration.id}`, medicationAdministration); }, [put]); const deleteMedicationAdministration = useCallback(async (medicationAdministration: MedicationAdministration): Promise => { return del(`${API_URL}/MedicationAdministration/${medicationAdministration.id}`); }, [del]); const archiveMedicationAdministration = useCallback( async (medicationAdministration: MedicationAdministration, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/MedicationAdministration/${medicationAdministration.id}`, applyArchive(medicationAdministration, reason, note)), [put], ); const transformToFhirMedicationAdministration = useCallback((item: any, patient: Patient, encounterState?: Encounter): MedicationAdministration => { const { partOf, status, statusReason, category, medicationType, medicationCodeableConcept, medicationReference, subject, context, effectiveType, effectiveDateTime, effectivePeriod, performer, reasonCode, reasonReference, request, device, note, dosage } = 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 transformedPerformer: ProcedurePerformer[] = performer.map((p: any) => { const transformedPerformerActor: Reference[] = transformToReferenceCollection(p.actor); if (transformedPerformerActor.length === 0) { return null; } const performerItem: ProcedurePerformer = { actor: transformedPerformerActor[0], ...(p.function && { function: transformToCodeableConcept(p.function) }) }; if (Object.keys(performerItem).length !== 0) { return performerItem; } return null; }).filter(Boolean); let transformedDosage: MedicationAdministrationDosage | undefined = undefined; if (dosage) { const transformedDose = transformToFhirQuantity(dosage.dose); transformedDosage = { ...(dosage.text && { text: dosage.text }), ...(dosage.site && { site: transformToCodeableConcept(dosage.site) }), ...(dosage.route && { route: transformToCodeableConcept(dosage.route) }), ...(dosage.method && { method: transformToCodeableConcept(dosage.method) }), ...(transformedDose && { dose: transformedDose }), ...(dosage.rateType === 'ratio' && dosage.rateRatio && { rateRatio: dosage.rateRatio }), ...(dosage.rateType === 'quantity' && dosage.rateQuantity && { rateQuantity: transformToFhirQuantity(dosage.rateQuantity) }) }; } let medicationAdministration: MedicationAdministration = { resourceType: 'MedicationAdministration', status: status, subject: subject || patientReference, ...(encounterState && { context: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(transformedContext && transformedContext.length !== 0 && { context: transformedContext[0] }), ...(statusReason && statusReason.length > 0 && { statusReason: transformToCodeableConcept(statusReason) }), ...(category && { category: transformToCodeableConcept(category) }), ...(medicationCodeableConcept && { medicationCodeableConcept: transformToCodeableConcept(medicationCodeableConcept) }), ...(medicationType === 'reference' && medicationReference && { medicationReference }), ...(effectiveType === 'dateTime' && effectiveDateTime && { effectiveDateTime: formatDateTime(effectiveDateTime) }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(transformedPartOf && transformedPartOf.length !== 0 && { partOf: transformedPartOf }), ...(request && { request: request }), ...(device && device.length !== 0 && { device: device }), ...(note && note.length !== 0 && { note: note }), ...(transformedDosage && Object.keys(transformedDosage).length !== 0 && { dosage: transformedDosage }) }; if (effectivePeriod) { delete medicationAdministration.effectiveDateTime; return addFhirPeriodIfApplicable(medicationAdministration, item, 'effectivePeriod'); } return medicationAdministration; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, addFhirPeriodIfApplicable, transformToFhirQuantity, transformToReferenceCollection, getEncounterDisplay ]); const transformFromFhirMedicationAdministration = useCallback((medicationAdministration: MedicationAdministration): any => { const { partOf, status, statusReason, category, medicationCodeableConcept, medicationReference, subject, context, effectiveDateTime, effectivePeriod, performer, reasonCode, reasonReference, request, device, note, dosage } = medicationAdministration; const formData = { status: status, statusReason: statusReason ? statusReason.map(reason => transformFromCodeableConcept(reason)) : [], category: transformFromCodeableConcept(category) || null, medicationType: medicationCodeableConcept ? 'codeableConcept' : medicationReference ? 'reference' : 'codeableConcept', medicationCodeableConcept: transformFromCodeableConcept(medicationCodeableConcept) || null, medicationReference: medicationReference || null, subject: subject, context: transformFromReferenceCollection(context ? [context] : []), effectiveType: effectiveDateTime ? 'dateTime' : effectivePeriod ? 'period' : '', effectiveDateTime: formatDateTimeForInput(effectiveDateTime), performer: (performer ?? []).map((p: ProcedurePerformer) => ({ actor: transformFromReferenceCollection(p.actor ? [p.actor] : []), function: transformFromCodeableConcept(p.function) })), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), request: request, device: device ?? [], note: note ?? [], partOf: transformFromReferenceCollection(partOf ?? []), dosage: { text: dosage?.text ?? '', site: transformFromCodeableConcept(dosage?.site), route: transformFromCodeableConcept(dosage?.route), method: transformFromCodeableConcept(dosage?.method), dose: transformFromFhirQuantity(dosage?.dose), rateType: dosage?.rateRatio ? 'ratio' : dosage?.rateQuantity ? 'quantity' : '', rateRatio: dosage?.rateRatio, rateQuantity: transformFromFhirQuantity(dosage?.rateQuantity) } }; return addPeriodControlsIfApplicable(formData, medicationAdministration, 'effectivePeriod'); }, [ transformFromCodeableConcept, addFhirPeriodIfApplicable, transformFromReferenceCollection, transformFromFhirQuantity, formatDateTimeForInput ]); const getMedicationAdministrationDisplay = (medicationAdministration: MedicationAdministration): string => { if (!medicationAdministration) { return 'Unknown Medication Administration'; } let medicationName = 'Unknown Medication'; if (medicationAdministration.medicationCodeableConcept) { medicationName = formatFhirCodeableConcept(medicationAdministration.medicationCodeableConcept); } else if (medicationAdministration.medicationReference) { medicationName = medicationAdministration.medicationReference.display || 'Medication Reference'; } // - 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(medicationAdministration.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (medicationAdministration.effectiveDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(medicationAdministration.effectiveDateTime)})`; } else if (medicationAdministration.effectivePeriod) { dateSuffix = ` (${formatFhirPeriod(medicationAdministration.effectivePeriod)})`; } return `${medicationName}${status}${dateSuffix}`; }; return { medicationAdministrationsBundle, // 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, medicationAdministrationsList, setMedicationAdministrationsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchMedicationAdministrations, fetchMedicationAdministrationsWithPolling, fetchMedicationAdministration, fetchMedicationAdministrationHistory, createMedicationAdministration, updateMedicationAdministration, deleteMedicationAdministration, archiveMedicationAdministration, transformToFhirMedicationAdministration, transformFromFhirMedicationAdministration, getMedicationAdministrationDisplay }; }; const MedicationAdministrationServiceContext = createContext | undefined>(undefined); export const MedicationAdministrationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const medicationAdministrationService = useMedicationAdministrationService(); return ( {children} ); }; export const useMedicationAdministrationServiceContext = () => { const context = useContext(MedicationAdministrationServiceContext); if (context === undefined) { throw new Error('useMedicationAdministrationServiceContext must be used within a MedicationAdministrationServiceProvider'); } return context; };