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, Procedure, Patient, CodeableConcept, Reference, Practitioner, Encounter, ProcedurePerformer, ProcedureFocalDevice, RelatedPerson } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.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.ts'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useProcedureService = () => { const { currentUser } = useAuthServiceContext(); const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [proceduresList, setProceduresList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [proceduresBundle, setProceduresBundle] = useState | null>(null); const proceduresBundleBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { proceduresBundleBundleRef.current = proceduresBundle; }, [proceduresBundle]); // 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('Procedure', () => { setLastUpserted(null); setProceduresBundle(null); setProceduresList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setProceduresBundle(null); setProceduresList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchProcedures = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('Procedure', await fetchAllPagesAsBundle((pageParam) => { const 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}/Procedure?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get, currentUser]); const fetchProceduresWithPolling = 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('Procedure', lastFetchTimeRef.current) && proceduresBundleBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return proceduresBundleBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Procedure[]; do { bundle = await fetchProcedures(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Procedure', 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); setProceduresBundle(bundle); setProceduresList(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; }, [fetchProcedures, unpackBundle, lastUpserted, shouldReload]); const fetchProcedure = useCallback(async (id: string): Promise => { const cachedProcedure = cacheOwner.isCurrent(cacheOwner.claimed) ? proceduresList.find(procedure => procedure.id === id) : undefined; if (cachedProcedure) { return cachedProcedure; } return get(`${API_URL}/Procedure/${id}`); }, [get, proceduresList]); const fetchProcedureHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Procedure/${id}/_history`, options); }, [get]); const createProcedure = useCallback(async (procedure: Procedure, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(procedure.subject) as string); return post(`${API_URL}/Procedure?${params.toString()}`, procedure, { ...transport }); }, [post, extractReferenceId]); const updateProcedure = useCallback(async (procedure: Procedure): Promise => { return put(`${API_URL}/Procedure/${procedure.id}`, procedure); }, [put]); const deleteProcedure = useCallback(async (procedure: Procedure): Promise => { return del(`${API_URL}/Procedure/${procedure.id}`); }, [del]); const archiveProcedure = useCallback( async (procedure: Procedure, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/Procedure/${procedure.id}`, applyArchive(procedure, reason, note)), [put], ); const transformToFhirProcedure = useCallback((item: any, patient?: Patient, encounterState?: Encounter, recorder?: Practitioner | Patient | RelatedPerson): Procedure => { const { encounter, location, asserter, status, category, code, bodySite, outcome, followUp, performedType, performedDateTime, performedAge, performedString, performedRange, performer, reasonCode, reasonReference, note, meta, usedCode, usedReference, complication, complicationDetail, focalDevice, basedOn, partOf } = item; const usCoreProcedureProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-procedure'; const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedUsedReference = transformToReferenceCollection(usedReference); const transformedBasedOn = transformToReferenceCollection(basedOn); const transformedPartOf = transformToReferenceCollection(partOf); const transformedPerformer: ProcedurePerformer[] = (performer ?? []).map((p: any) => { const transformedPerformerActor: Reference[] = transformToReferenceCollection(p.actor); if (transformedPerformerActor.length === 0) { return null; } const performerItem: ProcedurePerformer = { actor: transformedPerformerActor[0], ...(p.function && { function: transformToCodeableConcept(p.function) }) }; if (Object.keys(performerItem).length !== 0) { return performerItem; } return null; }).filter(Boolean); const transformedFocalDevice: ProcedureFocalDevice[] = (focalDevice ?? []).map((fd: any) => { if (!fd.manipulated) { return null; } return { ...(fd.action && { action: transformToCodeableConcept(fd.action) }), manipulated: fd.manipulated }; }).filter(Boolean); let procedure: Procedure = { resourceType: 'Procedure', meta: { ...meta, profile: [usCoreProcedureProfile] }, status: status, ...(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 }), ...(location && { location: location }), ...(category && { category: transformToCodeableConcept(category) }), ...(code && { code: transformToCodeableConcept(code) as CodeableConcept }), ...(bodySite && bodySite.length !== 0 && { bodySite: transformToCodeableConcept(bodySite) as CodeableConcept[] }), ...(outcome && { outcome: transformToCodeableConcept(outcome) }), ...(followUp && followUp.length !== 0 && { followUp: transformToCodeableConcept(followUp) as CodeableConcept[] }), ...(performedType === 'dateTime' && performedDateTime && { performedDateTime: formatDateTime(performedDateTime) }), ...(performedType === 'age' && performedAge && { performedAge: performedAge }), ...(performedType === 'string' && performedString && { performedString: performedString }), ...(performedType === 'range' && performedRange && { performedRange: performedRange }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer }), ...(transformedFocalDevice && transformedFocalDevice.length !== 0 && { focalDevice: transformedFocalDevice }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) as CodeableConcept[] }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(usedCode && usedCode.length !== 0 && { usedCode: transformToCodeableConcept(usedCode) as CodeableConcept[] }), ...(transformedUsedReference && transformedUsedReference.length !== 0 && { usedReference: transformedUsedReference }), ...(complication && complication.length !== 0 && { complication: transformToCodeableConcept(complication) as CodeableConcept[] }), ...(complicationDetail && complicationDetail.length !== 0 && { complicationDetail: complicationDetail }), ...(transformedBasedOn && transformedBasedOn.length !== 0 && { basedOn: transformedBasedOn }), ...(transformedPartOf && transformedPartOf.length !== 0 && { partOf: transformedPartOf }), ...(note && note.length !== 0 && { note: note }) }; if (performedType === 'period') { return addFhirPeriodIfApplicable(procedure, item, 'performedPeriod'); } return procedure; }, [ createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, formatDateTime, getEncounterDisplay, transformToReferenceCollection ]); const transformFromFhirProcedure = useCallback((procedure: Procedure): any => { const { status, subject, recorder, asserter, encounter, category, code, bodySite, outcome, followUp, performedDateTime, performedPeriod, performedString, performedAge, performedRange, performer, reasonCode, reasonReference, note, meta, location, usedCode, usedReference, complication, complicationDetail, focalDevice, basedOn, partOf } = procedure; let performedType = ''; if (performedDateTime) performedType = 'dateTime'; else if (performedPeriod) performedType = 'period'; else if (performedString) performedType = 'string'; else if (performedAge) performedType = 'age'; else if (performedRange) performedType = 'range'; const formData = { meta: meta, status: status, subject: subject, encounter: encounter, location: location, recorder: recorder, asserter: asserter, category: transformFromCodeableConcept(category), code: transformFromCodeableConcept(code), bodySite: bodySite ? transformFromCodeableConcept(bodySite) : [], outcome: transformFromCodeableConcept(outcome), followUp: followUp ? transformFromCodeableConcept(followUp) : [], performedType: performedType, performedDateTime: formatDateTimeForInput(performedDateTime), performedString: performedString, performedAge: performedAge, performedRange: performedRange, performer: (performer ?? []).map((p: ProcedurePerformer) => ({ actor: transformFromReferenceCollection(p.actor ? [p.actor] : []), function: transformFromCodeableConcept(p.function) })), focalDevice: (focalDevice ?? []).map((fd: ProcedureFocalDevice) => ({ action: transformFromCodeableConcept(fd.action), manipulated: fd.manipulated })), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), usedCode: transformFromCodeableConcept(usedCode ?? []), usedReference: transformFromReferenceCollection(usedReference ?? []), complication: transformFromCodeableConcept(complication ?? []), complicationDetail: complicationDetail, note: note ?? [], basedOn: transformFromReferenceCollection(basedOn ?? []), partOf: transformFromReferenceCollection(partOf ?? []) }; if (performedPeriod) { return addPeriodControlsIfApplicable(formData, procedure, 'performedPeriod'); } return formData; }, [transformFromCodeableConcept, addFhirPeriodIfApplicable, transformFromReferenceCollection, formatDateTimeForInput]); const getProcedureDisplay = (procedure: Procedure): string => { if (!procedure) { return 'Unknown Procedure'; } // - was ` [${procedure.status}]`, the RAW code with not even a hyphen stripped, so this // read `Appendectomy [in-progress]`. Every Procedure event-status code is in the shared vocabulary. const procedureName = formatFhirCodeableConcept(procedure.code) || 'Unknown Procedure'; const procedureStatus = statusLabel(procedure.status); const status = procedureStatus ? ` ยท ${procedureStatus}` : ''; let dateSuffix = ''; if (procedure.performedDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(procedure.performedDateTime)})`; } else if (procedure.performedPeriod?.start) { dateSuffix = ` (${formatDateTimeForDisplay(procedure.performedPeriod.start)})`; } return `${procedureName}${status}${dateSuffix}`; }; return { proceduresBundle, // 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, proceduresList, setProceduresList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchProcedures, fetchProceduresWithPolling, fetchProcedure, fetchProcedureHistory, createProcedure, updateProcedure, deleteProcedure, archiveProcedure, transformToFhirProcedure, transformFromFhirProcedure, getProcedureDisplay }; }; const ProcedureServiceContext = createContext | undefined>(undefined); export const ProcedureServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const procedureService = useProcedureService(); return ( {children} ); }; /** Absent outside its provider, for readers that must never take a surface down with them. */ export const useOptionalProcedureServiceContext = () => useContext(ProcedureServiceContext); export const useProcedureServiceContext = () => { const context = useContext(ProcedureServiceContext); if (context === undefined) { throw new Error('useProcedureServiceContext must be used within a ProcedureServiceProvider'); } return context; };