export const EXPORT_FAILED_COPY = 'Your practice could not be copied to the sandbox. Try again in a minute.'; export const REJECTED_COPY = "Your practice's setup could not be copied to the sandbox."; export const NOT_READY_COPY = 'The sandbox is not ready yet. Try again in a minute.'; export const RESTORE_INCOMPLETE_COPY = 'A restore of the sandbox did not finish. An administrator can restore setup from live again.'; export const RESTORING_COPY = 'A restore of this sandbox is still finishing. Try again in a few minutes.'; export const PREPARING_LABEL = 'Preparing your sandbox…'; // A 403 means the account is not on this practice's staff (removed, deactivated, or never added) - waiting // cannot change that, so it must not be worded as a delay. export const NO_ACCESS_COPY = "Your account can't open this practice's sandbox. If you think it should, ask the practice's administrator."; const POLL_MS = 1000; // A first import is reported applied only once its staff are searchable, which can take a while. const DEADLINE_MS = 150_000; export class SandboxNotReadyError extends Error {} // Structural, not `instanceof HttpService's HttpError` - importing that class would pull the whole // React-hook-shaped HttpService module into this plain module for one field. Every HttpError this // codebase throws carries a numeric `status`, which is all a 403 check needs. const isForbidden = (error: unknown): boolean => typeof error === 'object' && error !== null && 'status' in error && (error as { status?: unknown }).status === 403; // The http client's own fetch has no timeout, so a hung export or poll would otherwise leave this // function - and the caller's non-dismissible dialog - waiting forever. Races the real call against // the SAME `sleep` the between-poll wait uses, so a fake clock in tests drives both consistently. const withDeadline = (work: Promise, remainingMs: number, sleep: (ms: number) => Promise): Promise => Promise.race([work, sleep(Math.max(0, remainingMs)).then(() => { throw new SandboxNotReadyError(NOT_READY_COPY); })]); interface SandboxStatus { state: string; exportedAt?: string; workspace?: boolean; reasons?: string[] } export interface PrepareDeps { post: (url: string, body: unknown) => Promise<{ organizationId: string; exportedAt: string }>; get: (url: string, options: { bypassOrganization: true }) => Promise; liveFhirFoundationApi: string; sandboxFhirFoundationApi: string; organizationId: string; sleep?: (ms: number) => Promise; now?: () => number; } // A sandbox that already holds this practice is entered as its coworkers left it; only one without it // is copied from live, applied, and then entered. Replacing an existing one is Restore, never entry. // An applied state older than this export is the previous copy, not this one. A workspace mid-Restore // (pending), or applied before its staff can be found (indexing), is waited out, and one a Restore // left partly applied is refused rather than entered. // A Restore counts as incomplete whether its setup was partly applied or its sample data was not cleared. const partlyRestored = (status: SandboxStatus): boolean => status.workspace === true && status.state === 'rejected' && (status.reasons ?? []).some((reason) => reason.startsWith('APPLY_FAILED') || reason === 'RESTORE_INCOMPLETE'); const restoring = (status: SandboxStatus): boolean => status.workspace === true && status.state === 'pending' && (status.reasons ?? []).includes('RESTORING'); const usable = (status: SandboxStatus): boolean => status.workspace === true && status.state !== 'pending' && status.state !== 'indexing' && !partlyRestored(status); // 'landing': a ready sandbox was opened while its refreshed copy is still on its way in. export type PrepareResult = 'ready' | 'landing'; export async function prepareSandbox(deps: PrepareDeps): Promise { const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const now = deps.now ?? Date.now; const statusUrl = `${deps.sandboxFhirFoundationApi}/sandbox-import/${deps.organizationId}`; const deadline = now() + DEADLINE_MS; const bounded = (work: Promise) => withDeadline(work, deadline - now(), sleep); let inFlight = false; let refreshing = false; try { const current = await bounded(deps.get(statusUrl, { bypassOrganization: true })); if (partlyRestored(current)) throw new SandboxNotReadyError(RESTORE_INCOMPLETE_COPY); if (restoring(current)) throw new SandboxNotReadyError(RESTORING_COPY); // Usable: entered as its coworkers left it while live refreshes it behind, bringing in anyone // who joined the practice since the first copy. refreshing = usable(current); inFlight = !refreshing && current.workspace === true; } catch (error) { if (error instanceof SandboxNotReadyError) throw error; if (isForbidden(error)) throw new SandboxNotReadyError(NO_ACCESS_COPY); } let exportedAt = ''; if (!inFlight) try { const response = await bounded(deps.post(`${deps.liveFhirFoundationApi}/sandbox-export`, {})); // A receipt without a time is a failed export, not something worth a 30-second wait - // there is no exportedAt for the sandbox to ever catch up to. if (typeof response.exportedAt !== 'string') { throw new SandboxNotReadyError(EXPORT_FAILED_COPY); } exportedAt = response.exportedAt; // A ready sandbox opens once live has the refresh; the copy lands behind and entry says so. if (refreshing) return 'landing'; } catch (error) { // A refresh only adds a member; it never keeps the existing ones out. if (refreshing) return 'ready'; if (error instanceof SandboxNotReadyError) throw error; throw new SandboxNotReadyError(isForbidden(error) ? NO_ACCESS_COPY : EXPORT_FAILED_COPY); } while (true) { try { const status = await bounded(deps.get(statusUrl, { bypassOrganization: true })); if (partlyRestored(status)) throw new SandboxNotReadyError(RESTORE_INCOMPLETE_COPY); if (restoring(status)) throw new SandboxNotReadyError(RESTORING_COPY); const landed = (status.exportedAt ?? '') >= exportedAt; if (usable(status)) return 'ready'; if (status.state === 'applied' && landed) return 'ready'; if (status.state === 'rejected' && (status.exportedAt ?? '') >= exportedAt) throw new SandboxNotReadyError(REJECTED_COPY); } catch (error) { if (error instanceof SandboxNotReadyError) throw error; // The token's organization_roles claim does not name this practice - retrying within the // token's own life cannot change that, so there is nothing worth waiting out. if (isForbidden(error)) throw new SandboxNotReadyError(NO_ACCESS_COPY); } if (now() >= deadline) throw new SandboxNotReadyError(NOT_READY_COPY); await sleep(POLL_MS); } }