import type { ReadOptions } from '../hooks/anchorView/readOptions'; import type { SaveTransport } from '../utils/saveOperation'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, MedicationRequest, Patient, Encounter, MedicationRequestSubstitution, MedicationRequestDispenseRequest, MedicationRequestDispenseRequestInitialFill, Practitioner } 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 { medicationNameOf } from '../utils/medicationName'; 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 useMedicationRequestService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToFhirDosage, transformFromFhirDosage, transformToReferenceCollection, transformFromReferenceCollection, transformToFhirQuantity, transformFromFhirQuantity, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToFhirDuration, transformFromFhirDuration, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [medicationRequestsList, setMedicationRequestsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [medicationRequestsBundle, setMedicationRequestsBundle] = useState | null>(null); const medicationRequestsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { medicationRequestsBundleRef.current = medicationRequestsBundle; }, [medicationRequestsBundle]); // 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('MedicationRequest', () => { setLastUpserted(null); setMedicationRequestsBundle(null); setMedicationRequestsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setMedicationRequestsBundle(null); setMedicationRequestsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchMedicationRequests = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('MedicationRequest', 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}/MedicationRequest?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchMedicationRequestsWithPolling = 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('MedicationRequest', lastFetchTimeRef.current) && medicationRequestsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return medicationRequestsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: MedicationRequest[]; do { bundle = await fetchMedicationRequests(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('MedicationRequest', 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); setMedicationRequestsBundle(bundle); setMedicationRequestsList(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); setListRead({ patientId: patient.id, startedAt: now }); return bundle; }, [fetchMedicationRequests, unpackBundle, lastUpserted, shouldReload]); const fetchMedicationRequest = useCallback(async (id: string): Promise => { const cachedRequest = cacheOwner.isCurrent(cacheOwner.claimed) ? medicationRequestsList.find(request => request.id === id) : undefined; if (cachedRequest) { return cachedRequest; } return get(`${API_URL}/MedicationRequest/${id}`); }, [get, medicationRequestsList]); const fetchMedicationRequestHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/MedicationRequest/${id}/_history`, options); }, [get]); const createMedicationRequest = useCallback(async (medicationRequest: MedicationRequest, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(medicationRequest.subject) as string); return post(`${API_URL}/MedicationRequest?${params.toString()}`, medicationRequest, { ...transport }); }, [post, extractReferenceId]); const updateMedicationRequest = useCallback(async (medicationRequest: MedicationRequest): Promise => { return put(`${API_URL}/MedicationRequest/${medicationRequest.id}`, medicationRequest); }, [put]); const deleteMedicationRequest = useCallback(async (medicationRequest: MedicationRequest): Promise => { return del(`${API_URL}/MedicationRequest/${medicationRequest.id}`); }, [del]); const archiveMedicationRequest = useCallback( async (medicationRequest: MedicationRequest, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/MedicationRequest/${medicationRequest.id}`, applyArchive(medicationRequest, reason, note)), [put], ); const transformToFhirMedicationRequest = useCallback((item: any, patient?: Patient, encounterState?: Encounter, recorder?: Practitioner): MedicationRequest => { const { status, statusReason, intent, category, priority, authoredOn, doNotPerform, reportedBoolean, medicationCodeableConcept, reasonCode, note, dosageInstruction, dispenseRequest, substitution, encounter, meta, reasonReference, priorPrescription, courseOfTherapyType, detectedIssue, basedOn, performerType, performer, requester } = item; const usCoreCareteamProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-medicationrequest'; const transformedDosage = transformToFhirDosage(dosageInstruction); const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedBasedOn = transformToReferenceCollection(basedOn); const transformedPerformer = transformToReferenceCollection(performer); const transformedRequester = transformToReferenceCollection(requester); let transformedSubstitution: MedicationRequestSubstitution | undefined = undefined; let transformedDispenseRequest: MedicationRequestDispenseRequest | undefined = undefined; if (substitution) { transformedSubstitution = { ...(!substitution.allowedCodeableConcept && substitution.allowedBoolean !== undefined && { allowedBoolean: substitution.allowedBoolean }), ...(substitution.allowedBoolean && substitution.allowedCodeableConcept && { allowedCodeableConcept: transformToCodeableConcept(substitution.allowedCodeableConcept) }), ...(substitution.allowedBoolean && substitution.reason && { reason: transformToCodeableConcept(substitution.reason) }) }; } if (dispenseRequest) { // A DISPENSE REQUEST NEED NOT CARRY AN INITIAL FILL. FHIR makes `initialFill` 0..1, and the // ordinary prescription - a quantity and a refill count - has none. This was the only // unguarded read in the object beside a dozen that check first, and it THREW rather than // omitting, so the item could not be filed at all and the screen said only "This item was not // added. Please try again." // // It was reachable only once the condensed form started delivering the dotted dispense fields, // which is why it surfaced after that fix rather than before it: with `dispenseRequest` // undefined this whole block was skipped. const transformedInitialFillQuantity = transformToFhirQuantity(dispenseRequest.initialFill?.quantity); const transformedInitialFillDuration = transformToFhirDuration(dispenseRequest.initialFill?.duration); const transformedInitialFill: MedicationRequestDispenseRequestInitialFill = { ...(transformedInitialFillQuantity && { quantity: transformedInitialFillQuantity }), ...(transformedInitialFillDuration && { duration: transformedInitialFillDuration }) }; const transformedDispenseInterval = transformToFhirDuration(dispenseRequest.dispenseInterval); const transformedQuantity = transformToFhirQuantity(dispenseRequest.quantity); const transformedExpectedSupplyDuration = transformToFhirDuration(dispenseRequest.expectedSupplyDuration); transformedDispenseRequest = { ...(Object.keys(transformedInitialFill).length !== 0 && { initialFill: transformedInitialFill }), ...(dispenseRequest.numberOfRepeatsAllowed !== undefined && { numberOfRepeatsAllowed: dispenseRequest.numberOfRepeatsAllowed }), ...(transformedDispenseInterval && { dispenseInterval: transformedDispenseInterval }), ...(transformedQuantity && { quantity: transformedQuantity }), ...(transformedExpectedSupplyDuration && { expectedSupplyDuration: transformedExpectedSupplyDuration }) }; transformedDispenseRequest = addFhirPeriodIfApplicable(transformedDispenseRequest, dispenseRequest, 'validityPeriod'); } let medicationRequest: MedicationRequest = { resourceType: 'MedicationRequest', status: status, intent: intent, meta: { ...meta, profile: [usCoreCareteamProfile] }, ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(recorder && { recorder: createReferenceObject(recorder.resourceType, recorder.id as string, formatFhirHumanName(recorder.name)) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(category && { category: transformToCodeableConcept(category) }), ...(authoredOn && { authoredOn: formatDateTime(authoredOn) }), ...(priority && { priority: priority }), ...(detectedIssue && { detectedIssue: detectedIssue }), ...(priorPrescription && { priorPrescription: priorPrescription }), ...(courseOfTherapyType && { courseOfTherapyType: transformToCodeableConcept(courseOfTherapyType) }), ...(doNotPerform !== undefined && { doNotPerform: doNotPerform }), ...(reportedBoolean !== undefined && { reportedBoolean: reportedBoolean }), ...(medicationCodeableConcept && { medicationCodeableConcept: transformToCodeableConcept(medicationCodeableConcept) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(performerType && { performerType: transformToCodeableConcept(performerType) }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer[0] }), ...(transformedRequester && transformedRequester.length !== 0 && { requester: transformedRequester[0] }), ...(note && note.length !== 0 && { note: note }), ...(transformedDosage && { dosageInstruction: transformedDosage }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(transformedSubstitution && Object.keys(transformedSubstitution).length !== 0 && { substitution: transformedSubstitution }), ...(transformedDispenseRequest && Object.keys(transformedDispenseRequest).length !== 0 && { dispenseRequest: transformedDispenseRequest }), ...(transformedBasedOn && transformedBasedOn.length !== 0 && { basedOn: transformedBasedOn }) }; return medicationRequest; }, [ createReferenceObject, formatDateTime, transformToCodeableConcept, transformToFhirDosage, transformToReferenceCollection, getEncounterDisplay, transformToFhirQuantity, transformToFhirDuration ]); const transformFromFhirMedicationRequest = useCallback((medicationRequest: MedicationRequest): any => { const { status, statusReason, category, intent, priority, authoredOn, doNotPerform, reportedBoolean, medicationCodeableConcept, reasonCode, note, dosageInstruction, dispenseRequest, substitution, meta, reasonReference, priorPrescription, courseOfTherapyType, detectedIssue, encounter, basedOn, performerType, performer, requester } = medicationRequest; let transformedDispenseRequest: any = { initialFill: { quantity: transformFromFhirQuantity(dispenseRequest?.initialFill?.quantity), duration: transformFromFhirDuration(dispenseRequest?.initialFill?.duration) }, dispenseInterval: transformFromFhirDuration(dispenseRequest?.dispenseInterval), numberOfRepeatsAllowed: dispenseRequest?.numberOfRepeatsAllowed, quantity: transformFromFhirQuantity(dispenseRequest?.quantity), expectedSupplyDuration: transformFromFhirDuration(dispenseRequest?.expectedSupplyDuration) }; if (dispenseRequest) { transformedDispenseRequest = addPeriodControlsIfApplicable(transformedDispenseRequest, dispenseRequest, 'validityPeriod'); } const formData = { meta: meta, status: status, statusReason: transformFromCodeableConcept(statusReason) ?? null, category: transformFromCodeableConcept(category) ?? null, intent: intent, priority: priority ?? '', doNotPerform: doNotPerform ?? false, reportedBoolean: reportedBoolean ?? false, authoredOn: formatDateTimeForInput(authoredOn), medicationCodeableConcept: transformFromCodeableConcept(medicationCodeableConcept) ?? null, reasonCode: transformFromCodeableConcept(reasonCode ?? []), note: note ?? [], dosageInstruction: transformFromFhirDosage(dosageInstruction), reasonReference: transformFromReferenceCollection(reasonReference ?? []), priorPrescription: priorPrescription, courseOfTherapyType: transformFromCodeableConcept(courseOfTherapyType), detectedIssue: detectedIssue, encounter: encounter, basedOn: transformFromReferenceCollection(basedOn ?? []), performerType: transformFromCodeableConcept(performerType), performer: transformFromReferenceCollection(performer ? [performer] : []), requester: transformFromReferenceCollection(requester ? [requester] : []), dispenseRequest: transformedDispenseRequest, substitution: { allowedBoolean: substitution?.allowedBoolean || substitution?.allowedCodeableConcept, allowedCodeableConcept: transformFromCodeableConcept(substitution?.allowedCodeableConcept), reason: transformFromCodeableConcept(substitution?.reason) } }; return formData; }, [ transformFromCodeableConcept, transformFromFhirDosage, formatDateTimeForInput, transformFromReferenceCollection, transformFromFhirQuantity, transformFromFhirDuration ]); const getMedicationRequestDisplay = (medicationRequest: MedicationRequest): string => { if (!medicationRequest) { return 'Unknown Medication Request'; } const medicationName = medicationNameOf(medicationRequest) ?? '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(medicationRequest.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (medicationRequest.authoredOn) { dateSuffix = ` (${formatDateTimeForDisplay(medicationRequest.authoredOn)})`; } return `${medicationName}${status}${dateSuffix}`; }; return { medicationRequestsBundle, // 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, medicationRequestsList, setMedicationRequestsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchMedicationRequests, fetchMedicationRequestsWithPolling, fetchMedicationRequest, fetchMedicationRequestHistory, createMedicationRequest, updateMedicationRequest, deleteMedicationRequest, archiveMedicationRequest, transformToFhirMedicationRequest, transformFromFhirMedicationRequest, getMedicationRequestDisplay }; }; const MedicationRequestServiceContext = createContext | undefined>(undefined); export const MedicationRequestServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const medicationRequestService = useMedicationRequestService(); return ( {children} ); }; /** Absent outside its provider, for readers that must never take a surface down with them. */ export const useOptionalMedicationRequestServiceContext = () => useContext(MedicationRequestServiceContext); export const useMedicationRequestServiceContext = () => { const context = useContext(MedicationRequestServiceContext); if (context === undefined) { throw new Error('useMedicationRequestServiceContext must be used within a MedicationRequestServiceProvider'); } return context; };