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, MedicationDispense, Patient, Reference, MedicationDispensePerformer, Encounter, ProcedurePerformer, Dosage, MedicationDispenseSubstitution } 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 { 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 useMedicationDispenseService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, transformToReferenceCollection, transformFromReferenceCollection, transformToFhirDosage, transformFromFhirDosage, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [medicationDispensesList, setMedicationDispensesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [medicationDispensesBundle, setMedicationDispensesBundle] = useState | null>(null); const medicationDispensesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { medicationDispensesBundleRef.current = medicationDispensesBundle; }, [medicationDispensesBundle]); // 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('MedicationDispense', () => { setLastUpserted(null); setMedicationDispensesBundle(null); setMedicationDispensesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setMedicationDispensesBundle(null); setMedicationDispensesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchMedicationDispenses = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('MedicationDispense', 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}/MedicationDispense?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchMedicationDispensesWithPolling = 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. // // The freshness clause reads the REF, not the bundle state beside it. That distinction cost a // card: this callback is a useCallback whose dependencies do not include the bundle, so the // state it closes over is from whichever render last rebuilt it and can be arbitrarily old. // Reading it made the cache miss on lists it was holding, and - in the narrow window where the // closure held a real bundle and state had moved on - answer with the older of the two. if (!forceReload && !shouldReload && !writtenSince('MedicationDispense', lastFetchTimeRef.current) && medicationDispensesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return medicationDispensesBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: MedicationDispense[]; do { bundle = await fetchMedicationDispenses(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('MedicationDispense', 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); setMedicationDispensesBundle(bundle); setMedicationDispensesList(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; }, [fetchMedicationDispenses, unpackBundle, lastUpserted, shouldReload]); const fetchMedicationDispense = useCallback(async (id: string): Promise => { const cachedDispense = cacheOwner.isCurrent(cacheOwner.claimed) ? medicationDispensesList.find(dispense => dispense.id === id) : undefined; if (cachedDispense) { return cachedDispense; } return get(`${API_URL}/MedicationDispense/${id}`); }, [get, medicationDispensesList]); const fetchMedicationDispenseHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/MedicationDispense/${id}/_history`, options); }, [get]); const createMedicationDispense = useCallback(async (medicationDispense: MedicationDispense): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(medicationDispense.subject) as string); return post(`${API_URL}/MedicationDispense?${params.toString()}`, medicationDispense); }, [post, extractReferenceId]); const updateMedicationDispense = useCallback(async (medicationDispense: MedicationDispense): Promise => { return put(`${API_URL}/MedicationDispense/${medicationDispense.id}`, medicationDispense); }, [put]); const deleteMedicationDispense = useCallback(async (medicationDispense: MedicationDispense): Promise => { return del(`${API_URL}/MedicationDispense/${medicationDispense.id}`); }, [del]); const archiveMedicationDispense = useCallback( async (medicationDispense: MedicationDispense, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/MedicationDispense/${medicationDispense.id}`, applyArchive(medicationDispense, reason, note)), [put], ); const transformToFhirMedicationDispense = useCallback((item: any, patient: Patient, encounterState?: Encounter): MedicationDispense => { const { partOf, status, statusReason, statusReasonCodeableConcept, statusReasonReference, category, medicationType, medicationCodeableConcept, medicationReference, context, performer, location, authorizingPrescription, type, quantity, daysSupply, whenPrepared, whenHandedOver, destination, note, dosageInstruction, substitution, detectedIssue, meta } = item; const usCoreMedicationdispenseProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-medicationdispense'; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const transformedPerformer: MedicationDispensePerformer[] = performer.map((p: any) => { const transformedPerformerActor: Reference[] = transformToReferenceCollection(p.actor); if (transformedPerformerActor.length === 0) { return null; } const performerItem: MedicationDispensePerformer = { actor: transformedPerformerActor[0], ...(p.function && { function: transformToCodeableConcept(p.function) }) }; if (Object.keys(performerItem).length !== 0) { return performerItem; } return null; }).filter(Boolean); let transformedSubstitution: MedicationDispenseSubstitution | undefined; if (substitution) { transformedSubstitution = { wasSubstituted: !!substitution.wasSubstituted, ...(substitution.type && { type: transformToCodeableConcept(substitution.type) }), ...(substitution.reason && substitution.reason.length !== 0 && { reason: transformToCodeableConcept(substitution.reason) }), ...(substitution.responsibleParty && substitution.responsibleParty.length !== 0 && { responsibleParty: substitution.responsibleParty }) }; } const transformedDosageInstruction: Dosage[] = transformToFhirDosage(dosageInstruction) as Dosage[]; const transformedContext = transformToReferenceCollection(context); let medicationDispense: MedicationDispense = { resourceType: 'MedicationDispense', meta: { ...meta, profile: [usCoreMedicationdispenseProfile] }, status: status, subject: patientReference, ...(encounterState && { context: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(transformedContext && transformedContext.length !== 0 && { context: transformedContext[0] }), ...(partOf && partOf.length > 0 && { partOf }), ...(statusReason === 'codeableConcept' && statusReasonCodeableConcept && { statusReasonCodeableConcept: transformToCodeableConcept(statusReasonCodeableConcept) }), ...(statusReason === 'reference' && statusReasonReference && { statusReasonReference: statusReasonReference }), ...(category && { category: transformToCodeableConcept(category) }), ...(medicationCodeableConcept && { medicationCodeableConcept: transformToCodeableConcept(medicationCodeableConcept) }), ...(medicationType === 'reference' && medicationReference && { medicationReference: medicationReference }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer }), ...(location && { location: location }), ...(authorizingPrescription && authorizingPrescription.length !== 0 && { authorizingPrescription: authorizingPrescription }), ...(type && { type: transformToCodeableConcept(type) }), ...(quantity && { quantity: transformToFhirQuantity(quantity) }), ...(daysSupply && { daysSupply: transformToFhirQuantity(daysSupply) }), ...(whenPrepared && { whenPrepared: formatDateTime(whenPrepared) }), ...(whenHandedOver && { whenHandedOver: formatDateTime(whenHandedOver) }), ...(destination && { destination: destination }), ...(note && note.length !== 0 && { note: note }), ...(transformedDosageInstruction && transformedDosageInstruction.length !== 0 && { dosageInstruction: transformedDosageInstruction }), ...(transformedSubstitution && Object.keys(transformedSubstitution).length !== 0 && { substitution: transformedSubstitution }), ...(detectedIssue && detectedIssue.length !== 0 && { detectedIssue: detectedIssue }), ...(partOf && partOf.length !== 0 && { partOf: partOf }) }; return medicationDispense; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, transformToFhirQuantity, transformToReferenceCollection, transformToFhirDosage, getEncounterDisplay ]); const transformFromFhirMedicationDispense = useCallback((medicationDispense: MedicationDispense): any => { const { partOf, status, statusReasonCodeableConcept, statusReasonReference, category, medicationCodeableConcept, medicationReference, subject, context, performer, location, authorizingPrescription, type, quantity, daysSupply, whenPrepared, whenHandedOver, destination, receiver, note, dosageInstruction, substitution, detectedIssue, meta } = medicationDispense; const formData = { meta: meta, status: status, statusReason: statusReasonCodeableConcept ? 'codeableConcept' : statusReasonReference ? 'reference' : '', statusReasonCodeableConcept: transformFromCodeableConcept(statusReasonCodeableConcept), statusReasonReference: statusReasonReference, category: transformFromCodeableConcept(category), medicationType: medicationCodeableConcept ? 'codeableConcept' : medicationReference ? 'reference' : '', medicationCodeableConcept: transformFromCodeableConcept(medicationCodeableConcept), medicationReference: medicationReference, subject: subject, context: transformFromReferenceCollection(context ? [context] : []), performer: (performer ?? []).map((p: ProcedurePerformer) => ({ actor: transformFromReferenceCollection(p.actor ? [p.actor] : []), function: transformFromCodeableConcept(p.function) })), location: location, authorizingPrescription: authorizingPrescription, type: transformFromCodeableConcept(type), quantity: transformFromFhirQuantity(quantity), daysSupply: transformFromFhirQuantity(daysSupply), whenPrepared: formatDateTimeForInput(whenPrepared), whenHandedOver: formatDateTimeForInput(whenHandedOver), destination: destination, receiver: receiver ?? [], note: note ?? [], dosageInstruction: transformFromFhirDosage(dosageInstruction), substitution: { wasSubstituted: substitution?.wasSubstituted ?? false, type: transformFromCodeableConcept(substitution?.type), reason: transformFromCodeableConcept(substitution?.reason), responsibleParty: substitution?.responsibleParty ?? [] }, detectedIssue: detectedIssue ?? [], partOf: partOf ?? [] }; return formData; }, [ transformFromCodeableConcept, transformFromReferenceCollection, transformFromFhirQuantity, transformFromFhirDosage ]); const getMedicationDispenseDisplay = (medicationDispense: MedicationDispense): string => { if (!medicationDispense) { return 'Unknown Medication Dispense'; } let medicationName = 'Unknown Medication'; if (medicationDispense.medicationCodeableConcept) { medicationName = formatFhirCodeableConcept(medicationDispense.medicationCodeableConcept); } else if (medicationDispense.medicationReference) { medicationName = medicationDispense.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(medicationDispense.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (medicationDispense.whenHandedOver) { dateSuffix = ` (${formatDateTimeForDisplay(medicationDispense.whenHandedOver)})`; } else if (medicationDispense.whenPrepared) { dateSuffix = ` (${formatDateTimeForDisplay(medicationDispense.whenPrepared)})`; } return `${medicationName}${status}${dateSuffix}`; }; return { medicationDispensesBundle, // 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, medicationDispensesList, setMedicationDispensesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchMedicationDispenses, fetchMedicationDispensesWithPolling, fetchMedicationDispense, fetchMedicationDispenseHistory, createMedicationDispense, updateMedicationDispense, deleteMedicationDispense, archiveMedicationDispense, transformToFhirMedicationDispense, transformFromFhirMedicationDispense, getMedicationDispenseDisplay }; }; const MedicationDispenseServiceContext = createContext | undefined>(undefined); export const MedicationDispenseServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const medicationDispenseService = useMedicationDispenseService(); return ( {children} ); }; export const useMedicationDispenseServiceContext = () => { const context = useContext(MedicationDispenseServiceContext); if (context === undefined) { throw new Error('useMedicationDispenseServiceContext must be used within a MedicationDispenseServiceProvider'); } return context; };