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, Flag, Patient, Practitioner, 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 { usePatientServiceContext } from './PatientService.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.fhirBaseApi; export const useFlagService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, unpackBundle, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable } = useFhirServiceContext(); const [flagsList, setFlagsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [flagsBundle, setFlagsBundle] = useState | null>(null); const flagsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { flagsBundleRef.current = flagsBundle; }, [flagsBundle]); // 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('Flag', () => { setLastUpserted(null); setFlagsBundle(null); setFlagsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setFlagsBundle(null); setFlagsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchFlags = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('Flag', 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}/Flag?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchFlagsWithPolling = 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('Flag', lastFetchTimeRef.current) && flagsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return flagsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Flag[]; do { bundle = await fetchFlags(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Flag', 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); setFlagsBundle(bundle); setFlagsList(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; }, [fetchFlags, unpackBundle, lastUpserted, shouldReload]); const fetchFlag = useCallback(async (id: string): Promise => { const cachedFlag = cacheOwner.isCurrent(cacheOwner.claimed) ? flagsList.find(flag => flag.id === id) : undefined; if (cachedFlag) { return cachedFlag; } return get(`${API_URL}/Flag/${id}`); }, [get, flagsList]); const fetchFlagHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Flag/${id}/_history`, options); }, [get]); const createFlag = useCallback(async (flag: Flag): Promise => { return post(`${API_URL}/Flag`, flag); }, [post]); const updateFlag = useCallback(async (flag: Flag): Promise => { return put(`${API_URL}/Flag/${flag.id}`, flag); }, [put]); const deleteFlag = useCallback(async (flag: Flag): Promise => { return del(`${API_URL}/Flag/${flag.id}`); }, [del]); const archiveFlag = useCallback(async (flag: Flag, reason: ArchiveReason, note?: string): Promise => { return put(`${API_URL}/Flag/${flag.id}`, applyArchive(flag, reason, note)); }, [put]); const transformToFhirFlag = useCallback((item: any, subjectType?: 'Patient', newSubject?: Patient, authorType?: 'Practitioner', newAuthor?: Practitioner, newEncounter?: Encounter): Flag => { const { status, category, code, subject, encounter, author } = item; const flag: Flag = { resourceType: 'Flag', status: status, code: transformToCodeableConcept(code), ...(category && { category: transformToCodeableConcept(category) }), ...((subject || newSubject) && { subject: newSubject ? createReferenceObject(subjectType as string, newSubject.id as string, formatFhirHumanName((newSubject as Patient).name)) : subject }), ...((author || newAuthor) && { author: newAuthor ? createReferenceObject(authorType as string, newAuthor.id as string, formatFhirHumanName((newAuthor as Practitioner).name)) : author }), ...((encounter || newEncounter) && { encounter: newEncounter ? createReferenceObject('Encounter', encounter.id as string) : encounter }), }; return addFhirPeriodIfApplicable(flag, item, 'period'); }, [createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable]); const transformFromFhirFlag = useCallback((flag: Flag): any => { const { status, category, code, subject, encounter, author } = flag; const formData = { status: status, category: transformFromCodeableConcept(category), code: transformFromCodeableConcept(code), subject, encounter, author }; return addPeriodControlsIfApplicable(formData, flag); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable]); 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, flagsBundle, flagsList, setFlagsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchFlags, fetchFlagsWithPolling, fetchFlag, fetchFlagHistory, createFlag, updateFlag, deleteFlag, archiveFlag, transformToFhirFlag, transformFromFhirFlag }; }; const FlagServiceContext = createContext | undefined>(undefined); export const FlagServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const flagService = useFlagService(); return ( {children} ); }; export const useFlagServiceContext = () => { const context = useContext(FlagServiceContext); if (context === undefined) { throw new Error('useFlagServiceContext must be used within a FlagServiceProvider'); } return context; };