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 { fromManufacturerReference, toManufacturerReference } from '../utils/fhir/manufacturerReference'; import { Bundle, Immunization, Patient, Reference, Encounter, ImmunizationProtocolApplied, ProcedurePerformer, ImmunizationEducation, ImmunizationReaction } 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 { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { formatDate as formatDisplayDate, formatTimeOfDay } from '../utils/dateUtils'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useImmunizationService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDate, formatDateTime, formatDateTimeForInput, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [immunizationsList, setImmunizationsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [immunizationsBundle, setImmunizationsBundle] = useState | null>(null); const immunizationsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { immunizationsBundleRef.current = immunizationsBundle; }, [immunizationsBundle]); // 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('Immunization', () => { setLastUpserted(null); setImmunizationsBundle(null); setImmunizationsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setImmunizationsBundle(null); setImmunizationsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchImmunizations = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('Immunization', 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}/Immunization?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchImmunizationsWithPolling = 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('Immunization', lastFetchTimeRef.current) && immunizationsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return immunizationsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Immunization[]; do { bundle = await fetchImmunizations(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Immunization', 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); setImmunizationsBundle(bundle); setImmunizationsList(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; }, [fetchImmunizations, unpackBundle, lastUpserted, shouldReload]); const fetchImmunization = useCallback(async (id: string): Promise => { const cachedImmunization = cacheOwner.isCurrent(cacheOwner.claimed) ? immunizationsList.find(immunization => immunization.id === id) : undefined; if (cachedImmunization) { return cachedImmunization; } return get(`${API_URL}/Immunization/${id}`); }, [get, immunizationsList]); const fetchImmunizationHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Immunization/${id}/_history`, options); }, [get]); const createImmunization = useCallback(async (immunization: Immunization, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(immunization.patient) as string); return post(`${API_URL}/Immunization?${params.toString()}`, immunization, { ...transport }); }, [post, extractReferenceId]); const updateImmunization = useCallback(async (immunization: Immunization): Promise => { return put(`${API_URL}/Immunization/${immunization.id}`, immunization); }, [put]); const deleteImmunization = useCallback(async (immunization: Immunization): Promise => { return del(`${API_URL}/Immunization/${immunization.id}`); }, [del]); const archiveImmunization = useCallback( async (immunization: Immunization, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/Immunization/${immunization.id}`, applyArchive(immunization, reason, note)), [put], ); const transformToFhirImmunization = useCallback((item: any, patient: Patient, encounterState?: Encounter): Immunization => { const { status, statusReason, vaccineCode, manufacturer, encounter, occurrenceDateTime, recorded, primarySource, reportOrigin, location, lotNumber, expirationDate, site, route, doseQuantity, performer, note, reasonCode, reasonReference, isSubpotent, subpotentReason, programEligibility, fundingSource, reaction, protocolApplied, education, meta } = item; const usCoreImmunizationProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-immunization'; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const transformedReasonReference = transformToReferenceCollection(reasonReference); // An absent list is no entries, not an error: a vaccine filed from the note offers no performer or education, // and the resource leaves out an empty list either way. const transformedPerformer: ProcedurePerformer[] = (performer ?? []).map((p: any) => { const transformedPerformerActor: Reference[] = transformToReferenceCollection(p.actor); if (transformedPerformerActor.length === 0) { return null; } const performerItem: ProcedurePerformer = { actor: transformedPerformerActor[0], ...(p.function && { function: transformToCodeableConcept(p.function) }) }; if (Object.keys(performerItem).length !== 0) { return performerItem; } return null; }).filter(Boolean); const transformedEducation: ImmunizationEducation[] = (education ?? []).map((e: any) => { const educationItem: ImmunizationEducation = { ...(e.documentType && { documentType: e.documentType }), ...(e.reference && { reference: e.reference }), ...(e.publicationDate && { publicationDate: formatDateTime(e.publicationDate) }), ...(e.presentationDate && { presentationDate: formatDateTime(e.presentationDate) }) }; if (Object.keys(educationItem).length !== 0) { return educationItem; } return null; }).filter(Boolean); let immunization: Immunization = { resourceType: 'Immunization', status: status, patient: patientReference, meta: { ...meta, profile: [usCoreImmunizationProfile] }, ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(location && { location: location }), ...(status === 'not-done' && statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(vaccineCode && { vaccineCode: transformToCodeableConcept(vaccineCode) }), ...(occurrenceDateTime && { occurrenceDateTime: formatDateTime(occurrenceDateTime) }), ...(recorded && { recorded: formatDateTime(recorded) }), ...(primarySource !== undefined && { primarySource: primarySource }), ...(reportOrigin && primarySource === false && { reportOrigin: transformToCodeableConcept(reportOrigin) }), ...(toManufacturerReference(manufacturer) && { manufacturer: toManufacturerReference(manufacturer) }), ...(lotNumber && { lotNumber: lotNumber }), ...(expirationDate && { expirationDate: formatDate(expirationDate) }), ...(site && { site: transformToCodeableConcept(site) }), ...(route && { route: transformToCodeableConcept(route) }), ...(doseQuantity && { doseQuantity: transformToFhirQuantity(doseQuantity) }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer }), ...(transformedEducation && transformedEducation.length !== 0 && { education: transformedEducation }), ...(note && note.length !== 0 && { note: note }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(isSubpotent !== undefined && { isSubpotent: isSubpotent }), ...(isSubpotent && subpotentReason && subpotentReason.length !== 0 && { subpotentReason: transformToCodeableConcept(subpotentReason) }), ...(programEligibility && programEligibility.length !== 0 && { programEligibility: transformToCodeableConcept(programEligibility) }), ...(fundingSource && { fundingSource: transformToCodeableConcept(fundingSource) }), ...(reaction && reaction.length !== 0 && { reaction: reaction.map((r: any) => ({ ...(r.date && { date: formatDateTime(r.date) }), ...(r.detail && { detail: r.detail }), reported: !!r.reported })) }), ...(protocolApplied && protocolApplied.length !== 0 && { protocolApplied: protocolApplied.map((p: any) => ({ doseNumberPositiveInt: p.doseNumberPositiveInt, ...(p.series && { series: p.series }), ...(p.targetDisease && { targetDisease: transformToCodeableConcept(p.targetDisease) }), ...(p.seriesDosesPositiveInt && { seriesDosesPositiveInt: p.seriesDosesPositiveInt }) })) }) }; return immunization; }, [ createReferenceObject, transformToCodeableConcept, formatDate, formatDateTime, transformToFhirQuantity, getEncounterDisplay, transformToReferenceCollection ]); const transformFromFhirImmunization = useCallback((immunization: Immunization): any => { const { status, statusReason, vaccineCode, encounter, occurrenceDateTime, recorded, primarySource, reportOrigin, location, manufacturer, lotNumber, expirationDate, site, route, doseQuantity, performer, note, reasonCode, reasonReference, isSubpotent, subpotentReason, programEligibility, fundingSource, reaction, protocolApplied, education, meta } = immunization; const formData = { meta: meta, status: status, statusReason: transformFromCodeableConcept(statusReason), vaccineCode: transformFromCodeableConcept(vaccineCode), occurrenceDateTime: formatDateTimeForInput(occurrenceDateTime), recorded: formatDateTimeForInput(recorded), primarySource: !!primarySource, reportOrigin: transformFromCodeableConcept(reportOrigin), encounter: encounter, location: location, manufacturer: fromManufacturerReference(manufacturer), lotNumber: lotNumber ?? '', expirationDate: expirationDate ?? '', site: transformFromCodeableConcept(site), route: transformFromCodeableConcept(route), doseQuantity: transformFromFhirQuantity(doseQuantity), performer: (performer ?? []).map((p: ProcedurePerformer) => ({ actor: transformFromReferenceCollection(p.actor ? [p.actor] : []), function: transformFromCodeableConcept(p.function) })), note: note || [], reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), isSubpotent: isSubpotent !== undefined ? isSubpotent : false, subpotentReason: transformFromCodeableConcept(subpotentReason ?? []), programEligibility: transformFromCodeableConcept(programEligibility ?? []), fundingSource: transformFromCodeableConcept(fundingSource), reaction: (reaction ?? []).map((r: ImmunizationReaction) => ({ ...r, date: formatDateTimeForInput(r.date) })), protocolApplied: (protocolApplied ?? []).map((protocol: ImmunizationProtocolApplied) => ({ ...protocol, targetDisease: transformFromCodeableConcept(protocol.targetDisease) })), education: (education ?? []).map((e: ImmunizationEducation) => ({ documentType: e.documentType ?? '', reference: e.reference ?? '', publicationDate: formatDateTimeForInput(e.publicationDate), presentationDate: formatDateTimeForInput(e.presentationDate) })) }; return formData; }, [ transformFromCodeableConcept, transformFromFhirQuantity, transformFromReferenceCollection, formatDateTimeForInput ]); const getImmunizationDisplay = (immunization: Immunization): string => { if (!immunization) { return 'Unknown Immunization'; } const vaccineName = formatFhirCodeableConcept(immunization.vaccineCode) || 'Unknown Vaccine'; // - 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(immunization.status); const status = statusText ? ` ยท ${statusText}` : ''; // A DISPLAY DATE, NOT THE STORED ONE. `formatDateTime` from the FHIR service writes the wire value, so the row // title read "(2026-10-15T14:00:00.000Z)". An instant reads in local time as the details view does, a day as // that day, and a year or month as it was recorded rather than as a guessed day. let dateSuffix = ''; const at = immunization.occurrenceDateTime; if (at) { const shown = at.includes('T') ? `${formatDisplayDate(at)}, ${formatTimeOfDay(at)}` : /^\d{4}-\d{2}-\d{2}$/.test(at) ? formatDisplayDate(at) : at; dateSuffix = ` (${shown})`; } else if (immunization.occurrenceString) { dateSuffix = ` (${immunization.occurrenceString})`; } return `${vaccineName}${status}${dateSuffix}`; }; return { immunizationsBundle, // 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, immunizationsList, setImmunizationsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchImmunizations, fetchImmunizationsWithPolling, fetchImmunization, fetchImmunizationHistory, createImmunization, updateImmunization, deleteImmunization, archiveImmunization, transformToFhirImmunization, transformFromFhirImmunization, getImmunizationDisplay }; }; const ImmunizationServiceContext = createContext | undefined>(undefined); export const ImmunizationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const immunizationService = useImmunizationService(); return ( {children} ); }; /** Absent outside its provider, for readers that must never take a surface down with them. */ export const useOptionalImmunizationServiceContext = () => useContext(ImmunizationServiceContext); export const useImmunizationServiceContext = () => { const context = useContext(ImmunizationServiceContext); if (context === undefined) { throw new Error('useImmunizationServiceContext must be used within a ImmunizationServiceProvider'); } return context; };