import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, BodyStructure, Patient } 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 { usePatientServiceContext } from './PatientService.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useBodyStructureService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, unpackBundle, extractReferenceId } = useFhirServiceContext(); const [bodyStructuresList, setBodyStructuresList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [bodyStructuresBundle, setBodyStructuresBundle] = useState | undefined>(undefined); const bodyStructuresBundleRef = useRef | undefined>(undefined); const lastFetchTimeRef = useRef(0); useEffect(() => { bodyStructuresBundleRef.current = bodyStructuresBundle; }, [bodyStructuresBundle]); // 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('BodyStructure', () => { setLastUpserted(null); setBodyStructuresBundle(undefined); setBodyStructuresList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setBodyStructuresBundle(undefined); setBodyStructuresList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchBodyStructures = useCallback(async (patient: Patient): Promise> => { return overlayHeldBundle('BodyStructure', await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/BodyStructure?${params.toString()}`); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchBodyStructuresWithPolling = 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('BodyStructure', lastFetchTimeRef.current) && bodyStructuresBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return bodyStructuresBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | undefined = undefined; let entries: BodyStructure[]; do { bundle = await fetchBodyStructures(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('BodyStructure', 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); setBodyStructuresBundle(bundle); setBodyStructuresList(entries); 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; }, [fetchBodyStructures, unpackBundle, lastUpserted, shouldReload]); const fetchBodyStructure = useCallback(async (id: string): Promise => { const cachedStructure = cacheOwner.isCurrent(cacheOwner.claimed) ? bodyStructuresList.find(structure => structure.id === id) : undefined; if (cachedStructure) { return cachedStructure; } return get(`${API_URL}/BodyStructure/${id}`); }, [get, bodyStructuresList]); const fetchBodyStructureHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/BodyStructure/${id}/_history`); }, [get]); const createBodyStructure = useCallback(async (bodyStructure: BodyStructure): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(bodyStructure.patient) as string); return post(`${API_URL}/BodyStructure?${params.toString()}`, bodyStructure); }, [post, extractReferenceId]); const updateBodyStructure = useCallback(async (bodyStructure: BodyStructure): Promise => { return put(`${API_URL}/BodyStructure/${bodyStructure.id}`, bodyStructure); }, [put]); const deleteBodyStructure = useCallback(async (bodyStructure: BodyStructure): Promise => { return del(`${API_URL}/BodyStructure/${bodyStructure.id}`); }, [del]); const transformToFhirBodyStructure = useCallback((item: any, patient?: Patient): BodyStructure => { const { description, active, morphology, location, locationQualifier } = item; let bodyStructure: BodyStructure = { resourceType: 'BodyStructure', ...(patient && { patient: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(description && { description: description }), ...(active !== undefined && { active: active }), ...(morphology && { morphology: transformToCodeableConcept(morphology) }), ...(location && { location: transformToCodeableConcept(location) }), ...(locationQualifier && { locationQualifier: transformToCodeableConcept(locationQualifier) }) }; return bodyStructure; }, [createReferenceObject, transformToCodeableConcept]); const transformFromFhirBodyStructure = useCallback((bodyStructure: BodyStructure): any => { const { description, active, morphology, location, locationQualifier, meta } = bodyStructure; const formData = { meta: meta, description: description ?? '', active: active, morphology: transformFromCodeableConcept(morphology), location: transformFromCodeableConcept(location), locationQualifier: transformFromCodeableConcept(locationQualifier) }; return formData; }, [transformFromCodeableConcept]); const getBodyStructureDisplay = (bodyStructure: BodyStructure): string => { if (!bodyStructure) { return 'Unknown BodyStructure'; } return ``; }; return { // 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, bodyStructuresBundle, bodyStructuresList, setBodyStructuresList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchBodyStructures, fetchBodyStructuresWithPolling, fetchBodyStructure, fetchBodyStructureHistory, createBodyStructure, updateBodyStructure, deleteBodyStructure, transformToFhirBodyStructure, transformFromFhirBodyStructure, getBodyStructureDisplay }; }; const BodyStructureServiceContext = createContext | undefined>(undefined); export const BodyStructureServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const bodyStructureService = useBodyStructureService(); return ( {children} ); }; export const useBodyStructureServiceContext = () => { const context = useContext(BodyStructureServiceContext); if (context === undefined) { throw new Error('useBodyStructureServiceContext must be used within a BodyStructureServiceProvider'); } return context; };