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, ImmunizationRecommendation, Patient, ImmunizationRecommendationRecommendation, ImmunizationRecommendationRecommendationDateCriterion, Reference } 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 { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { codeableStatusLabel } from '../utils/fhirStatusVocabulary.ts'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useImmunizationRecommendationService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const [immunizationRecommendationsList, setImmunizationRecommendationsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [immunizationRecommendationsBundle, setImmunizationRecommendationsBundle] = useState | null>(null); const immunizationRecommendationsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { immunizationRecommendationsBundleRef.current = immunizationRecommendationsBundle; }, [immunizationRecommendationsBundle]); // 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. // Which full list read is published, for whom and when it started: a reader asking whether its list was read under // a newer entitlement looks here, never at its own request (a superseded read resolves without publishing). const [listRead, setListRead] = useState<{ patientId?: string; startedAt: number } | undefined>(undefined); const cacheOwner = useCacheOwner('ImmunizationRecommendation', () => { setLastUpserted(null); setImmunizationRecommendationsBundle(null); setImmunizationRecommendationsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setImmunizationRecommendationsBundle(null); setImmunizationRecommendationsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchImmunizationRecommendations = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('ImmunizationRecommendation', 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}/ImmunizationRecommendation?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchImmunizationRecommendationsWithPolling = 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('ImmunizationRecommendation', lastFetchTimeRef.current) && immunizationRecommendationsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return immunizationRecommendationsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: ImmunizationRecommendation[]; do { bundle = await fetchImmunizationRecommendations(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('ImmunizationRecommendation', 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); setImmunizationRecommendationsBundle(bundle); setImmunizationRecommendationsList(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); setListRead({ patientId: patient.id, startedAt: now }); return bundle; }, [fetchImmunizationRecommendations, unpackBundle, lastUpserted, shouldReload]); const fetchImmunizationRecommendation = useCallback(async (id: string): Promise => { const cachedRecommendation = cacheOwner.isCurrent(cacheOwner.claimed) ? immunizationRecommendationsList.find(recommendation => recommendation.id === id) : undefined; if (cachedRecommendation) { return cachedRecommendation; } return get(`${API_URL}/ImmunizationRecommendation/${id}`); }, [get, immunizationRecommendationsList]); const fetchImmunizationRecommendationHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/ImmunizationRecommendation/${id}/_history`, options); }, [get]); const createImmunizationRecommendation = useCallback(async (immunizationRecommendation: ImmunizationRecommendation): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(immunizationRecommendation.patient) as string); return post(`${API_URL}/ImmunizationRecommendation?${params.toString()}`, immunizationRecommendation); }, [post, extractReferenceId]); const updateImmunizationRecommendation = useCallback(async (immunizationRecommendation: ImmunizationRecommendation): Promise => { return put(`${API_URL}/ImmunizationRecommendation/${immunizationRecommendation.id}`, immunizationRecommendation); }, [put]); const deleteImmunizationRecommendation = useCallback(async (immunizationRecommendation: ImmunizationRecommendation): Promise => { return del(`${API_URL}/ImmunizationRecommendation/${immunizationRecommendation.id}`); }, [del]); const archiveImmunizationRecommendation = useCallback( async (immunizationRecommendation: ImmunizationRecommendation, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/ImmunizationRecommendation/${immunizationRecommendation.id}`, applyArchive(immunizationRecommendation, reason, note)), [put], ); const transformToFhirImmunizationRecommendation = useCallback((item: any, patient: Patient): ImmunizationRecommendation => { const { date, authority, identifier, recommendation } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); let immunizationRecommendation: ImmunizationRecommendation = { resourceType: 'ImmunizationRecommendation', patient: patientReference, ...(date && { date: formatDateTime(date) }), ...(authority && { authority: createReferenceObject('Organization', authority.id) }), ...(identifier && identifier.length > 0 && { identifier }), ...(recommendation && recommendation.length !== 0 && { recommendation: recommendation.map((rec: any) => { const transformedSupportingImmunization = transformToReferenceCollection(rec.supportingImmunization); const recommendationItem: ImmunizationRecommendationRecommendation = { forecastStatus: transformToCodeableConcept(rec.forecastStatus), ...(rec.vaccineCode && rec.vaccineCode.length > 0 && { vaccineCode: transformToCodeableConcept(rec.vaccineCode) }), ...(rec.targetDisease && { targetDisease: transformToCodeableConcept(rec.targetDisease) }), ...(rec.contraindicatedVaccineCode && rec.contraindicatedVaccineCode.length !== 0 && { contraindicatedVaccineCode: transformToCodeableConcept(rec.contraindicatedVaccineCode) }), ...(rec.forecastReason && rec.forecastReason.length !== 0 && { forecastReason: transformToCodeableConcept(rec.forecastReason) }), ...(rec.dateCriterion && rec.dateCriterion.length !== 0 && { dateCriterion: rec.dateCriterion.map((criterion: any) => ({ code: transformToCodeableConcept(criterion.code), value: formatDateTime(criterion.value) })) }), ...(rec.description && { description: rec.description }), ...(rec.series && { series: rec.series }), ...(rec.doseNumberPositiveInt && { doseNumberPositiveInt: rec.doseNumberPositiveInt }), ...(rec.doseNumberString && { doseNumberString: rec.doseNumberString }), ...(rec.seriesDosesPositiveInt && { seriesDosesPositiveInt: rec.seriesDosesPositiveInt }), ...(rec.seriesDosesString && { seriesDosesString: rec.seriesDosesString }), ...(transformedSupportingImmunization && transformedSupportingImmunization.length !== 0 && { supportingImmunization: transformedSupportingImmunization }), }; return recommendationItem; }) }) }; return immunizationRecommendation; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, transformToReferenceCollection]); const transformFromFhirImmunizationRecommendation = useCallback((immunizationRecommendation: ImmunizationRecommendation): any => { const { date, authority, identifier, recommendation } = immunizationRecommendation; const formData = { date: formatDateTimeForInput(date), authority: authority, identifier: identifier, recommendation: (recommendation ?? []).map((rec: ImmunizationRecommendationRecommendation) => { const extendedRecommendation: any = { vaccineCode: transformFromCodeableConcept(rec.vaccineCode ?? []), targetDisease: transformFromCodeableConcept(rec.targetDisease), contraindicatedVaccineCode: transformFromCodeableConcept(rec.contraindicatedVaccineCode ?? []), forecastStatus: transformFromCodeableConcept(rec.forecastStatus), forecastReason: transformFromCodeableConcept(rec.forecastReason ?? []), dateCriterion: (rec.dateCriterion || []).map((criterion: ImmunizationRecommendationRecommendationDateCriterion) => ({ code: transformFromCodeableConcept(criterion.code), value: formatDateTimeForInput(criterion.value) })), description: rec.description ?? '', series: rec.series ?? '', doseNumberPositiveInt: rec.doseNumberPositiveInt, doseNumberString: rec.doseNumberString ?? '', seriesDosesPositiveInt: rec.seriesDosesPositiveInt, seriesDosesString: rec.seriesDosesString ?? '', supportingImmunization: transformFromReferenceCollection(rec.supportingImmunization ?? []) }; return extendedRecommendation; }) }; return formData; }, [transformFromCodeableConcept, transformFromReferenceCollection, formatDateTimeForInput]); const getImmunizationRecommendationDisplay = (immunizationRecommendation: ImmunizationRecommendation): string => { if (!immunizationRecommendation) { return 'Unknown Immunization Recommendation'; } const firstRecommendation = immunizationRecommendation.recommendation && immunizationRecommendation.recommendation.length > 0 ? immunizationRecommendation.recommendation[0] : null; let recommendationName = 'Immunization Recommendation'; if (firstRecommendation) { const vaccineCode = firstRecommendation.vaccineCode && firstRecommendation.vaccineCode.length > 0 ? formatFhirCodeableConcept(firstRecommendation.vaccineCode[0]) : ''; const targetDisease = firstRecommendation.targetDisease ? formatFhirCodeableConcept(firstRecommendation.targetDisease) : ''; if (vaccineCode) { recommendationName = vaccineCode; } else if (targetDisease) { recommendationName = `Vaccine for ${targetDisease}`; } } // - was ` [${formatFhirCodeableConcept(...)}]`. A vaccine forecast is the one status here // that is genuinely ACTIONABLE (`due`, `overdue`), and none of the forecast codes is in the shared // vocabulary yet, so `codeableStatusLabel` deliberately falls through to the terminology display // rather than omitting it - omitting would hide an overdue immunisation. const forecastLabel = codeableStatusLabel(firstRecommendation?.forecastStatus); const forecastStatus = forecastLabel ? ` ยท ${forecastLabel}` : ''; let dateSuffix = ''; if (immunizationRecommendation.date) { dateSuffix = ` (${formatDateTimeForDisplay(immunizationRecommendation.date)})`; } return `${recommendationName}${forecastStatus}${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, listRead, immunizationRecommendationsBundle, immunizationRecommendationsList, setImmunizationRecommendationsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchImmunizationRecommendations, fetchImmunizationRecommendationsWithPolling, fetchImmunizationRecommendation, fetchImmunizationRecommendationHistory, createImmunizationRecommendation, updateImmunizationRecommendation, deleteImmunizationRecommendation, archiveImmunizationRecommendation, transformToFhirImmunizationRecommendation, transformFromFhirImmunizationRecommendation, getImmunizationRecommendationDisplay }; }; const ImmunizationRecommendationServiceContext = createContext | undefined>(undefined); export const ImmunizationRecommendationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const immunizationRecommendationService = useImmunizationRecommendationService(); return ( {children} ); }; export const useImmunizationRecommendationServiceContext = () => { const context = useContext(ImmunizationRecommendationServiceContext); if (context === undefined) { throw new Error('useImmunizationRecommendationServiceContext must be used within a ImmunizationRecommendationServiceProvider'); } return context; };