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, DeviceRequest, Patient, CodeableConcept, Encounter, Device, Practitioner, PractitionerRole, Organization } 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 useDeviceRequestService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToFhirTiming, transformFromFhirTiming, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [deviceRequestsList, setDeviceRequestsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [deviceRequestsBundle, setDeviceRequestsBundle] = useState | null>(null); const deviceRequestsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { deviceRequestsBundleRef.current = deviceRequestsBundle; }, [deviceRequestsBundle]); // 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('DeviceRequest', () => { setLastUpserted(null); setDeviceRequestsBundle(null); setDeviceRequestsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setDeviceRequestsBundle(null); setDeviceRequestsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchDeviceRequests = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('DeviceRequest', 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}/DeviceRequest?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchDeviceRequestsWithPolling = 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('DeviceRequest', lastFetchTimeRef.current) && deviceRequestsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return deviceRequestsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: DeviceRequest[]; do { bundle = await fetchDeviceRequests(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('DeviceRequest', 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); setDeviceRequestsBundle(bundle); setDeviceRequestsList(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; }, [fetchDeviceRequests, unpackBundle, lastUpserted, shouldReload]); const fetchDeviceRequest = useCallback(async (id: string): Promise => { const cachedDeviceRequest = cacheOwner.isCurrent(cacheOwner.claimed) ? deviceRequestsList.find(deviceRequest => deviceRequest.id === id) : undefined; if (cachedDeviceRequest) { return cachedDeviceRequest; } return get(`${API_URL}/DeviceRequest/${id}`); }, [get, deviceRequestsList]); const fetchDeviceRequestHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/DeviceRequest/${id}/_history`, options); }, [get]); const createDeviceRequest = useCallback(async (deviceRequest: DeviceRequest): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(deviceRequest.subject) as string); return post(`${API_URL}/DeviceRequest?${params.toString()}`, deviceRequest); }, [post, extractReferenceId]); const updateDeviceRequest = useCallback(async (deviceRequest: DeviceRequest): Promise => { return put(`${API_URL}/DeviceRequest/${deviceRequest.id}`, deviceRequest); }, [put]); const deleteDeviceRequest = useCallback(async (deviceRequest: DeviceRequest): Promise => { return del(`${API_URL}/DeviceRequest/${deviceRequest.id}`); }, [del]); const archiveDeviceRequest = useCallback(async (deviceRequest: DeviceRequest, reason: ArchiveReason, note?: string): Promise => { return put(`${API_URL}/DeviceRequest/${deviceRequest.id}`, applyArchive(deviceRequest, reason, note)); }, [put]); const transformToFhirDeviceRequest = useCallback((item: any, patient?: Patient, encounterState?: Encounter, requester?: Device | Practitioner | PractitionerRole | Organization): DeviceRequest => { const { status, intent, priority, codeCodeableConcept, device, occurrenceType, occurrenceDateTime, occurrenceTiming, reasonCode, supportingInfo, note, relevantHistory, encounter, authoredOn, reasonReference, performerType, performer } = item; const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedPerformer = transformToReferenceCollection(performer); let deviceRequest: DeviceRequest = { resourceType: 'DeviceRequest', intent: intent, ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(requester && { requester: createReferenceObject(requester.resourceType, requester.id as string, formatFhirHumanName(requester.name)) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(status && { status: status }), ...(priority && { priority: priority }), ...(codeCodeableConcept && { codeCodeableConcept: transformToCodeableConcept(codeCodeableConcept) as CodeableConcept }), ...(device && { device: device }), ...(occurrenceType === 'dateTime' && occurrenceDateTime && { occurrenceDateTime: formatDateTime(occurrenceDateTime) }), ...(occurrenceType === 'timing' && occurrenceTiming && { occurrenceTiming: transformToFhirTiming(occurrenceTiming) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) as CodeableConcept[] }), ...(supportingInfo && supportingInfo.length !== 0 && { supportingInfo: supportingInfo }), ...(note && note.length !== 0 && { note: note }), ...(relevantHistory && relevantHistory.length !== 0 && { relevantHistory: relevantHistory }), ...(authoredOn && { authoredOn: formatDateTime(authoredOn) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(performerType && { performerType: transformToCodeableConcept(performerType) }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer[0] }), }; if (occurrenceType === 'period') { return addFhirPeriodIfApplicable(deviceRequest, item, 'occurrencePeriod'); } return deviceRequest; }, [ createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, formatDateTime, transformToFhirTiming, getEncounterDisplay ]); const transformFromFhirDeviceRequest = useCallback((deviceRequest: DeviceRequest): any => { const { status, intent, priority, codeCodeableConcept, occurrenceDateTime, occurrencePeriod, occurrenceTiming, reasonCode, supportingInfo, note, relevantHistory, authoredOn, reasonReference, performerType, performer, encounter } = deviceRequest; let occurrenceType = ''; if (occurrenceDateTime) occurrenceType = 'dateTime'; else if (occurrencePeriod) occurrenceType = 'period'; else if (occurrenceTiming) occurrenceType = 'timing'; const formData = { status: status, intent: intent, priority: priority, codeCodeableConcept: transformFromCodeableConcept(codeCodeableConcept), occurrenceType: occurrenceType, occurrenceDateTime: formatDateTimeForInput(occurrenceDateTime), occurrenceTiming: transformFromFhirTiming(occurrenceTiming), reasonCode: transformFromCodeableConcept(reasonCode ?? []), supportingInfo: supportingInfo ?? [], note: note ?? [], relevantHistory: relevantHistory ?? [], authoredOn: formatDateTimeForInput(authoredOn), reasonReference: transformFromReferenceCollection(reasonReference ?? []), performerType: transformFromCodeableConcept(performerType), performer: transformFromReferenceCollection(performer ? [performer] : []), encounter: encounter }; if (occurrencePeriod) { return addPeriodControlsIfApplicable(formData, deviceRequest, 'occurrencePeriod'); } return formData; }, [ transformFromCodeableConcept, addFhirPeriodIfApplicable, transformFromFhirTiming, addPeriodControlsIfApplicable, formatDateTimeForInput ]); const getDeviceRequestDisplay = (deviceRequest: DeviceRequest): string => { if (!deviceRequest) { return 'Unknown Device Request'; } let deviceName = 'Unknown Device'; if (deviceRequest.codeCodeableConcept) { deviceName = formatFhirCodeableConcept(deviceRequest.codeCodeableConcept); } else if (deviceRequest.device?.display) { deviceName = deviceRequest.device.display; } else if (deviceRequest.device?.reference) { const parts = deviceRequest.device.reference.split('/'); deviceName = parts.length > 1 ? `Device: ${parts[1]}` : deviceRequest.device.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(deviceRequest.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (deviceRequest.occurrenceDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(deviceRequest.occurrenceDateTime)})`; } else if (deviceRequest.occurrencePeriod?.start) { dateSuffix = ` (${formatFhirPeriod(deviceRequest.occurrencePeriod.start)})`; } else if (deviceRequest.authoredOn) { dateSuffix = ` (${formatDateTimeForDisplay(deviceRequest.authoredOn)})`; } return `${deviceName}${status}${dateSuffix}`; }; return { deviceRequestsBundle, // 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, deviceRequestsList, setDeviceRequestsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDeviceRequests, fetchDeviceRequestsWithPolling, fetchDeviceRequest, fetchDeviceRequestHistory, createDeviceRequest, updateDeviceRequest, deleteDeviceRequest, archiveDeviceRequest, transformToFhirDeviceRequest, transformFromFhirDeviceRequest, getDeviceRequestDisplay }; }; const DeviceRequestServiceContext = createContext | undefined>(undefined); export const DeviceRequestServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const deviceRequestService = useDeviceRequestService(); return ( {children} ); }; export const useDeviceRequestServiceContext = () => { const context = useContext(DeviceRequestServiceContext); if (context === undefined) { throw new Error('useDeviceRequestServiceContext must be used within a DeviceRequestServiceProvider'); } return context; };