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, AdverseEvent, Patient, AdverseEventSuspectEntity, Reference, Encounter, AdverseEventSuspectEntityCausality, Practitioner, RelatedPerson } 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 { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useAdverseEventService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [adverseEventsList, setAdverseEventsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [adverseEventsBundle, setAdverseEventsBundle] = useState | null>(null); const adverseEventsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { adverseEventsBundleRef.current = adverseEventsBundle; }, [adverseEventsBundle]); // 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('AdverseEvent', () => { setLastUpserted(null); setAdverseEventsBundle(null); setAdverseEventsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setAdverseEventsBundle(null); setAdverseEventsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchAdverseEvents = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('AdverseEvent', 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}/AdverseEvent?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchAdverseEventsWithPolling = 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('AdverseEvent', lastFetchTimeRef.current) && adverseEventsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return adverseEventsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: AdverseEvent[]; do { bundle = await fetchAdverseEvents(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('AdverseEvent', 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); setAdverseEventsBundle(bundle); setAdverseEventsList(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; }, [fetchAdverseEvents, unpackBundle, lastUpserted, shouldReload]); const fetchAdverseEvent = useCallback(async (id: string): Promise => { const cachedEvent = cacheOwner.isCurrent(cacheOwner.claimed) ? adverseEventsList.find(event => event.id === id) : undefined; if (cachedEvent) { return cachedEvent; } return get(`${API_URL}/AdverseEvent/${id}`); }, [get, adverseEventsList]); const fetchAdverseEventHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/AdverseEvent/${id}/_history`, options); }, [get]); const createAdverseEvent = useCallback(async (adverseEvent: AdverseEvent): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(adverseEvent.subject) as string); return post(`${API_URL}/AdverseEvent?${params.toString()}`, adverseEvent); }, [post, extractReferenceId]); const updateAdverseEvent = useCallback(async (adverseEvent: AdverseEvent): Promise => { return put(`${API_URL}/AdverseEvent/${adverseEvent.id}`, adverseEvent); }, [put]); const deleteAdverseEvent = useCallback(async (adverseEvent: AdverseEvent): Promise => { return del(`${API_URL}/AdverseEvent/${adverseEvent.id}`); }, [del]); const archiveAdverseEvent = useCallback( async (adverseEvent: AdverseEvent, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/AdverseEvent/${adverseEvent.id}`, applyArchive(adverseEvent, reason, note)), [put], ); const transformToFhirAdverseEvent = useCallback((item: any, patient?: Patient, encounterState?: Encounter, recorder?: Practitioner | Patient | RelatedPerson): AdverseEvent => { const { actuality, category, event, encounter, date, detected, recordedDate, resultingCondition, location, seriousness, severity, outcome, contributor, suspectEntity, subjectMedicalHistory } = item; const transformedSubjectMedicalHistory = transformToReferenceCollection(subjectMedicalHistory); const transformedContributor = transformToReferenceCollection(contributor); const transformedSuspectEntity = (suspectEntity ?? []).map((entity: any) => { const transformedSuspectEntityInstance: Reference[] = transformToReferenceCollection(entity.instance); const transformedSuspectEntityCasualty: AdverseEventSuspectEntityCausality[] = entity.causality.map((causality: any) => { const casualtyItem: AdverseEventSuspectEntityCausality = { ...(causality.assessment && { assessment: transformToCodeableConcept(causality.assessment) }), ...(causality.productRelatedness && { productRelatedness: causality.productRelatedness }), ...(causality.method && { method: transformToCodeableConcept(causality.method) }) }; if (Object.keys(casualtyItem).length !== 0) { return casualtyItem } return null; }).filter(Boolean); if (transformedSuspectEntityInstance.length === 0) { return null; } const suspectEntityItem: AdverseEventSuspectEntity = { instance: transformedSuspectEntityInstance[0], ...(transformedSuspectEntityCasualty && transformedSuspectEntityCasualty.length !== 0 && { causality: transformedSuspectEntityCasualty }) }; if (Object.keys(suspectEntityItem).length !== 0) { return suspectEntityItem } return null; }).filter(Boolean); let adverseEvent: AdverseEvent = { resourceType: 'AdverseEvent', actuality: actuality, ...(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 }), ...(category && category.length !== 0 && { category: transformToCodeableConcept(category ?? []) }), ...(event && { event: transformToCodeableConcept(event) }), ...(date && { date: formatDateTime(date) }), ...(detected && { detected: formatDateTime(detected) }), ...(recordedDate && { recordedDate: formatDateTime(recordedDate) }), ...(resultingCondition && resultingCondition.length !== 0 && { resultingCondition: resultingCondition }), ...(location && { location: location }), ...(seriousness && { seriousness: transformToCodeableConcept(seriousness) }), ...(severity && { severity: transformToCodeableConcept(severity) }), ...(outcome && { outcome: transformToCodeableConcept(outcome) }), ...(transformedSubjectMedicalHistory && transformedSubjectMedicalHistory.length !== 0 && { subjectMedicalHistory: transformedSubjectMedicalHistory }), ...(transformedContributor && transformedContributor.length !== 0 && { contributor: transformedContributor }), ...(transformedSuspectEntity && transformedSuspectEntity.length !== 0 && { suspectEntity: transformedSuspectEntity }) }; return adverseEvent; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, getEncounterDisplay, transformToReferenceCollection]); const transformFromFhirAdverseEvent = useCallback((adverseEvent: AdverseEvent): any => { const { actuality, category, event, encounter, date, detected, recordedDate, resultingCondition, location, seriousness, severity, outcome, contributor, suspectEntity, subjectMedicalHistory } = adverseEvent; const formData = { actuality: actuality, category: transformFromCodeableConcept(category ?? []), event: transformFromCodeableConcept(event), encounter: encounter, date: formatDateTimeForInput(date), detected: formatDateTimeForInput(detected), recordedDate: formatDateTimeForInput(recordedDate), resultingCondition: resultingCondition, location: location, seriousness: transformFromCodeableConcept(seriousness), severity: transformFromCodeableConcept(severity), outcome: transformFromCodeableConcept(outcome), contributor: transformFromReferenceCollection(contributor ?? []), subjectMedicalHistory: transformFromReferenceCollection(subjectMedicalHistory ?? []), suspectEntity: (suspectEntity ?? []).map(entity => ({ instance: transformFromReferenceCollection(entity.instance ? [entity.instance] : []), causality: (entity.causality ?? []).map(causality => ({ assessment: transformFromCodeableConcept(causality.assessment), productRelatedness: causality.productRelatedness ?? '', method: transformFromCodeableConcept(causality.method) })) })) }; return formData; }, [transformFromCodeableConcept, transformFromReferenceCollection, formatDateTimeForInput]); const getAdverseEventDisplay = (adverseEvent: AdverseEvent): string => { if (!adverseEvent) { return 'Unknown Adverse Event'; } const eventName = formatFhirCodeableConcept(adverseEvent.event) || 'Adverse Event'; const actuality = adverseEvent.actuality ? ` [${adverseEvent.actuality}]` : ''; let dateSuffix = ''; if (adverseEvent.date) { dateSuffix = ` (${formatDateTimeForDisplay(adverseEvent.date)})`; } else if (adverseEvent.recordedDate) { dateSuffix = ` (${formatDateTimeForDisplay(adverseEvent.recordedDate)})`; } return `${eventName}${actuality}${dateSuffix}`; }; return { adverseEventsBundle, // 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, adverseEventsList, setAdverseEventsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchAdverseEvents, fetchAdverseEventsWithPolling, fetchAdverseEvent, fetchAdverseEventHistory, createAdverseEvent, updateAdverseEvent, deleteAdverseEvent, archiveAdverseEvent, transformToFhirAdverseEvent, transformFromFhirAdverseEvent, getAdverseEventDisplay }; }; const AdverseEventServiceContext = createContext | undefined>(undefined); export const AdverseEventServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const adverseEventService = useAdverseEventService(); return ( {children} ); }; export const useAdverseEventServiceContext = () => { const context = useContext(AdverseEventServiceContext); if (context === undefined) { throw new Error('useAdverseEventServiceContext must be used within a AdverseEventServiceProvider'); } return context; };