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}`;
}
};