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, DetectedIssue, Patient, Reference, DetectedIssueEvidence, PractitionerRole } 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 { usePractitionerRoleServiceContext } from './PractitionerRoleService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { usePatientServiceContext } from './PatientService.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 useDetectedIssueService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, extractReferenceId } = useFhirServiceContext(); const { getPractitionerRoleDisplay } = usePractitionerRoleServiceContext(); const [detectedIssuesList, setDetectedIssuesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [detectedIssuesBundle, setDetectedIssuesBundle] = useState | null>(null); const detectedIssuesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { detectedIssuesBundleRef.current = detectedIssuesBundle; }, [detectedIssuesBundle]); // 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('DetectedIssue', () => { setLastUpserted(null); setDetectedIssuesBundle(null); setDetectedIssuesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setDetectedIssuesBundle(null); setDetectedIssuesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchDetectedIssues = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('DetectedIssue', 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}/DetectedIssue?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchDetectedIssuesWithPolling = 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('DetectedIssue', lastFetchTimeRef.current) && detectedIssuesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return detectedIssuesBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: DetectedIssue[]; do { bundle = await fetchDetectedIssues(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('DetectedIssue', 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); setDetectedIssuesBundle(bundle); setDetectedIssuesList(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; }, [fetchDetectedIssues, unpackBundle, lastUpserted, shouldReload]); const fetchDetectedIssue = useCallback(async (id: string): Promise => { const cachedIssue = cacheOwner.isCurrent(cacheOwner.claimed) ? detectedIssuesList.find(issue => issue.id === id) : undefined; if (cachedIssue) { return cachedIssue; } return get(`${API_URL}/DetectedIssue/${id}`); }, [get, detectedIssuesList]); const fetchDetectedIssueHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/DetectedIssue/${id}/_history`, options); }, [get]); const createDetectedIssue = useCallback(async (detectedIssue: DetectedIssue): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(detectedIssue.patient) as string); return post(`${API_URL}/DetectedIssue?${params.toString()}`, detectedIssue); }, [post, extractReferenceId]); const updateDetectedIssue = useCallback(async (detectedIssue: DetectedIssue): Promise => { return put(`${API_URL}/DetectedIssue/${detectedIssue.id}`, detectedIssue); }, [put]); const deleteDetectedIssue = useCallback(async (detectedIssue: DetectedIssue): Promise => { return del(`${API_URL}/DetectedIssue/${detectedIssue.id}`); }, [del]); const archiveDetectedIssue = useCallback( async (detectedIssue: DetectedIssue, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/DetectedIssue/${detectedIssue.id}`, applyArchive(detectedIssue, reason, note)), [put], ); const transformToFhirDetectedIssue = useCallback((item: any, patient: Patient, author?: PractitionerRole): DetectedIssue => { const { status, code, severity, identifiedType, identifiedDateTime, evidence, detail, reference, mitigation } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); let detectedIssue: DetectedIssue = { resourceType: 'DetectedIssue', status: status, ...(patient && { patient: patientReference }), ...(code && { code: transformToCodeableConcept(code) }), ...(severity && { severity }), ...(identifiedType === 'dateTime' && identifiedDateTime && { identifiedDateTime: formatDateTime(identifiedDateTime) }), ...(author && { author: createReferenceObject('PractitionerRole', author.id as string, getPractitionerRoleDisplay(author)) }), ...(evidence && evidence.length !== 0 && { evidence: evidence.map((ev: any) => { const evidenceItem: DetectedIssueEvidence = { ...(ev.code && ev.code.length !== 0 && { code: transformToCodeableConcept(ev.code) }) }; return evidenceItem; }) }), ...(detail && { detail }), ...(reference && { reference }), ...(mitigation && mitigation.length !== 0 && { mitigation: mitigation.map((mit: any) => ({ action: transformToCodeableConcept(mit.action), ...(mit.date && { date: formatDateTime(mit.date) }) })) }) }; if (identifiedType === 'period') { delete detectedIssue.identifiedDateTime; return addFhirPeriodIfApplicable(detectedIssue, item, 'identifiedPeriod'); } return detectedIssue; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, addFhirPeriodIfApplicable]); const transformFromFhirDetectedIssue = useCallback((detectedIssue: DetectedIssue): any => { const { identifier, status, code, severity, identifiedDateTime, identifiedPeriod, evidence, detail, mitigation } = detectedIssue; const formData = { identifier: identifier || [], status: status, code: transformFromCodeableConcept(code), severity: severity, identifiedType: identifiedDateTime ? 'dateTime' : identifiedPeriod ? 'period' : '', identifiedDateTime: formatDateTimeForInput(identifiedDateTime), evidence: (evidence ?? []).map(ev => ({ code: transformFromCodeableConcept(ev.code ?? []) })), detail: detail ?? '', mitigation: (mitigation ?? []).map(mit => ({ action: transformFromCodeableConcept(mit.action), date: formatDateTimeForInput(mit.date), })) }; return addPeriodControlsIfApplicable(formData, detectedIssue, 'identifiedPeriod'); }, [transformFromCodeableConcept, formatDateTime]); const getDetectedIssueDisplay = (detectedIssue: DetectedIssue): string => { if (!detectedIssue) { return 'Unknown Detected Issue'; } const issueCode = formatFhirCodeableConcept(detectedIssue.code) || 'Detected Issue'; // - 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(detectedIssue.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (detectedIssue.identifiedDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(detectedIssue.identifiedDateTime)})`; } else if (detectedIssue.identifiedPeriod) { dateSuffix = ` (${formatFhirPeriod(detectedIssue.identifiedPeriod)})`; } return `${issueCode}${status}${dateSuffix}`; }; return { detectedIssuesBundle, // 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, detectedIssuesList, setDetectedIssuesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDetectedIssues, fetchDetectedIssuesWithPolling, fetchDetectedIssue, fetchDetectedIssueHistory, createDetectedIssue, updateDetectedIssue, deleteDetectedIssue, archiveDetectedIssue, transformToFhirDetectedIssue, transformFromFhirDetectedIssue, getDetectedIssueDisplay, }; }; const DetectedIssueServiceContext = createContext | undefined>(undefined); export const DetectedIssueServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const detectedIssueService = useDetectedIssueService(); return ( {children} ); }; export const useDetectedIssueServiceContext = () => { const context = useContext(DetectedIssueServiceContext); if (context === undefined) { throw new Error('useDetectedIssueServiceContext must be used within a DetectedIssueServiceProvider'); } return context; };