import { withRead, 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, Device, Patient, Reference, DeviceUdiCarrier } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { isArchived, type ArchiveReason } from '../utils/archivedResource'; import type { SaveTransport } from '../utils/saveOperation'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirBaseApi; const equipmentFields = [ 'identifier', 'udiCarrier', 'deviceName', 'manufacturer', 'distinctIdentifier', 'modelNumber', 'partNumber', 'serialNumber', 'lotNumber', 'manufactureDate', 'expirationDate', 'status', ] as const; type EquipmentEdit = Device & { clearEquipmentFields?: (typeof equipmentFields[number])[] }; function equipmentFacts(device: Device): Partial { const facts: Partial = {}; for (const key of equipmentFields) { if (Object.prototype.hasOwnProperty.call(device, key)) Object.assign(facts, { [key]: device[key] }); } return facts; } function equipmentEdition(device: Device): { headers: { 'If-Match': string } } { const version = device.meta?.versionId; if (!device.id || !/^[A-Za-z0-9.-]+$/.test(device.id) || !version || !/^[A-Za-z0-9.-]+$/.test(version)) { throw new Error('Reload this equipment record before saving.'); } return { headers: { 'If-Match': `W/"${version}"` } }; } export const useDeviceService = () => { const { get, post, put } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformFromCodeableConcept, formatDate, unpackBundle, transformToIdentifier, transformFromIdentifier, extractReferenceId } = useFhirServiceContext(); const [devicesList, setDevicesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [devicesBundle, setDevicesBundle] = useState | null>(null); const devicesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { devicesBundleRef.current = devicesBundle; }, [devicesBundle]); // 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('Device', () => { setLastUpserted(null); setDevicesBundle(null); setDevicesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setDevicesBundle(null); setDevicesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchDevices = useCallback(async (patient: Patient, transport?: SaveTransport, options?: ReadOptions): Promise> => { transport?.admit(); const bundle = await fetchAllPagesAsBundle(async (pageParam) => { transport?.admit(); const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); if (pageParam) params.set('page', pageParam); const url = `${API_URL}/Device?${params.toString()}`; const page = transport ? await get>(url, withRead(transport, options)) : await get>(url, options); transport?.admit(); return page; }); transport?.admit(); return overlayHeldBundle('Device', bundle, (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchDevicesWithPolling = useCallback(async (patient: Patient, forceReload = false, transport?: SaveTransport): Promise> => { transport?.admit(); 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('Device', lastFetchTimeRef.current) && devicesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return devicesBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Device[]; do { bundle = await fetchDevices(patient, transport); transport?.admit(); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Device', lastUpserted) : true; if (!found) { transport?.admit(); await new Promise((resolve, reject) => { const signal = transport?.signal; const finish = () => { signal?.removeEventListener('abort', cancel); resolve(); }; const timer = setTimeout(finish, delay); const cancel = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); try { transport?.admit(); reject(new Error('Equipment read was cancelled.')); } catch (error) { reject(error); } }; if (signal?.aborted) cancel(); else signal?.addEventListener('abort', cancel, { once: true }); }); transport?.admit(); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); transport?.admit(); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setDevicesBundle(bundle); setDevicesList(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; }, [fetchDevices, unpackBundle, lastUpserted, shouldReload]); const fetchDevice = useCallback(async (id: string, transport?: SaveTransport): Promise => { const cachedDevice = cacheOwner.isCurrent(cacheOwner.claimed) ? devicesList.find(device => device.id === id) : undefined; if (cachedDevice && !transport) { return cachedDevice; } return get(`${API_URL}/Device/${id}`, transport); }, [get, devicesList]); const fetchDeviceHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Device/${id}/_history`, options); }, [get]); const createDevice = useCallback(async (device: Device, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(device.patient) as string); return post(`${API_URL}/Device?${params.toString()}`, { resourceType: 'Device', patient: device.patient, ...equipmentFacts(device) }, transport); }, [post, extractReferenceId]); const updateDevice = useCallback(async (device: EquipmentEdit, transport?: SaveTransport): Promise => { const options = equipmentEdition(device); return put(`${API_URL}/Device/${device.id}`, { resourceType: 'Device', id: device.id, ...equipmentFacts(device), ...(device.clearEquipmentFields?.length && { clearEquipmentFields: device.clearEquipmentFields }) }, { ...transport, ...options }); }, [put]); const deleteDevice = useCallback(async (_device: Device): Promise => { throw new Error('Use the equipment archive action to retain this record and its history.'); }, []); const archiveDevice = useCallback(async (device: Device, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => { const options = equipmentEdition(device); if (!['duplicate', 'no-longer-applies', 'other'].includes(reason)) { throw new Error('Choose an administrative equipment archive reason.'); } if (note?.trim()) throw new Error('Archive equipment with a reason. Clinical notes require their original correction workflow.'); return put(`${API_URL}/Device/${device.id}`, { action: 'archive', reason }, { ...transport, ...options }); }, [put]); const transformToFhirDevice = useCallback((item: any, patient: Patient): EquipmentEdit => { const { status, deviceName, manufacturer, distinctIdentifier, modelNumber, partNumber, serialNumber, lotNumber, expirationDate, manufactureDate, udiCarrier, identifier } = item; const patientReference: Reference = { reference: `Patient/${patient.id}` }; const transformedUdiCarrier = (udiCarrier ?? []).map((udi: DeviceUdiCarrier) => { const deviceUdiCarrier: DeviceUdiCarrier = { ...(udi.deviceIdentifier && { deviceIdentifier: udi.deviceIdentifier }), ...(udi.carrierHRF && { carrierHRF: udi.carrierHRF }), ...(udi.entryType && { entryType: udi.entryType }) }; if (Object.keys(deviceUdiCarrier).length !== 0) { return deviceUdiCarrier } return null; }).filter(Boolean); const clearEquipmentFields = equipmentFields.filter(key => Object.prototype.hasOwnProperty.call(item, key) && (item[key] === '' || (Array.isArray(item[key]) && item[key].length === 0) || (key === 'udiCarrier' && transformedUdiCarrier.length === 0))); const device: EquipmentEdit = { resourceType: 'Device', patient: patientReference, ...(clearEquipmentFields.length && { clearEquipmentFields }), ...(status && { status: status }), ...(deviceName && deviceName.length !== 0 && { deviceName: deviceName }), ...(manufacturer && { manufacturer: manufacturer }), ...(distinctIdentifier && { distinctIdentifier: distinctIdentifier }), ...(modelNumber && { modelNumber: modelNumber }), ...(partNumber && { partNumber: partNumber }), ...(serialNumber && { serialNumber: serialNumber }), ...(lotNumber && { lotNumber: lotNumber }), ...(expirationDate && { expirationDate: formatDate(expirationDate) }), ...(manufactureDate && { manufactureDate: formatDate(manufactureDate) }), ...(transformedUdiCarrier && transformedUdiCarrier.length !== 0 && { udiCarrier: transformedUdiCarrier }), ...(identifier && identifier.length !== 0 && { identifier: transformToIdentifier(identifier) }) }; return device; }, [formatDate, transformToIdentifier]); const transformFromFhirDevice = useCallback((device: Device): any => { const { status, statusReason, type, deviceName, manufacturer, distinctIdentifier, modelNumber, partNumber, serialNumber, lotNumber, expirationDate, manufactureDate, udiCarrier, note, meta, identifier } = device; const formData = { meta: meta, status: status, statusReason: transformFromCodeableConcept(statusReason), type: transformFromCodeableConcept(type), deviceName: deviceName ?? [], manufacturer: manufacturer ?? '', distinctIdentifier: distinctIdentifier ?? '', modelNumber: modelNumber ?? '', partNumber: partNumber ?? '', serialNumber: serialNumber ?? '', lotNumber: lotNumber ?? '', expirationDate: expirationDate ?? '', manufactureDate: manufactureDate ?? '', udiCarrier: udiCarrier ?? [], note: note ?? [], identifier: transformFromIdentifier(identifier ?? []), }; return formData; }, [transformFromCodeableConcept, transformFromIdentifier]); const getDeviceDisplay = (device: Device): string => { if (!device) { return 'Unknown Device'; } let deviceName = 'Unknown Device'; if (device.deviceName && device.deviceName.length > 0) { const primaryName = device.deviceName.find(name => name.type === 'user-friendly-name') || device.deviceName.find(name => name.type === 'model-name') || device.deviceName[0]; deviceName = primaryName.name; } else if (device.type) { deviceName = formatFhirCodeableConcept(device.type); } // - 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(device.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (device.expirationDate) { dateSuffix = ` (Expires: ${formatDate(device.expirationDate)})`; } else if (device.manufactureDate) { dateSuffix = ` (Mfg: ${formatDate(device.manufactureDate)})`; } return `${deviceName}${status}${dateSuffix}`; }; return { devicesBundle, // 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, devicesList, setDevicesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDevices, fetchDevicesWithPolling, fetchDevice, fetchDeviceHistory, createDevice, updateDevice, deleteDevice, archiveDevice, transformToFhirDevice, transformFromFhirDevice, getDeviceDisplay }; }; const DeviceServiceContext = createContext | undefined>(undefined); export const DeviceServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const deviceService = useDeviceService(); return ( {children} ); }; export const useDeviceServiceContext = () => { const context = useContext(DeviceServiceContext); if (context === undefined) { throw new Error('useDeviceServiceContext must be used within a DeviceServiceProvider'); } return context; };