import { useState, useCallback, useRef, createContext, useContext, ReactNode } from 'react'; import { Bundle, ImagingStudy, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { SaveRetiredError } from '../utils/saveOperation'; /** The operation a poll is made for: its reads are its requests, and it publishes only while the operation belongs. */ export interface PollOwner { belongs: () => boolean; request: { admit?: () => void; signal?: AbortSignal; organizationId?: string }; } // environment.fhirClinicalApi already ends in /R4 - do not append another /R4. // NOT environment.healthLakeEndpoint: that key does not exist on environment (see // MediaService.tsx / DiagnosticReportService.tsx, which reference it and therefore build URLs // beginning with the literal string "undefined" - a pre-existing bug, not copied here). const API_URL = environment.fhirClinicalApi; // HealthLake is eventually consistent and an import job (S3 upload + HealthImaging import + // importComplete writing the ImagingStudy) takes materially longer than a single resource write, // so a freshly-imported study will frequently be absent on the first read. 20 retries at 3s gives // the import meaningfully more time to land than MediaService's 10 retries at 3s. const POLL_MAX_RETRIES = 20; const POLL_DELAY_MS = 3000; const CACHE_FRESH_MS = 5 * 60 * 1000; export const useImagingStudyService = () => { const { get } = useHttpService(); const [imagingStudyBundle, setImagingStudyBundle] = useState | null>(null); const [imagingStudyList, setImagingStudyList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastFetchTime, setLastFetchTime] = useState(0); const cacheOwner = useCacheOwner(); // The practice that published the cached list; the patient id alone cannot vouch for a successor practice. const cacheScope = useRef(undefined); // subject, never patient. ImagingStudy's CapabilityStatement advertises subject/identifier/_id // as search parameters and does NOT advertise patient - a ?patient= query would pass the API // authorizer (which accepts patient/subject/for) and then fail at HealthLake, a split failure // across two layers that is painful to diagnose. const fetchImagingStudyList = useCallback(async (patient: Patient, owner?: PollOwner): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('subject', patient.id as string); // - there was no `_count` here at all, so the page size was whatever the server defaults to // - an unstated bound is still a bound, and this one was invisible from the client. params.append('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); const url = `${API_URL}/ImagingStudy?${params.toString()}`; return owner ? get>(url, owner.request) : get>(url); }); }, [get]); // Read-by-id for the viewer route, which is entered with a study id from the url rather than a // patient. The server's `GET /R4/ImagingStudy/{id}` route carries `requirePatientAccess: true` and // resolves the subject from the resource itself, so this needs no patient argument to be safe. const fetchImagingStudyById = useCallback(async (studyId: string): Promise => { return get(`${API_URL}/ImagingStudy/${encodeURIComponent(studyId)}`); }, [get]); // Polls until the subject has at least `minCount` ImagingStudy entries, up to // POLL_MAX_RETRIES. There is no client-known target id to match against (unlike Media's // lastUpserted pattern): the ImagingStudy resource is created server-side by importComplete, // after the client's upload call has already returned a jobId, not a study id. // // minCount defaults to 1, NOT because that's a safe generic default, but because a bare // non-empty check is WRONG for the case this function exists to serve: a returning patient // who already has prior studies. For them, `entry.length > 0` is true on the very first read // - before the newly-imported study has landed - because the OLD studies already satisfy it. // That reports success while the UI is still showing stale data, and nothing errors. // // The correct call from an upload flow is: capture `imagingStudyList.length` as a baseline // BEFORE triggering the upload, then poll with `minCount: baseline + 1`. That is what makes // "found" mean "a NEW study landed," not "some study, possibly old, exists." Do not // "simplify" this back to `entry.length > 0` - that silently reintroduces the stale-data bug // for every returning patient, which is the normal case here, not an edge case. const fetchImagingStudyListWithPolling = useCallback(async ( patient: Patient, forceReload = false, minCount = 1, owner?: PollOwner ): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTime; const cacheFresh = cacheAge < CACHE_FRESH_MS; const scope = `${patient.id}|${owner?.request.organizationId ?? ''}`; // An owned poll stops the moment its operation retires: no further read, and nothing it read is published. const admit = () => { if (owner && !owner.belongs()) throw new SaveRetiredError(); owner?.request.admit?.(); }; // A fresh bundle is a hit only when it is this patient's and, for an owned read, the admitted practice's. if (!forceReload && !shouldReload && imagingStudyBundle && cacheFresh && cacheOwner.isFor(patient.id) && (owner === undefined || cacheScope.current === scope)) { admit(); return imagingStudyBundle; } let retries = POLL_MAX_RETRIES; let found = false; let bundle: Bundle | null = null; do { admit(); bundle = await fetchImagingStudyList(patient, owner); found = (bundle.entry ?? []).length >= minCount; if (!found && retries > 1) { await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS)); } retries--; } while (!found && retries > 0); admit(); setImagingStudyBundle(bundle); setImagingStudyList((bundle.entry ?? []).map(entry => entry.resource) as ImagingStudy[]); cacheOwner.claim(patient.id); cacheScope.current = scope; setLastFetchTime(now); setShouldReload(false); return bundle; }, [fetchImagingStudyList, shouldReload, imagingStudyBundle, lastFetchTime, cacheOwner]); return { imagingStudyBundle, imagingStudyList, cachedForPatientId: cacheOwner.ownerId, shouldReload, setShouldReload, fetchImagingStudyList, fetchImagingStudyById, fetchImagingStudyListWithPolling }; }; const ImagingStudyServiceContext = createContext | undefined>(undefined); export const ImagingStudyServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const imagingStudyService = useImagingStudyService(); return ( {children} ); }; export const useImagingStudyServiceContext = () => { const context = useContext(ImagingStudyServiceContext); if (context === undefined) { throw new Error('useImagingStudyServiceContext must be used within an ImagingStudyServiceProvider'); } return context; };