import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuthServiceContext, useHttpService } from '../../services'; import { viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; import SandboxSampleSheet from './SandboxSampleSheet'; import RestoreSetupSheet from './RestoreSetupSheet'; import { lifecycleUrl, lastErrorCopy, operationLabel, type SandboxLifecycle, PROTOCOL, admitLifecycle, admitDiscovery, readCustody, writeCustody, clearCustody, createCustodyRecovery, newCommand, resumeCommand, commandUrl, commandRoute, checkUrl, discoveryUrl, heldCopy, terminalCopy, freshStartProof, readFence, fenceFreshStart, spendFence, UNREADABLE_COPY, STORAGE_COPY, NOT_SENT_COPY, UNSETTLED_COPY, CHECK_FAILED_COPY, UNSAVED_COPY, type Custody, type StoredCustody, type V1Action, type V1Observation, type CustodyReadAttempt, } from './sandboxLifecycle'; import './SandboxSamplePanel.css'; const BUSY_POLL_MS = 3_000; const IDLE_POLL_MS = 15_000; const EMPTY_LEAD = "Try the app with your practice's current setup. Populate sample data to see it in use."; type SandboxSamplePanelProps = { canManage: boolean; setupSummary?: string; onRestored?: () => void; }; type Confirming = 'repopulate' | 'restore' | null; const retired = () => new DOMException('This request belongs to a closed opening.', 'AbortError'); // admin-v2 sandbox dashboard: sample data state, Populate/Repopulate and Restore, and whatever // operation any coworker has running. Every change is refused or allowed by the server, and a request // whose answer was lost stays held in this browser until the server's own outcome for it is read. const SandboxSamplePanel = ({ canManage, setupSummary, onRestored }: SandboxSamplePanelProps) => { const { get, post } = useHttpService(); const { wpOrganization } = useAuthServiceContext(); const [practiceId] = useState(() => wpOrganization?.current?.id ?? null); const [viewerKey] = useState(() => writeThroughViewer()); const [epoch] = useState(() => viewerEpoch()); const [lifecycle, setLifecycle] = useState(null); const [readFailed, setReadFailed] = useState(false); const [confirming, setConfirming] = useState(null); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); const [stored, setStored] = useState(() => (practiceId && viewerKey ? readCustody(practiceId, viewerKey) : { state: 'none' })); const [observation, setObservation] = useState(null); const [checkFailed, setCheckFailed] = useState(false); const [unsaved, setUnsaved] = useState(false); const alive = useRef(true); const opening = useRef(0); const inFlight = useRef(false); const abort = useRef(null); const lifecycleSeq = useRef({ next: 0, admitted: 0 }); const checkSeq = useRef({ next: 0, admitted: 0 }); const restoredFor = useRef(new Set()); useEffect(() => () => { alive.current = false; abort.current?.abort(); }, []); const mounted = useCallback(() => alive.current && viewerEpoch() === epoch, [epoch]); const scoped = practiceId ? { organizationId: practiceId } : {}; const [recovery] = useState(() => practiceId && viewerKey ? createCustodyRecovery(practiceId, viewerKey, mounted) : null); const rehydrate = useCallback(() => { if (mounted() && practiceId && viewerKey) setStored(readCustody(practiceId, viewerKey)); }, [mounted, practiceId, viewerKey]); const refresh = useCallback(async () => { if (!practiceId) { setReadFailed(true); return; } const seq = ++lifecycleSeq.current.next; let admitted: ReturnType = { ok: false }; try { admitted = admitLifecycle(await get(lifecycleUrl(), { organizationId: practiceId }), practiceId); } catch { /* unavailable */ } if (!mounted() || seq <= lifecycleSeq.current.admitted) return; if (!admitted.ok) { setReadFailed(true); return; } lifecycleSeq.current.admitted = seq; setLifecycle(admitted.value); setReadFailed(false); }, [get, mounted, practiceId]); // An answer is shown only for the original saved now, validated against what that original has already // proved; a stale, foreign or contradicting answer is dropped whole. Only a terminal one, cleared by exact // compare, releases custody. const settle = useCallback((body: unknown, route: 'destination' | 'source', attempt: CustodyReadAttempt, current: () => boolean): boolean => { if (!current()) return false; const result = recovery?.accept(body, route, attempt); if (!result || !current()) return false; const o = result.observation; setUnsaved(result.persistenceHeld); if (result.terminal) { setObservation(null); setError(terminalCopy(o)); rehydrate(); if (o.action === 'restore' && o.outcome === 'completed' && !restoredFor.current.has(o.requestId)) { restoredFor.current.add(o.requestId); onRestored?.(); } void refresh(); return true; } setObservation(o); rehydrate(); return true; }, [onRestored, recovery, refresh, rehydrate]); const check = useCallback(async () => { if (!practiceId || !viewerKey) return; const held = readCustody(practiceId, viewerKey); if (held.state !== 'held') { rehydrate(); return; } const { requestId, action } = held.custody; const attempt = recovery?.begin(requestId); if (!attempt) { rehydrate(); return; } const seq = ++checkSeq.current.next; const read = async (route: 'destination' | 'source') => { try { return await get(checkUrl(requestId, route), { organizationId: practiceId }); } catch { return undefined; } }; const fresh = () => mounted() && seq > checkSeq.current.admitted; let shown = settle(await read('destination'), 'destination', attempt, mounted); if (!shown && action === 'restore' && fresh()) shown = settle(await read('source'), 'source', attempt, mounted); if (!fresh()) return; if (shown) checkSeq.current.admitted = seq; setCheckFailed(!shown); }, [get, mounted, practiceId, recovery, rehydrate, settle, viewerKey]); useEffect(() => { void refresh(); if (stored.state === 'held') void check(); // Hydration runs once per opening of the panel; later reads follow the poll. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const held = stored.state !== 'none'; const busy = !!lifecycle?.operation || held; useEffect(() => { const timer = setTimeout(() => { void refresh(); if (held) void check(); }, busy ? BUSY_POLL_MS : IDLE_POLL_MS); return () => clearTimeout(timer); }, [lifecycle, busy, held, observation, refresh, check]); const send = async (custody: Custody, command: object, fresh: boolean) => { const mine = opening.current; const current = () => mounted() && opening.current === mine; const controller = new AbortController(); abort.current = controller; let sent = false; try { const attempt = recovery?.begin(custody.requestId); if (!attempt) return; const body = await post(commandUrl(custody.action), command, { ...scoped, signal: controller.signal, onSend: () => { sent = true; }, admit: (phase?: 'answer') => { if (!current() || !recovery?.owns(attempt) || (!fresh && phase !== 'answer' && !recovery.canResume(observation))) throw retired(); }, }); if (!current()) return; setConfirming(null); settle(body, commandRoute(custody.action), attempt, current); } catch { // Only a request that never reached fetch is known unsent; anything later may still arrive. if (!sent && fresh) { clearCustody(custody); rehydrate(); if (current()) setError(NOT_SENT_COPY); return; } if (current()) setConfirming(null); } finally { if (current()) { setStarting(false); inFlight.current = false; } } }; const submit = async (action: V1Action) => { if (inFlight.current || !practiceId || !viewerKey) return; inFlight.current = true; const mine = opening.current; const current = () => mounted() && opening.current === mine; setStarting(true); setError(null); const stop = (copy: string | null) => { if (!current()) return; if (copy) setError(copy); setStarting(false); inFlight.current = false; }; if (readCustody(practiceId, viewerKey).state !== 'none') { rehydrate(); stop(null); return; } if (recovery?.holds()) { stop(UNSAVED_COPY); return; } setObservation(null); let head: ReturnType = { ok: false }; try { head = admitDiscovery(await get(discoveryUrl(action), { organizationId: practiceId }), practiceId, commandRoute(action)); } catch { /* not sent */ } if (!current()) return; if (!head.ok) { stop(NOT_SENT_COPY); return; } if (head.value.unsettled) { stop(UNSETTLED_COPY); return; } // The fresh start is this deployment's own fence proof for the head just read, passed explicitly. let proof = freshStartProof(readFence(practiceId, viewerKey, action), head.value.admissionRevision); if (!proof) { const fenced = await fenceFreshStart((url, body) => post(url, body, { ...scoped, admit: () => { if (!current()) throw retired(); } }), practiceId, viewerKey, action, head.value.admissionRevision, current); if (!current()) return; if (!fenced.ok) { stop(fenced.copy); return; } proof = fenced.value; } const custody: Custody = { v: 1, protocol: PROTOCOL, requestId: crypto.randomUUID(), action, practiceId, viewerKey, admissionRevision: proof.admissionRevision, ...(proof.scopeDigest ? { scopeDigest: proof.scopeDigest } : {}), stage: 'sending', at: new Date().toISOString(), }; if (readCustody(practiceId, viewerKey).state !== 'none') { rehydrate(); stop(null); return; } if (recovery?.holds()) { stop(UNSAVED_COPY); return; } try { writeCustody(custody); } catch { stop(STORAGE_COPY); return; } spendFence(practiceId, viewerKey, action); setStored({ state: 'held', custody }); await send(custody, newCommand(custody), true); }; // Resume is offered only on the saved original's own newest observation, never another request's permit. const resumable = stored.state === 'held' && !!recovery?.canResume(observation) && observation?.requestId === stored.custody.requestId; const resume = async () => { if (inFlight.current || !practiceId || !viewerKey || !resumable) return; const live = readCustody(practiceId, viewerKey); if (live.state !== 'held' || live.custody.requestId !== observation?.requestId || live.custody.revision !== observation.revision) return; inFlight.current = true; setStarting(true); setError(null); await send(live.custody, resumeCommand(live.custody), false); }; // Closing retires this opening: an answer still owed to it is never shown, and custody stays as stored. const closeSheet = () => { opening.current += 1; abort.current?.abort(); inFlight.current = false; setStarting(false); setConfirming(null); rehydrate(); }; if (!lifecycle) { return readFailed ? (

The sandbox sample data could not be read.

) : null; } const { sample, operation } = lifecycle; const ready = sample.state === 'ready'; const failure = !operation && !held ? lastErrorCopy(lifecycle.lastError) : null; const locked = (busy && !operation?.stale) || starting || held || !practiceId || !viewerKey; const status = ( <> {operation && (
{operationLabel(operation)} {operation.total > 0 && } {operation.stale ? 'This has stopped responding. An administrator can start it again.' : 'Everyone in this practice sandbox sees this until it finishes.'}
)} {held && (
{stored.state === 'held' ? heldCopy(stored.custody.action, observation) : UNREADABLE_COPY} {checkFailed && {CHECK_FAILED_COPY}} {unsaved && {UNSAVED_COPY}} {stored.state === 'held' && ( {canManage && resumable && ( )} )}
)} {failure &&

{failure}

} {error && !confirming &&

{error}

} ); const openSheet = (which: Confirming) => { setError(null); setConfirming(which); }; const sheet = confirming === 'repopulate' ? ( This affects everyone in this practice sandbox.Coordinate with your coworkers first. Existing sample patients and their appointments, visits, messages and records will be replaced., <>Your sandbox setup stays as you changed it.New sample data uses your sandbox locations, practitioners, services and schedules., <>Your live practice is untouched.No real patient record is copied into the sandbox., ]} action="Repopulate sample data" pending={starting} error={error} onConfirm={() => void submit('repopulate')} onClose={closeSheet} /> ) : confirming === 'restore' ? ( void submit('restore')} onClose={closeSheet} /> ) : null; if (ready) { const counts = sample.counts; return (

{busy ? 'Sandbox Mode' : <>Sandbox Mode {'\u00b7'} sample patient data is ready}

{canManage && (
)}
{status} {counts && (
{counts.patients}Sample patients
{counts.thisWeek}Appointments this week
{counts.encounters}Visits in the last 90 days
)} {sheet}
); } return (

Sandbox Mode

{sample.state === 'partial' ? 'Sample data is incomplete' : 'Sample data has not been added'}

{EMPTY_LEAD}

{setupSummary &&
Copied practice setup: {setupSummary}. No real patient information comes across.
}

Your coworkers share this sandbox. An administrator can add sample data for everyone.

{status} {canManage && (
)}
{sheet}
); }; export default SandboxSamplePanel;