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, EpisodeOfCare, EpisodeOfCareStatusHistory, Organization, 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 { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.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.fhirBaseApi; export const useEpisodeOfCareService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, extractReferenceId } = useFhirServiceContext(); const [episodesOfCareList, setEpisodesOfCareList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [episodeOfCaresBundle, setEpisodeOfCaresBundle] = useState | null>(null); const episodeOfCaresBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); const cachedOrganizationRef = useRef(null); useEffect(() => { episodeOfCaresBundleRef.current = episodeOfCaresBundle; }, [episodeOfCaresBundle]); // 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('EpisodeOfCare', () => { setLastUpserted(null); setEpisodeOfCaresBundle(null); setEpisodesOfCareList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setEpisodeOfCaresBundle(null); setEpisodesOfCareList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchEpisodeOfCares = useCallback(async (patient: Patient, organization: Organization | null, options?: ReadOptions): Promise> => { return overlayHeldBundle('EpisodeOfCare', await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); // The org-less patients address holds no organization; the server scopes that read to the patient. if (organization?.id) params.append('organization', organization.id); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/EpisodeOfCare?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id) && (!organization?.id || resource.managingOrganization?.reference === `Organization/${organization.id}`)) as Bundle; }, [get]); const fetchEpisodeOfCaresWithPolling = useCallback(async (patient: Patient, organization: Organization | null, 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 cache answers only the practice it was read for, as well as only its patient. if (!forceReload && !shouldReload && !writtenSince('EpisodeOfCare', lastFetchTimeRef.current) && episodeOfCaresBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id) && cachedOrganizationRef.current === (organization?.id ?? null)) { return episodeOfCaresBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: EpisodeOfCare[]; do { bundle = await fetchEpisodeOfCares(patient, organization); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('EpisodeOfCare', 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); setEpisodeOfCaresBundle(bundle); setEpisodesOfCareList(entries.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; cachedOrganizationRef.current = organization?.id ?? null; // 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; }, [fetchEpisodeOfCares, unpackBundle, lastUpserted, shouldReload]); const fetchEpisodeOfCare = useCallback(async (id: string): Promise => { const cachedEpisode = cacheOwner.isCurrent(cacheOwner.claimed) ? episodesOfCareList.find(episode => episode.id === id) : undefined; if (cachedEpisode) { return cachedEpisode; } return get(`${API_URL}/EpisodeOfCare/${id}`); }, [get, episodesOfCareList]); const fetchEpisodeOfCareHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/EpisodeOfCare/${id}/_history`, options); }, [get]); const createEpisodeOfCare = useCallback(async (episodeOfCare: EpisodeOfCare): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(episodeOfCare.patient) as string); return post(`${API_URL}/EpisodeOfCare?${params.toString()}`, episodeOfCare); }, [post, extractReferenceId]); const updateEpisodeOfCare = useCallback(async (episodeOfCare: EpisodeOfCare): Promise => { return put(`${API_URL}/EpisodeOfCare/${episodeOfCare.id}`, episodeOfCare); }, [put]); const deleteEpisodeOfCare = useCallback(async (episodeOfCare: EpisodeOfCare): Promise => { return del(`${API_URL}/EpisodeOfCare/${episodeOfCare.id}`); }, [del]); const archiveEpisodeOfCare = useCallback(async (episodeOfCare: EpisodeOfCare, reason: ArchiveReason, note?: string): Promise => { return put(`${API_URL}/EpisodeOfCare/${episodeOfCare.id}`, applyArchive(episodeOfCare, reason, note)); }, [put]); const transformToFhirEpisodeOfCare = useCallback((item: any, patient?: Patient, organization?: Organization): EpisodeOfCare => { const { status, statusHistory, type, diagnosis, careManager, team, account } = item; let episodeOfCare: EpisodeOfCare = { resourceType: 'EpisodeOfCare', status: status, ...(patient && { patient: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(organization && { managingOrganization: createReferenceObject('Organization', organization.id as string, organization?.name) }), ...(statusHistory && statusHistory.length !== 0 && { statusHistory: statusHistory.map((history: any) => { // @ts-ignore const statusHistoryItem: EpisodeOfCareStatusHistory = { status: history.status }; return addFhirPeriodIfApplicable(statusHistoryItem, history, 'period'); }) }), ...(type && type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(diagnosis && diagnosis.length !== 0 && { diagnosis: diagnosis.map((d: any) => ({ condition: d.condition, ...(d.role && { role: transformToCodeableConcept(d.role) }), ...(d.rank && { rank: +d.rank }) })) }), ...(careManager && { careManager: careManager }), ...(team && team.length !== 0 && { team: team }), ...(account && account.length !== 0 && { account: account }) }; return addFhirPeriodIfApplicable(episodeOfCare, item, 'period'); }, [createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable]); const transformFromFhirEpisodeOfCare = useCallback((episodeOfCare: EpisodeOfCare): any => { const { status, statusHistory, type, diagnosis, careManager, team, account } = episodeOfCare; const formData = { status: status, statusHistory: (statusHistory ?? []).map(history => { const statusHistoryItem = { status: history.status }; return addPeriodControlsIfApplicable(statusHistoryItem, history, 'period'); }), type: transformFromCodeableConcept(type ?? []), diagnosis: (diagnosis ?? []).map(d => ({ condition: d.condition, role: transformFromCodeableConcept(d.role), rank: d.rank })), careManager: careManager, team: team ?? [], account: account ?? [] }; return addPeriodControlsIfApplicable(formData, episodeOfCare, 'period'); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable]); const getEpisodeOfCareDisplay = (episodeOfCare: EpisodeOfCare): string => { if (!episodeOfCare) { return 'Unknown Episode of Care'; } const types = episodeOfCare.type && episodeOfCare.type.length > 0 ? episodeOfCare.type.map(type => formatFhirCodeableConcept(type)).join(', ') : 'Episode of Care'; // - 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(episodeOfCare.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (episodeOfCare.period) { dateSuffix = ` (${formatFhirPeriod(episodeOfCare.period)})`; } return `${types}${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, episodeOfCaresBundle, episodesOfCareList, setEpisodesOfCareList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchEpisodeOfCares, fetchEpisodeOfCaresWithPolling, fetchEpisodeOfCare, fetchEpisodeOfCareHistory, createEpisodeOfCare, updateEpisodeOfCare, deleteEpisodeOfCare, archiveEpisodeOfCare, transformToFhirEpisodeOfCare, transformFromFhirEpisodeOfCare, getEpisodeOfCareDisplay }; }; const EpisodeOfCareServiceContext = createContext | undefined>(undefined); export const EpisodeOfCareServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const episodeOfCareService = useEpisodeOfCareService(); return ( {children} ); }; export const useEpisodeOfCareServiceContext = () => { const context = useContext(EpisodeOfCareServiceContext); if (context === undefined) { throw new Error('useEpisodeOfCareServiceContext must be used within an EpisodeOfCareServiceProvider'); } return context; };