import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { Bundle, List, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useFhirServiceContext } from './FhirService.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.fhirClinicalApi; export const CLINICAL_LIST_CODES = { CURRENT_MEDICATIONS: 'current-medications', KNOWN_ALLERGIES: 'known-allergies', FAMILY_HISTORY: 'family-history', RELATED_PERSONS: 'related-persons' } as const; export const LIST_EMPTY_REASON_CODES = { NIL_KNOWN: 'nilknown' } as const; export type ClinicalListCode = typeof CLINICAL_LIST_CODES[keyof typeof CLINICAL_LIST_CODES]; export const CARE_VIEW_ANCHOR_TAG_SYSTEM = 'https://akashichealthrecords.com/fhir/tags'; export const CARE_VIEW_ANCHOR_TAG_CODE = 'care-view-anchor'; export const useListService = () => { const { get, post, put, delete: del } = useHttpService(); const { unpackBundle } = useFhirServiceContext(); const { patientState } = usePatientServiceContext(); const [clinicalListsBundle, setClinicalListsBundle] = useState | null>(null); const [clinicalListsList, setClinicalListsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const clinicalListsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { clinicalListsBundleRef.current = clinicalListsBundle; }, [clinicalListsBundle]); // 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('List', () => { setClinicalListsBundle(null); setClinicalListsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setClinicalListsBundle(null); setClinicalListsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchClinicalLists = useCallback(async (patient: Patient): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', `Patient/${patient.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/List?${params.toString()}`); }).then((bundle) => overlayHeldBundle('List', bundle, (resource) => refersToPatient(resource, patient.id)) as Bundle); }, [get]); const fetchCareViewAnchors = useCallback(async (patient: Patient): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', `Patient/${patient.id}`); params.append('_tag', `${CARE_VIEW_ANCHOR_TAG_SYSTEM}|${CARE_VIEW_ANCHOR_TAG_CODE}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/List?${params.toString()}`); }); }, [get]); const fetchClinicalListsWithCache = 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('List', lastFetchTimeRef.current) && clinicalListsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return clinicalListsBundleRef.current; } const operation = cacheOwner.begin(); const bundle = await fetchClinicalLists(patient); const entries = unpackBundle(bundle); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setClinicalListsBundle(bundle); setClinicalListsList(entries.filter((r) => !isArchived(r))); 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; }, [fetchClinicalLists, unpackBundle, shouldReload]); const createClinicalList = useCallback(async (list: List): Promise => { const subjectReference = list.subject?.reference; const params = new URLSearchParams(); if (subjectReference) { params.append("subject", subjectReference); } const createdList = await post( `${API_URL}/List?${params.toString()}`, list ); if (createdList?.resourceType === 'List' && createdList.id) { setClinicalListsList(prev => { const exists = prev.some(existingList => existingList.id === createdList.id); return exists ? prev.map(existingList => existingList.id === createdList.id ? createdList : existingList) : [createdList, ...prev]; }); if (clinicalListsBundleRef.current) { setClinicalListsBundle({ ...clinicalListsBundleRef.current, entry: [ { resource: createdList }, ...(clinicalListsBundleRef.current.entry || []) ] }); } setShouldReload(false); } else { setShouldReload(true); } return createdList; }, [post]); const updateClinicalList = useCallback(async (list: List): Promise => { const listId = list.id; if (!listId) { throw new Error('Cannot update List without an id.'); } const responseList = await put(`${API_URL}/List/${listId}`, list); const updatedList = responseList?.resourceType === 'List' && responseList.id ? responseList : list; setClinicalListsList(prev => prev.map(existingList => existingList.id === listId ? updatedList : existingList ) ); if (clinicalListsBundleRef.current?.entry) { setClinicalListsBundle({ ...clinicalListsBundleRef.current, entry: clinicalListsBundleRef.current.entry.map(entry => ({ ...entry, resource: entry.resource?.id === listId ? updatedList : entry.resource })) }); } setShouldReload(false); return updatedList; }, [put]); const deleteClinicalList = useCallback(async (list: List): Promise => { if (!list.id) { throw new Error('Cannot delete List without an id.'); } const deletedList = await del(`${API_URL}/List/${list.id}`); setClinicalListsList(prev => prev.filter(existingList => existingList.id !== list.id) ); if (clinicalListsBundleRef.current?.entry) { setClinicalListsBundle({ ...clinicalListsBundleRef.current, entry: clinicalListsBundleRef.current.entry.filter( entry => entry.resource?.id !== list.id ) }); } setShouldReload(false); return deletedList; }, [del]); const archiveClinicalList = useCallback(async (list: List, reason: ArchiveReason, note?: string): Promise => { if (!list.id) { throw new Error('Cannot archive List without an id.'); } const archivedList = await put(`${API_URL}/List/${list.id}`, applyArchive(list, reason, note)); setClinicalListsList(prev => prev.filter(existingList => existingList.id !== list.id) ); if (clinicalListsBundleRef.current?.entry) { setClinicalListsBundle({ ...clinicalListsBundleRef.current, entry: clinicalListsBundleRef.current.entry.filter( entry => entry.resource?.id !== list.id ) }); } setShouldReload(false); return archivedList; }, [put]); const getClinicalListCode = useCallback((list: List): string | undefined => { return list.code?.coding?.[0]?.code || list.code?.text; }, []); const isNilKnownClinicalList = useCallback((list: List, code: ClinicalListCode): boolean => { const listCode = getClinicalListCode(list); const emptyReasonCode = list.emptyReason?.coding?.[0]?.code; return listCode === code && emptyReasonCode === LIST_EMPTY_REASON_CODES.NIL_KNOWN; }, [getClinicalListCode]); const findNilKnownClinicalList = useCallback((code: ClinicalListCode): List | undefined => { return clinicalListsList.find(list => isNilKnownClinicalList(list, code)); }, [clinicalListsList, isNilKnownClinicalList]); const buildNilKnownClinicalList = useCallback(( patient: Patient, code: ClinicalListCode, title: string, emptyReasonText: string ): List => { return { resourceType: 'List', status: 'current', mode: 'snapshot', title, subject: { reference: `Patient/${patient.id}` }, code: { coding: [ { system: 'https://akashichealthrecords.com/fhir/CodeSystem/clinical-list-type', code, display: title } ], text: title }, emptyReason: { coding: [ { system: 'http://terminology.hl7.org/CodeSystem/list-empty-reason', code: LIST_EMPTY_REASON_CODES.NIL_KNOWN, display: 'Nil Known' } ], text: emptyReasonText }, date: new Date().toISOString() }; }, []); const confirmNilKnownClinicalList = useCallback(async ( patient: Patient, code: ClinicalListCode, title: string, emptyReasonText: string ): Promise => { const existingList = findNilKnownClinicalList(code); const nextList = buildNilKnownClinicalList(patient, code, title, emptyReasonText); if (existingList?.id) { return updateClinicalList({ ...existingList, ...nextList, id: existingList.id }); } return createClinicalList(nextList); }, [buildNilKnownClinicalList, createClinicalList, findNilKnownClinicalList, updateClinicalList]); const clearNilKnownClinicalList = useCallback(async (code: ClinicalListCode): Promise => { const existingList = findNilKnownClinicalList(code); if (existingList) { await deleteClinicalList(existingList); } }, [deleteClinicalList, findNilKnownClinicalList]); const confirmNoCurrentMedications = useCallback(async (patient: Patient): Promise => { return confirmNilKnownClinicalList( patient, CLINICAL_LIST_CODES.CURRENT_MEDICATIONS, 'Current medications', 'Patient reports no current medications' ); }, [confirmNilKnownClinicalList]); const confirmNoKnownAllergies = useCallback(async (patient: Patient): Promise => { return confirmNilKnownClinicalList( patient, CLINICAL_LIST_CODES.KNOWN_ALLERGIES, 'Known allergies', 'Patient reports no known allergies' ); }, [confirmNilKnownClinicalList]); const confirmNoKnownFamilyHistory = useCallback(async (patient: Patient): Promise => { return confirmNilKnownClinicalList( patient, CLINICAL_LIST_CODES.FAMILY_HISTORY, 'Family history', 'Patient reports no known family history' ); }, [confirmNilKnownClinicalList]); const confirmNoRelatedPersons = useCallback(async (patient: Patient): Promise => { return confirmNilKnownClinicalList( patient, CLINICAL_LIST_CODES.RELATED_PERSONS, 'Related persons', 'Patient reports nobody to add' ); }, [confirmNilKnownClinicalList]); const clearNoCurrentMedications = useCallback(async (): Promise => { return clearNilKnownClinicalList(CLINICAL_LIST_CODES.CURRENT_MEDICATIONS); }, [clearNilKnownClinicalList]); const clearNoKnownAllergies = useCallback(async (): Promise => { return clearNilKnownClinicalList(CLINICAL_LIST_CODES.KNOWN_ALLERGIES); }, [clearNilKnownClinicalList]); const hasConfirmedNoCurrentMedications = !!findNilKnownClinicalList(CLINICAL_LIST_CODES.CURRENT_MEDICATIONS); const hasConfirmedNoKnownAllergies = !!findNilKnownClinicalList(CLINICAL_LIST_CODES.KNOWN_ALLERGIES); const hasConfirmedNoKnownFamilyHistory = !!findNilKnownClinicalList(CLINICAL_LIST_CODES.FAMILY_HISTORY); const hasConfirmedNoRelatedPersons = !!findNilKnownClinicalList(CLINICAL_LIST_CODES.RELATED_PERSONS); 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, clinicalListsBundle, clinicalListsList, setClinicalListsList, shouldReload, setShouldReload, fetchClinicalLists, fetchCareViewAnchors, fetchClinicalListsWithCache, createClinicalList, updateClinicalList, deleteClinicalList, archiveClinicalList, isNilKnownClinicalList, findNilKnownClinicalList, confirmNoCurrentMedications, confirmNoKnownAllergies, confirmNoKnownFamilyHistory, confirmNoRelatedPersons, clearNoCurrentMedications, clearNoKnownAllergies, hasConfirmedNoCurrentMedications, hasConfirmedNoKnownAllergies, hasConfirmedNoKnownFamilyHistory, hasConfirmedNoRelatedPersons }; }; const ListServiceContext = createContext | undefined>(undefined); export const ListServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const listService = useListService(); return ( {children} ); }; export const useListServiceContext = () => { const context = useContext(ListServiceContext); if (context === undefined) { throw new Error('useListServiceContext must be used within a ListServiceProvider'); } return context; };