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, RiskAssessment, Patient, RiskAssessmentPrediction, Encounter } 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 { 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 useRiskAssessmentService = () => { 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 [riskAssessmentsList, setRiskAssessmentsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [riskAssessmentsBundle, setRiskAssessmentsBundle] = useState | null>(null); const riskAssessmentsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { riskAssessmentsBundleRef.current = riskAssessmentsBundle; }, [riskAssessmentsBundle]); // 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('RiskAssessment', () => { setLastUpserted(null); setRiskAssessmentsBundle(null); setRiskAssessmentsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setRiskAssessmentsBundle(null); setRiskAssessmentsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchRiskAssessments = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('RiskAssessment', 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}/RiskAssessment?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchRiskAssessmentsWithPolling = 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('RiskAssessment', lastFetchTimeRef.current) && riskAssessmentsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return riskAssessmentsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: RiskAssessment[]; do { bundle = await fetchRiskAssessments(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('RiskAssessment', 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); setRiskAssessmentsBundle(bundle); setRiskAssessmentsList(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; }, [fetchRiskAssessments, unpackBundle, lastUpserted, shouldReload]); const fetchRiskAssessment = useCallback(async (id: string): Promise => { const cachedAssessment = cacheOwner.isCurrent(cacheOwner.claimed) ? riskAssessmentsList.find(assessment => assessment.id === id) : undefined; if (cachedAssessment) { return cachedAssessment; } return get(`${API_URL}/RiskAssessment/${id}`); }, [get, riskAssessmentsList]); const fetchRiskAssessmentHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/RiskAssessment/${id}/_history`, options); }, [get]); const createRiskAssessment = useCallback(async (riskAssessment: RiskAssessment, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(riskAssessment.subject) as string); return post(`${API_URL}/RiskAssessment?${params.toString()}`, riskAssessment, { ...transport }); }, [post, extractReferenceId]); const updateRiskAssessment = useCallback(async (riskAssessment: RiskAssessment): Promise => { return put(`${API_URL}/RiskAssessment/${riskAssessment.id}`, riskAssessment); }, [put]); const deleteRiskAssessment = useCallback(async (riskAssessment: RiskAssessment): Promise => { return del(`${API_URL}/RiskAssessment/${riskAssessment.id}`); }, [del]); const archiveRiskAssessment = useCallback( async (riskAssessment: RiskAssessment, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/RiskAssessment/${riskAssessment.id}`, applyArchive(riskAssessment, reason, note)), [put], ); const transformToFhirRiskAssessment = useCallback((item: any, patient?: Patient, encounterState?: Encounter): RiskAssessment => { const { status, method, code, encounter, occurrence, occurrenceDateTime, condition, performer, reasonCode, reasonReference, prediction, mitigation, note } = item; const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedPerformer = transformToReferenceCollection(performer); const transformedPrediction: RiskAssessmentPrediction[] = (prediction ?? []).map((p: any) => { const predictionItem: RiskAssessmentPrediction = { ...(p.outcome && { outcome: transformToCodeableConcept(p.outcome) }), ...(p.qualitativeRisk && { qualitativeRisk: transformToCodeableConcept(p.qualitativeRisk) }), ...(p.rationale && { rationale: p.rationale }), ...(p.relativeRisk && { relativeRisk: +p.relativeRisk }), ...(p.probability === 'decimal' && { probabilityDecimal: +p.probabilityDecimal }), ...(p.probability === 'range' && p.probabilityRange && { probabilityRange: { low: { ...p.probabilityRange.low, code: '%' }, high: { ...p.probabilityRange.high, code: '%' } } }), ...(p.when === 'range' && p.whenRange && { whenRange: p.whenRange }) }; if (p.when === 'period') { return addFhirPeriodIfApplicable(predictionItem, p, 'whenPeriod'); } if (Object.keys(predictionItem).length !== 0) { return predictionItem; } return null; }).filter(Boolean); let riskAssessment: RiskAssessment = { resourceType: 'RiskAssessment', status: status, ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(condition && { condition: condition }), ...(method && { method: transformToCodeableConcept(method) }), ...(code && { code: transformToCodeableConcept(code) }), ...(occurrence === 'dateTime' && occurrenceDateTime && { occurrenceDateTime: formatDateTime(occurrenceDateTime) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer[0] }), ...(mitigation && { mitigation: mitigation }), ...(note && note.length !== 0 && { note: note }), ...(transformedPrediction && transformedPrediction.length !== 0 && { prediction: transformedPrediction }) }; if (occurrence === 'period') { return addFhirPeriodIfApplicable(riskAssessment, item, 'occurrencePeriod'); } return riskAssessment; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, addFhirPeriodIfApplicable, getEncounterDisplay, transformToReferenceCollection ]); const transformFromFhirRiskAssessment = useCallback((riskAssessment: RiskAssessment): any => { const { status, method, code, subject, encounter, occurrenceDateTime, occurrencePeriod, condition, performer, reasonCode, reasonReference, prediction, mitigation, note } = riskAssessment; const formData = { status: status, method: transformFromCodeableConcept(method), code: transformFromCodeableConcept(code), subject: subject, encounter: encounter, condition: condition, occurrence: occurrenceDateTime ? 'dateTime' : occurrencePeriod ? 'period' : '', occurrenceDateTime: formatDateTimeForInput(occurrenceDateTime), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), performer: transformFromReferenceCollection(performer ? [performer] : []), mitigation: mitigation ?? '', note: note ?? [], prediction: (prediction ?? []).map((p: RiskAssessmentPrediction) => { const extendedPrediction: any = { probability: p.probabilityDecimal !== undefined ? 'decimal' : p.probabilityRange ? 'range' : '', probabilityDecimal: p.probabilityDecimal, probabilityRange: p.probabilityRange, when: p.whenRange ? 'range' : p.whenPeriod ? 'period' : '', whenRange: p.whenRange, outcome: transformFromCodeableConcept(p.outcome), qualitativeRisk: transformFromCodeableConcept(p.qualitativeRisk), rationale: p.rationale, relativeRisk: p.relativeRisk }; if (p.whenPeriod) { return addPeriodControlsIfApplicable(extendedPrediction, p, 'whenPeriod'); } return extendedPrediction; }) }; return addPeriodControlsIfApplicable(formData, riskAssessment, 'occurrencePeriod'); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable, transformFromReferenceCollection, formatDateTimeForInput]); const getRiskAssessmentDisplay = (riskAssessment: RiskAssessment): string => { if (!riskAssessment) { return 'Unknown Risk Assessment'; } const riskCode = formatFhirCodeableConcept(riskAssessment.code) || 'Risk Assessment'; // - 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(riskAssessment.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (riskAssessment.occurrenceDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(riskAssessment.occurrenceDateTime)})`; } else if (riskAssessment.occurrencePeriod) { dateSuffix = ` (${formatFhirPeriod(riskAssessment.occurrencePeriod)})`; } return `${riskCode}${status}${dateSuffix}`; }; 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, riskAssessmentsBundle, riskAssessmentsList, setRiskAssessmentsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchRiskAssessments, fetchRiskAssessmentsWithPolling, fetchRiskAssessment, fetchRiskAssessmentHistory, createRiskAssessment, updateRiskAssessment, deleteRiskAssessment, archiveRiskAssessment, transformToFhirRiskAssessment, transformFromFhirRiskAssessment, getRiskAssessmentDisplay }; }; const RiskAssessmentServiceContext = createContext | undefined>(undefined); export const RiskAssessmentServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const riskAssessmentService = useRiskAssessmentService(); return ( {children} ); }; export const useRiskAssessmentServiceContext = () => { const context = useContext(RiskAssessmentServiceContext); if (context === undefined) { throw new Error('useRiskAssessmentServiceContext must be used within a RiskAssessmentServiceProvider'); } return context; }; /** * The same accessor, tolerant of an absent provider. * * Used by the encounter's "Add to the record" door, which mounts on a surface whose tests and older * callers do not all wrap this provider. Absent it the door simply does not draw - a capability that * cannot write must not be offered - rather than taking the whole encounter down with it, which is the * rule `useOptionalScribeService` already states for an add-on and applies just as well here. */ export const useOptionalRiskAssessmentServiceContext = () => useContext(RiskAssessmentServiceContext);