import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import type { Bundle, Patient, PractitionerRole, Resource } from 'fhir/r4'; import { selectVersionAsOf } from '../utils/selectVersionAsOf'; import { asSearched, onViewerChange, viewerEpoch } from '../utils/writeThroughHold'; import { forThisViewer, ownedRead, type ReadRequest } from '../utils/ownedRead'; // How long deleted-row discovery may take before the snapshot says deleted rows were not shown. const DELETED_DISCOVERY_DEADLINE_MS = 20_000; import { useSnapshotRowLabels } from './useSnapshotRowLabels'; import type { ReadOptions } from './anchorView/readOptions'; import { SNAPSHOT_TYPES, type SnapshotSection, } from '../components/PatientSnapshot/snapshotRegistry'; import { useAdverseEventServiceContext, useAllergyIntoleranceServiceContext, useAuthServiceContext, useCarePlanServiceContext, useCareTeamServiceContext, useClinicalImpressionServiceContext, useConditionServiceContext, useCoverageServiceContext, useDetectedIssueServiceContext, useDeviceRequestServiceContext, useDeviceServiceContext, useDeviceUseStatementServiceContext, useDocumentReferenceServiceContext, useEncounterServiceContext, useEpisodeOfCareServiceContext, useFamilyMemberHistoryServiceContext, useFlagServiceContext, useGoalServiceContext, useImmunizationEvaluationServiceContext, useImmunizationRecommendationServiceContext, useImmunizationServiceContext, useMedicationAdministrationServiceContext, useMedicationDispenseServiceContext, useMedicationRequestServiceContext, useMedicationStatementServiceContext, useNutritionOrderServiceContext, useObservationServiceContext, useProcedureServiceContext, useQuestionnaireResponseServiceContext, useRiskAssessmentServiceContext, useTaskServiceContext, useVisionPrescriptionServiceContext, useAuditEventServiceContext, } from '../services'; import { conditionIdsDeletedInAudit } from '../utils/deletedConditionIdsFromAudit'; import { STAFF_WORKSPACES, getPractitionerRoleWorkspaces } from '../utils/staffRoleCatalog'; export interface SnapshotItem { resourceType: string; id: string; resource: Resource; label: string; lastUpdated?: string; } export interface SnapshotTypeResult { resourceType: string; label: string; section: SnapshotSection; status: 'idle' | 'loading' | 'ready' | 'error'; items: SnapshotItem[]; truncatedCount: number; error?: string; /** Rows deleted from the record could not be looked for, so they are missing from a past date. */ omitsDeleted?: boolean; } /** * A simple promise pool: schedule up to `limit` tasks concurrently. Used to cap the * per-resource `_history` reads across the whole reconstruction run (no new dependency). */ function createPool(limit: number) { let active = 0; const queue: Array<() => void> = []; const pump = () => { while (active < limit && queue.length > 0) { const run = queue.shift() as () => void; active++; run(); } }; return function schedule(task: () => Promise): Promise { return new Promise((resolve, reject) => { queue.push(() => { task() .then(resolve, reject) .finally(() => { active--; pump(); }); }); pump(); }); }; } export interface BoundEntry { resourceType: string; section: SnapshotSection; label: string; hasDisplay: boolean; search: (p: Patient, options?: ReadOptions) => Promise>; history: (id: string, options?: ReadOptions) => Promise>; rowLabel: (r: Resource) => string; /** * Ids the live search cannot see. FHIR DELETE drops the row from SEARCH; `_history` still * reconstructs once the id is known. Discovery is additive and must not invent clinical facts. */ idsBeyondSearch?: (p: Patient, request?: ReadRequest) => Promise; /** Deleted rows exist for this type but this user cannot discover them, so the result must say so. */ deletedOutOfReach?: boolean; } /** The React-side dependencies `processSnapshotType` needs, passed in rather than closed over. */ export interface SnapshotRunContext { patient: Patient; asOfIso: string; /** False once this run has been superseded (patient or "as of" changed mid-flight). */ isCurrent: () => boolean; /** Every discovery read this run started, so its end can cancel them. */ reads?: Set; /** Per-call options for each search and history read, asked at the moment each read is sent. */ read?: () => ReadOptions | undefined; /** Caps concurrent `_history` reads across the whole run. */ schedule: (task: () => Promise) => Promise; updateResult: (resourceType: string, patch: Partial) => void; /** * Versions this page wrote itself, for a snapshot of NOW only. The record's history trails a write by * a minute or more (C9: Finish returned v8, and `_history` 69 seconds later still ended at v7, in * progress), so a known version that is newer than what the history returned is the one shown. */ known?: readonly Resource[]; } /** * One chart type's pipeline: enumerate via the raw patient search, walk each resource's `_history`, * pick the version live at T, and report `ready` - or report `error` if the SEARCH itself failed. * * Why this is exported rather than a closure inside the hook * The question this card turns on is whether a REFUSED read reaches the UI as an error or is folded * into a successful-but-empty result that renders as "Nothing recorded currently" - a false clinical * assertion. That could not be answered by mounting `usePatientSnapshot`: it pulls ~30 service * contexts and memoises its entry list on all of them, so any stub with an unstable identity causes * an infinite re-render that starves the event loop (the run emits only vitest's RUN banner and even * `--testTimeout` never fires). Lifting the pipeline out makes the behaviour testable directly, the * same way extracted pure geometry because jsdom reports zero for every bounding box. * * THE ASYMMETRY HERE IS DELIBERATE, and both halves are pinned by tests: * - a failed **search** fails the whole type -> `status: 'error'`, because we then know nothing * about it and must not imply the patient has none; * - a failed **per-resource history** does NOT -> it folds into `truncatedCount`, because the other * items are still trustworthy and "couldn't reconstruct this one" is the honest report. * * Staleness on the error path is enforced by the caller's `updateResult` (the hook's own copy * returns early when the run token has moved), matching the behaviour this replaced exactly. */ const instant = (iso?: string): number => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? -Infinity : t; }; /** The server grants Admin per organization, so only an active role at this one counts. */ export function holdsAdminAt( roles: readonly PractitionerRole[] | undefined, organizationId: string | undefined, groupOf: ((role: PractitionerRole) => string | undefined) | undefined, ): boolean { if (!organizationId) return false; return (roles ?? []).some((role) => role.active !== false && role.organization?.reference === `Organization/${organizationId}` && getPractitionerRoleWorkspaces(role, groupOf?.(role)).includes(STAFF_WORKSPACES.ADMIN)); } export async function processSnapshotType( bound: BoundEntry, { patient, asOfIso, isCurrent, schedule, updateResult, known, reads, read }: SnapshotRunContext, ): Promise { try { // Every id the index has, not the current view: a delete hides a row now, not on an earlier date. const bundle = asSearched(await bound.search(patient, read?.())); if (!isCurrent()) return; const resources = (bundle.entry ?? []) .map((e) => e.resource) .filter((r): r is Resource => Boolean(r && r.id)); const knownHere = new Map((known ?? []) .filter((r) => r.resourceType === bound.resourceType && r.id) .map((r) => [r.id as string, r])); // A visit the search has not indexed yet is still this patient's. for (const [id, r] of knownHere) if (!resources.some((x) => x.id === id)) resources.push(r); // FHIR DELETE drops the row from SEARCH. Extra ids use the same history path as search hits; // a refused discovery must not blank types the live search already returned. let omitsDeleted = Boolean(bound.deletedOutOfReach); if (bound.idsBeyondSearch) { try { const discover = bound.idsBeyondSearch; const extraIds = await ownedRead((request) => discover(patient, request), { deadlineMs: DELETED_DISCOVERY_DEADLINE_MS, owns: isCurrent, track: reads }); if (!isCurrent()) return; for (const id of extraIds) { if (!id || resources.some((x) => x.id === id)) continue; resources.push({ resourceType: bound.resourceType, id } as Resource); } } catch { if (!isCurrent()) return; omitsDeleted = true; } } const items: SnapshotItem[] = []; // A search served as of a cutoff and not in full left something unknown out: never a complete type. let truncatedCount = isAsOfIncomplete(bundle) ? 1 : 0; await Promise.all( resources.map((resource) => schedule(async () => { if (!isCurrent()) return; const id = resource.id as string; try { const hist = await bound.history(id, read?.()); if (!isCurrent()) return; const fromHistory = selectVersionAsOf(hist, asOfIso); const mine = knownHere.get(id); const sel = mine && (fromHistory.kind !== 'selected' || instant(mine.meta?.lastUpdated) > instant(fromHistory.lastUpdated)) ? { kind: 'selected' as const, resource: mine, lastUpdated: mine.meta?.lastUpdated } : fromHistory; if (sel.kind === 'selected') { items.push({ resourceType: bound.resourceType, id, resource: sel.resource, label: bound.rowLabel(sel.resource), lastUpdated: sel.lastUpdated, }); } else if (sel.kind === 'truncated') { truncatedCount += 1; } // sel.kind === 'omit' -> skip (didn't exist / deleted at T) } catch { // Per-resource isolation: one resource's history/select // failure doesn't fail the whole type. Fold it into // truncatedCount (both mean "couldn't reconstruct this item"). truncatedCount += 1; } }), ), ); if (!isCurrent()) return; items.sort((a, b) => (b.lastUpdated ?? '').localeCompare(a.lastUpdated ?? '')); updateResult(bound.resourceType, { status: 'ready', items, truncatedCount, ...(omitsDeleted ? { omitsDeleted } : {}) }); } catch (err) { updateResult(bound.resourceType, { status: 'error', items: [], truncatedCount: 0, error: err instanceof Error ? err.message : String(err), }); } } const HISTORY_CONCURRENCY = 6; const emptyBundle = (): Promise> => Promise.resolve({ resourceType: 'Bundle', type: 'searchset', entry: [] } as Bundle); const metaFor = (resourceType: string) => SNAPSHOT_TYPES.find((t) => t.resourceType === resourceType); /** * Reconstruct a patient's chart as of an ISO instant `asOfIso`. * * Self-driving: reconstruction runs in this hook's OWN effect keyed on `[patient?.id, asOfIso]`. * No `run()` is exposed to the view (avoids the provider-fetch reload-loop landmine - see * `reference-app-provider-auth-gate`). A changed patient/T abandons any in-flight run via a * run-token guard, so late async results never overwrite newer state. * * Each included chart type is enumerated via its RAW (unfiltered) patient search - so a * resource archived AFTER T is still present - then each resource's `_history` is walked and the * version live at T is chosen with `selectVersionAsOf`. FHIR DELETE removes the row from SEARCH; * Condition supplies those ids from AuditEvent `patient` + `action=D`. Results emit progressively * per type. */ export function usePatientSnapshot( patient: Patient | undefined, asOfIso: string | undefined, /** Versions this page wrote itself; pass them only for a snapshot of now. See `SnapshotRunContext`. */ known?: readonly Resource[], /** The original view the reads are carried in; a run restarts when its key changes. */ view?: { read: () => ReadOptions | undefined; key: string | undefined }, ): { results: SnapshotTypeResult[]; running: boolean } { // Read when a run starts. Callers move `asOfIso` whenever what they know changes, which starts one. const knownRef = useRef(known); knownRef.current = known; const readRef = useRef(view?.read); readRef.current = view?.read; const { wpOrganization, practitionerRoles, getCognitoGroup } = useAuthServiceContext(); const org = wpOrganization.current; // Organization | null // GET /AuditEvent is Admin-only at the server; anyone else gets a 403, so they skip the discovery and // deleted Conditions stay out of their snapshot as the live search leaves them. const canReadAuditEvents = holdsAdminAt(practitionerRoles, org?.id, getCognitoGroup); const conditionCtx = useConditionServiceContext(); const auditEventCtx = useAuditEventServiceContext(); const allergyCtx = useAllergyIntoleranceServiceContext(); const procedureCtx = useProcedureServiceContext(); const adverseEventCtx = useAdverseEventServiceContext(); const clinicalImpressionCtx = useClinicalImpressionServiceContext(); const detectedIssueCtx = useDetectedIssueServiceContext(); const familyHistoryCtx = useFamilyMemberHistoryServiceContext(); const observationCtx = useObservationServiceContext(); const medStatementCtx = useMedicationStatementServiceContext(); const medRequestCtx = useMedicationRequestServiceContext(); const medAdminCtx = useMedicationAdministrationServiceContext(); const medDispenseCtx = useMedicationDispenseServiceContext(); const immunizationCtx = useImmunizationServiceContext(); const immunizationEvalCtx = useImmunizationEvaluationServiceContext(); const immunizationRecCtx = useImmunizationRecommendationServiceContext(); const deviceCtx = useDeviceServiceContext(); const deviceRequestCtx = useDeviceRequestServiceContext(); const deviceUseCtx = useDeviceUseStatementServiceContext(); const carePlanCtx = useCarePlanServiceContext(); const careTeamCtx = useCareTeamServiceContext(); const goalCtx = useGoalServiceContext(); const nutritionOrderCtx = useNutritionOrderServiceContext(); const riskAssessmentCtx = useRiskAssessmentServiceContext(); const visionPrescriptionCtx = useVisionPrescriptionServiceContext(); const encounterCtx = useEncounterServiceContext(); const episodeCtx = useEpisodeOfCareServiceContext(); const taskCtx = useTaskServiceContext(); const docRefCtx = useDocumentReferenceServiceContext(); const questionnaireResponseCtx = useQuestionnaireResponseServiceContext(); const coverageCtx = useCoverageServiceContext(); const flagCtx = useFlagServiceContext(); // Shared, single-source row-label functions (also consumed by the anchor result mapper). const rowLabels = useSnapshotRowLabels(); const boundEntries = useMemo(() => { const entry = ( resourceType: string, search: (p: Patient, options?: ReadOptions) => Promise>, history: (id: string, options?: ReadOptions) => Promise>, rowLabel: (r: Resource) => string, idsBeyondSearch?: BoundEntry['idsBeyondSearch'], ): BoundEntry => { const meta = metaFor(resourceType); return { resourceType, section: meta?.section ?? 'Records', label: meta?.label ?? resourceType, hasDisplay: meta?.hasDisplay ?? false, search, history, rowLabel, idsBeyondSearch, }; }; return [ { ...entry( 'Condition', (p, o) => conditionCtx.fetchConditions(p, o), (id, o) => conditionCtx.fetchConditionHistory(id, o), rowLabels['Condition'], canReadAuditEvents ? async (p, request) => { if (!p.id) return []; const bundle = await auditEventCtx.fetchAuditEvents({ patient: p.id, action: 'D' }, request); return conditionIdsDeletedInAudit(bundle); } : undefined, ), deletedOutOfReach: !canReadAuditEvents, }, entry( 'AllergyIntolerance', (p, o) => allergyCtx.fetchAllergyIntolerances(p, o), (id, o) => allergyCtx.fetchAllergyIntoleranceHistory(id, o), rowLabels['AllergyIntolerance'], ), entry( 'Procedure', (p, o) => procedureCtx.fetchProcedures(p, o), (id, o) => procedureCtx.fetchProcedureHistory(id, o), rowLabels['Procedure'], ), entry( 'AdverseEvent', (p, o) => adverseEventCtx.fetchAdverseEvents(p, o), (id, o) => adverseEventCtx.fetchAdverseEventHistory(id, o), rowLabels['AdverseEvent'], ), entry( 'ClinicalImpression', (p, o) => clinicalImpressionCtx.fetchClinicalImpressions(p, o), (id, o) => clinicalImpressionCtx.fetchClinicalImpressionHistory(id, o), rowLabels['ClinicalImpression'], ), entry( 'DetectedIssue', (p, o) => detectedIssueCtx.fetchDetectedIssues(p, o), (id, o) => detectedIssueCtx.fetchDetectedIssueHistory(id, o), rowLabels['DetectedIssue'], ), entry( 'FamilyMemberHistory', (p, o) => familyHistoryCtx.fetchFamilyMemberHistories(p, o), // NOTE: doubled "History" - the history reader is fetchFamilyMemberHistoryHistory. (id, o) => familyHistoryCtx.fetchFamilyMemberHistoryHistory(id, o), rowLabels['FamilyMemberHistory'], ), entry( 'Observation', (p, o) => observationCtx.fetchObservations(p, undefined, o), (id, o) => observationCtx.fetchObservationHistory(id, o), rowLabels['Observation'], ), entry( 'MedicationStatement', (p, o) => medStatementCtx.searchMedicationStatementsForHistory(p, o), (id, o) => medStatementCtx.fetchMedicationStatementHistory(id, o), rowLabels['MedicationStatement'], ), entry( 'MedicationRequest', (p, o) => medRequestCtx.fetchMedicationRequests(p, o), (id, o) => medRequestCtx.fetchMedicationRequestHistory(id, o), rowLabels['MedicationRequest'], ), entry( 'MedicationAdministration', (p, o) => medAdminCtx.fetchMedicationAdministrations(p, o), (id, o) => medAdminCtx.fetchMedicationAdministrationHistory(id, o), rowLabels['MedicationAdministration'], ), entry( 'MedicationDispense', (p, o) => medDispenseCtx.fetchMedicationDispenses(p, o), (id, o) => medDispenseCtx.fetchMedicationDispenseHistory(id, o), rowLabels['MedicationDispense'], ), entry( 'Immunization', (p, o) => immunizationCtx.fetchImmunizations(p, o), (id, o) => immunizationCtx.fetchImmunizationHistory(id, o), rowLabels['Immunization'], ), entry( 'ImmunizationEvaluation', (p, o) => immunizationEvalCtx.fetchImmunizationEvaluations(p, o), (id, o) => immunizationEvalCtx.fetchImmunizationEvaluationHistory(id, o), rowLabels['ImmunizationEvaluation'], ), entry( 'ImmunizationRecommendation', (p, o) => immunizationRecCtx.fetchImmunizationRecommendations(p, o), (id, o) => immunizationRecCtx.fetchImmunizationRecommendationHistory(id, o), rowLabels['ImmunizationRecommendation'], ), entry( 'Device', (p, o) => deviceCtx.fetchDevices(p, undefined, o), (id, o) => deviceCtx.fetchDeviceHistory(id, o), rowLabels['Device'], ), entry( 'DeviceRequest', (p, o) => deviceRequestCtx.fetchDeviceRequests(p, o), (id, o) => deviceRequestCtx.fetchDeviceRequestHistory(id, o), rowLabels['DeviceRequest'], ), entry( 'DeviceUseStatement', (p, o) => deviceUseCtx.fetchDeviceUseStatements(p, o), (id, o) => deviceUseCtx.fetchDeviceUseStatementHistory(id, o), rowLabels['DeviceUseStatement'], ), entry( 'CarePlan', (p, o) => carePlanCtx.fetchCarePlans(p, undefined, o), (id, o) => carePlanCtx.fetchCarePlanHistory(id, o), rowLabels['CarePlan'], ), entry( 'CareTeam', // org OBJECT (fetchCareTeams reads organization.id); empty when org missing. org ? (p, o) => careTeamCtx.fetchCareTeams(p, org, undefined, o) : emptyBundle, (id, o) => careTeamCtx.fetchCareTeamHistory(id, o), rowLabels['CareTeam'], ), entry( 'Goal', (p, o) => goalCtx.fetchGoals(p, undefined, o), (id, o) => goalCtx.fetchGoalHistory(id, o), rowLabels['Goal'], ), entry( 'NutritionOrder', (p, o) => nutritionOrderCtx.fetchNutritionOrders(p, o), (id, o) => nutritionOrderCtx.fetchNutritionOrderHistory(id, o), rowLabels['NutritionOrder'], ), entry( 'RiskAssessment', (p, o) => riskAssessmentCtx.fetchRiskAssessments(p, o), (id, o) => riskAssessmentCtx.fetchRiskAssessmentHistory(id, o), rowLabels['RiskAssessment'], ), entry( 'VisionPrescription', (p, o) => visionPrescriptionCtx.fetchVisionPrescriptions(p, o), (id, o) => visionPrescriptionCtx.fetchVisionPrescriptionHistory(id, o), rowLabels['VisionPrescription'], ), entry( 'Encounter', // destructured object arg. (p, o) => encounterCtx.fetchEncounters({ patient: p }, o), (id, o) => encounterCtx.fetchEncounterHistory(id, o), rowLabels['Encounter'], ), entry( 'EpisodeOfCare', // org OBJECT; empty when org missing. org ? (p, o) => episodeCtx.fetchEpisodeOfCares(p, org, o) : emptyBundle, (id, o) => episodeCtx.fetchEpisodeOfCareHistory(id, o), rowLabels['EpisodeOfCare'], ), entry( 'Task', (p, o) => taskCtx.fetchTasks(p, undefined, o), (id, o) => taskCtx.fetchTaskHistory(id, o), rowLabels['Task'], ), entry( 'DocumentReference', // org OBJECT + isRoot=false; empty when org missing. org ? (p, o) => docRefCtx.fetchDocumentReferences(p, org, false, o) : emptyBundle, (id, o) => docRefCtx.fetchDocumentReferenceHistory(id, o), rowLabels['DocumentReference'], ), entry( 'QuestionnaireResponse', (p, o) => questionnaireResponseCtx.fetchQuestionnaireResponses(p, undefined, false, o), (id, o) => questionnaireResponseCtx.fetchQuestionnaireResponseHistory(id, undefined, o), rowLabels['QuestionnaireResponse'], ), entry( 'Coverage', (p, o) => coverageCtx.fetchCoverages(p, o), (id, o) => coverageCtx.fetchCoverageHistory(id, o), rowLabels['Coverage'], ), entry( 'Flag', (p, o) => flagCtx.fetchFlags(p, o), (id, o) => flagCtx.fetchFlagHistory(id, o), rowLabels['Flag'], ), ]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ org, canReadAuditEvents, conditionCtx, auditEventCtx, allergyCtx, procedureCtx, adverseEventCtx, clinicalImpressionCtx, detectedIssueCtx, familyHistoryCtx, observationCtx, medStatementCtx, medRequestCtx, medAdminCtx, medDispenseCtx, immunizationCtx, immunizationEvalCtx, immunizationRecCtx, deviceCtx, deviceRequestCtx, deviceUseCtx, carePlanCtx, careTeamCtx, goalCtx, nutritionOrderCtx, riskAssessmentCtx, visionPrescriptionCtx, encounterCtx, episodeCtx, taskCtx, docRefCtx, questionnaireResponseCtx, coverageCtx, flagCtx, rowLabels, ]); const [results, setResults] = useState([]); const [running, setRunning] = useState(false); const runTokenRef = useRef(0); // A new viewer, practice or acted-as subject starts a new run, as a new patient does. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); useEffect(() => { const token = ++runTokenRef.current; if (!patient || !asOfIso) { setRunning(false); setResults([]); return; } // Capture the (now-narrowed) values so the async closures keep the non-null types. const activePatient: Patient = patient; const activeAsOf: string = asOfIso; setRunning(true); setResults( SNAPSHOT_TYPES.map((t) => ({ resourceType: t.resourceType, label: t.label, section: t.section, status: 'loading' as const, items: [], truncatedCount: 0, })), ); const isCurrent = forThisViewer(() => runTokenRef.current === token); const reads = new Set(); const updateResult = (resourceType: string, patch: Partial) => { if (!isCurrent()) return; setResults((prev) => prev.map((r) => (r.resourceType === resourceType ? { ...r, ...patch } : r)), ); }; const schedule = createPool(HISTORY_CONCURRENCY); // The pipeline itself lives at module scope so it can be tested without mounting this hook // (see `processSnapshotType`). This closure only supplies its React-side dependencies. const processType = (bound: BoundEntry): Promise => processSnapshotType(bound, { patient: activePatient, asOfIso: activeAsOf, isCurrent, schedule, updateResult, known: knownRef.current, reads, read: () => readRef.current?.(), }); Promise.allSettled(boundEntries.map(processType)).then(() => { if (!isCurrent()) return; setRunning(false); }); return () => { // Ends this run at once, unmount included, and cancels the discovery reads it started. runTokenRef.current += 1; reads.forEach((read) => read.abort()); }; // Self-driving: re-run when the patient, the "as of" instant, or the resolved org // changes. `org?.id` covers org-dependent types (CareTeam/EpisodeOfCare/DocumentReference) // that would otherwise stay empty if org was null on the first run. // eslint-disable-next-line react-hooks/exhaustive-deps }, [patient?.id, asOfIso, org?.id, viewer, view?.key]); return { results, running }; }