import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import { isServedAtCutoff } from '../utils/searchTruncation'; import { RECORDS_INSTRUMENT } from '../config/consentInstruments'; import { useOptionalOrgAuthorizationServiceContext } from '../services/OrgAuthorizationService'; import { useOptionalWebSocketServiceContext } from '../services/WebSocketService'; import { AUTHORIZATION_ANSWER_EVENT, isRecordAnswerFor, onRecordAuthorizationAnswer, type AuthorizationAnswerNotice } from '../utils/recordAuthorizationAnswers'; import { onViewerChange, viewerEpoch } from '../utils/writeThroughHold'; import { withinDeadline } from '../utils/withinDeadline'; import { useRereadOnReturn } from './useRereadOnReturn'; /** * WHICH RECORD THIS PRACTICE MAY SEE, from the named HIPAA Authorization (the instrument that governs record access). * * `withdrawn.at` is the withdrawal instant the server bounds clinical reads at; it is absent when the answer carries * no usable timestamp, and never invented. `unknown` is a status read that failed or timed out: it is never read as * withdrawn, current or absent. */ export type RecordAccessStanding = | { kind: 'loading' } // `reauthorizedAt`: it became current here after being withdrawn, declined, not given or unknown. That proves the // entitlement, not that what is on screen was read under it. | { kind: 'current'; reauthorizedAt?: number } | { kind: 'withdrawn'; at?: string } | { kind: 'declined' } | { kind: 'not-given' } | { kind: 'unknown' }; export const STANDING_READ_DEADLINE_MS = 10_000; const usableInstant = (at: string | undefined): string | undefined => at && Number.isFinite(Date.parse(at)) ? at : undefined; const standingOf = (status: string, answeredAt?: string): RecordAccessStanding => { if (status === 'active') return { kind: 'current' }; if (status === 'withdrawn') return { kind: 'withdrawn', at: usableInstant(answeredAt) }; if (status === 'declined') return { kind: 'declined' }; return { kind: 'not-given' }; }; /** * The standing for one patient at one practice (`organizationId` omitted: the session's practice), owned by the * viewer, patient and practice that asked: a late answer for another of them is never shown, and a change of any of * them starts from `loading`. Read again on Retry, whenever an authorization answer returns in this client, when the * server announces one accepted anywhere else for this patient and practice, and when the viewer comes back. */ export const useRecordAccessStanding = ( { patientId, organizationId, practiceId, enabled: asked = true }: { patientId?: string; organizationId?: string; // The practice this session's socket speaks for, when the standing is read for the session's own practice: // a notice from any other practice is not about this standing. A named `organizationId` is used instead. practiceId?: string; enabled?: boolean; }, ): { standing: RecordAccessStanding; retry: () => void } => { // Outside the app's providers there is no standing to read, and nothing is qualified. const getStatus = useOptionalOrgAuthorizationServiceContext()?.getStatus; const socket = useOptionalWebSocketServiceContext(); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; const enabled = asked && Boolean(getStatus); const [viewer, setViewer] = useState(viewerEpoch); const [attempt, setAttempt] = useState(0); // Each change of owner is a new generation, so returning to an earlier owner never shows the answer read for it before. const owner = `${viewer}|${patientId ?? ''}|${organizationId ?? ''}|${practiceId ?? ''}`; const ownership = useRef({ owner, generation: 0 }); if (ownership.current.owner !== owner) ownership.current = { owner, generation: ownership.current.generation + 1 }; const key = `${ownership.current.generation}|${owner}`; // `answeredAt`: when the confirmed active answer was given, which tells a newer active answer from the one shown. const [answer, setAnswer] = useState<{ key: string; standing: RecordAccessStanding; answeredAt?: string } | null>(null); const seq = useRef(0); // One read at a time. A signal while one is out (it may have started before the answer it reports) asks for // exactly one more after it, however many arrive. const reading = useRef(false); const again = useRef(false); const reread = useCallback(() => { if (reading.current) { again.current = true; return; } setAttempt((n) => n + 1); }, []); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); useEffect(() => onRecordAuthorizationAnswer(reread), [reread]); // An answer given in another browser or at another desk fires nothing here. It arrives as the server's notice on // the practice's socket, which names the patient and agreement but never the answer, or not at all while the // socket is down - so coming back to the window reads again too. Either way the standing shown is the one read. useEffect(() => { if (!enabled || !patientId || !subscribe || !unsubscribe) return undefined; const onNotice = (notice: AuthorizationAnswerNotice) => { if (isRecordAnswerFor(notice, patientId, organizationId ?? practiceId, RECORDS_INSTRUMENT)) reread(); }; subscribe(AUTHORIZATION_ANSWER_EVENT, onNotice); return () => unsubscribe(AUTHORIZATION_ANSWER_EVENT, onNotice); }, [enabled, patientId, organizationId, practiceId, subscribe, unsubscribe, reread]); useRereadOnReturn(reread, enabled); useEffect(() => { if (!enabled) return undefined; const mine = ++seq.current; let live = true; if (!getStatus) return undefined; reading.current = true; const settled = () => { if (mine !== seq.current) return; reading.current = false; if (again.current) { again.current = false; setAttempt((n) => n + 1); } }; withinDeadline(getStatus(patientId, organizationId, RECORDS_INSTRUMENT), STANDING_READ_DEADLINE_MS, 'record standing') .then((status) => { if (!live || mine !== seq.current) return; const next = standingOf(status.status, status.answeredAt); const answeredAt = next.kind === 'current' ? usableInstant(status.answeredAt) : undefined; setAnswer((prev) => { const before = prev?.key === key ? prev : undefined; if (next.kind !== 'current' || !before) return { key, standing: next, answeredAt }; // A different active answer is a reauthorization even when the withdrawal before it was never seen here. if (before.standing.kind === 'current' && before.answeredAt === answeredAt) return before; return { key, standing: { kind: 'current', reauthorizedAt: Date.now() }, answeredAt }; }); }) .catch(() => { if (live && mine === seq.current) setAnswer({ key, standing: { kind: 'unknown' } }); }) .finally(settled); return () => { live = false; }; }, [enabled, key, attempt, getStatus, patientId, organizationId]); const retry = useCallback(() => setAttempt((n) => n + 1), []); const standing: RecordAccessStanding = !enabled ? { kind: 'current' } : answer?.key === key ? answer.standing : { kind: 'loading' }; return { standing, retry }; }; const NOT_A_PRACTICE_CHART = { standing: { kind: 'current' } as RecordAccessStanding, retry: () => undefined }; const ChartStandingContext = createContext<{ standing: RecordAccessStanding; retry: () => void }>(NOT_A_PRACTICE_CHART); /** One standing read for a practice's open chart, shared by the band and every section under it. */ export const ChartRecordStandingProvider = ({ patientId, practiceId, children }: { patientId?: string; practiceId?: string; children: ReactNode }) => { const value = useRecordAccessStanding({ patientId, practiceId, enabled: Boolean(patientId) }); return {children}; }; /** The open chart's standing; anywhere outside a practice chart (the patient's own record) it is `current`. */ export const useChartRecordStanding = () => useContext(ChartStandingContext); /** * Whether the list a reader shows was read under the current entitlement. After a reauthorization the list is read * again past every cache (`reload` must force it), and it is `fresh` only once a list read that started after the * reauthorization has been published for this patient. Until then it is `updating`, or `not-updated` once the read * settled without one - never taken for a current read. */ export type RecordFreshness = 'fresh' | 'updating' | 'not-updated'; // One forced read per list, patient and reauthorization, however many readers ask: readers of the same list share it. type Reader = { reload: () => Promise }; // The loop belongs to the list, not to whichever reader started it: it runs while any reader is still mounted. const sharedRereads = new Map; done: Promise }>(); // A list still served frozen at the cutoff is read again, this often and this many times, before it is not-updated. export const REREAD_AFTER_REAUTHORIZATION_MS = 3000; export const REREADS_AFTER_REAUTHORIZATION = 20; const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const useFreshAfterReauthorization = ( standing: RecordAccessStanding, reload: () => Promise, list: { resource: string; patientId?: string; listRead?: { patientId?: string; startedAt: number }; servedAtCutoff?: boolean }, ): RecordFreshness => { const at = standing.kind === 'current' ? standing.reauthorizedAt : undefined; const [settledAt, setSettledAt] = useState(null); const reloadRef = useRef(reload); reloadRef.current = reload; useEffect(() => { if (!at) return undefined; let live = true; const key = `${list.resource}|${list.patientId ?? ''}|${at}`; const reader: Reader = { reload: () => reloadRef.current() }; let shared = sharedRereads.get(key); if (!shared) { // The server can still answer frozen at the cutoff for a moment after the authorization is given again, // so the list is read until its own answer is not, or the rereads run out. A reader that leaves hands the // loop to any reader still showing the list. const entry = { readers: new Set([reader]), done: Promise.resolve() as Promise }; const rereads = async () => { for (let attempt = 0; attempt < REREADS_AFTER_REAUTHORIZATION && entry.readers.size > 0; attempt += 1) { const answer = await [...entry.readers][0].reload(); if (!isServedAtCutoff(answer as Parameters[0])) return; await pause(REREAD_AFTER_REAUTHORIZATION_MS); } }; entry.done = rereads().finally(() => sharedRereads.delete(key)); sharedRereads.set(key, entry); shared = entry; } else { shared.readers.add(reader); } const joined = shared; joined.done.then(() => undefined, () => undefined).then(() => { if (live) setSettledAt(at); }); return () => { live = false; joined.readers.delete(reader); }; }, [at, list.resource, list.patientId]); if (!at) return 'fresh'; // Fresh only once the list on screen is one read after the reauthorization, for this patient: a read that resolved // without publishing (superseded by another) never counts, and a later accepted read settles every reader. const read = list.listRead; if (read && read.patientId === list.patientId && read.startedAt >= at && !list.servedAtCutoff) return 'fresh'; return settledAt === at ? 'not-updated' : 'updating'; }; /** The cutoff with its time and zone, so a date alone never implies the rest of that day. */ export const formatCutoff = (at: string): string => new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', }).format(new Date(at)); /** * An empty section's words, qualified by the standing: once access is limited it describes the record available, * never the patient's current state, and while the standing is unresolved it says so rather than claiming none. */ export const availableRecordEmpty = (none: string, standing: RecordAccessStanding, freshness: RecordFreshness = 'fresh'): string => { const stop = none.endsWith('.') ? '.' : ''; const base = stop ? none.slice(0, -1) : none; switch (standing.kind) { case 'current': if (freshness === 'updating') return `${base} (updating after reauthorization)${stop}`; if (freshness === 'not-updated') return `${base} (could not be updated after reauthorization)${stop}`; return none; case 'withdrawn': return standing.at ? `${base} through ${formatCutoff(standing.at)}${stop}` : `${base} in the record available to this practice${stop}`; case 'declined': case 'not-given': return `${base} in the record available to this practice${stop}`; case 'loading': return `${base} (checking record access)${stop}`; case 'unknown': return `${base} (record access could not be checked)${stop}`; } };