import { useCallback, useEffect, useState } from "react"; import { environment } from "../environments/environment.ts"; import { HttpError, useHttpService, type RequestAdmission } from "./HttpService.tsx"; import { useAuthServiceContext } from "./AuthService.tsx"; import { isPermanentDenial } from "../utils/fetchFailureMessage"; // api-service routes: `apiUrl`, never `fhirBaseApi`. const API_URL = environment.apiUrl; const CLOSING_POLL_MS = 5000; export type ClosureStatus = "closing" | "closed" | "reopened"; export interface ClosureRecord { status: ClosureStatus; closedAt: string; completedAt?: string; reopenedAt?: string; /** Kept for diagnosis; not exact figures. See `progress`. */ totals: { cancelled: number; notified: number; unresolved: number; failed: number }; /** * How far closing has got is not counted exactly: a step can land before its count is saved, and a retry does not * count it again. A record read without this field is read the same way. */ progress?: { kind: "unquantified" }; /** Present for the Owner only. */ acknowledgedAppointments?: number; run?: { state: "queued" | "running" | "needs-retry" | "stopped" | "finished" }; billing?: { state: "pending" | "none" | "ending" | "failed" | "restored"; paidThrough?: string }; /** The Owner's saved reopen attempt while closed: where it stands, never what it sent. */ restore?: { attempt: number; at: string; state: RestoreState }; } /** Only not-sent lets the Owner confirm a new attempt; the rest are checked again or are finished. */ export type RestoreState = "not-sent" | "in-progress" | "unconfirmed" | "payment-pending" | "paid" | "needs-resolution"; export interface ClosureRead { active: boolean; closure: ClosureRecord | null; isOwner: boolean; preview?: { practiceName: string; futureAppointments: number; /** The paid period end as a Stripe instant; null when there is none. */ paidThrough?: string | null; staffCount?: number | null; patientCount?: number | null; }; } export interface PlanQuoteItem { price: string; quantity: number; unitAmount: number | null; /** The plan or add-on this line sells, when the quote names it. */ name?: string; interval?: string; usage: boolean; } export interface PlanQuote { payer: "organization" | "contractor"; currency: string; charged: "now" | "renewal"; total: number; subtotal: number; tax: number; discount: number; excludesUsage: boolean; renewalDiscountPending?: boolean; items: PlanQuoteItem[]; } /** A close or reopen answer; each status is its own outcome, so none of them throws. */ export interface ClosureOutcome { status: number; code?: string; closure?: ClosureRecord; count?: number; plan?: PlanQuote; paidThrough?: string | null; /** The attempt an unconfirmed restore answer is about. */ attempt?: number; } export type ClosureView = ClosureRead | "checking" | "denied" | "failed"; /** The opening a close or reopen belongs to: checked again after the token and proof waits, right before it is sent. */ export interface ClosureSendOptions { admit?: RequestAdmission; } /** Closing counts from the moment the Owner closes; a reopen that never went active again still counts. */ export const isUnderClosure = (read: ClosureRead): boolean => read.closure?.status === "closing" || read.closure?.status === "closed" || (read.closure?.status === "reopened" && !read.active); export const usePracticeClosureService = () => { const { get, fetchWithAuth } = useHttpService(); const readClosure = useCallback( (organizationId: string) => get(`${API_URL}/Organization/${organizationId}/closure`), [get], ); const send = useCallback(async (url: string, body: object, { admit }: ClosureSendOptions = {}): Promise => { const response = await fetchWithAuth(url, { method: "POST", body: JSON.stringify(body), headers: { Accept: "application/json", "Content-Type": "application/json" }, ...(admit ? { admit } : {}), }); const parsed = (await response.json().catch(() => ({}))) as Omit; return { ...parsed, status: response.status }; }, [fetchWithAuth]); const closePractice = useCallback( (organizationId: string, confirmName: string, acknowledgedAppointments: number, options?: ClosureSendOptions) => send(`${API_URL}/Organization/${organizationId}/close`, { confirmName, acknowledgedAppointments }, options), [send], ); const reopenPractice = useCallback( (organizationId: string, confirmedTotal?: number, options?: ClosureSendOptions) => send( `${API_URL}/Organization/${organizationId}/reopen`, confirmedTotal === undefined ? {} : { confirmedTotal }, options, ), [send], ); return { readClosure, closePractice, reopenPractice }; }; /** Where a closure stands; a refusal stays a refusal, never an empty answer. */ export const usePracticeClosure = (organizationId: string | undefined, { poll = true }: { poll?: boolean } = {}) => { const { readClosure } = usePracticeClosureService(); const { currentOrganization, setWpOrganization } = useAuthServiceContext(); // Keyed by practice, so a switch shows checking rather than the last practice's answer. const [result, setResult] = useState<{ organizationId: string; view: ClosureView }>(); const [reads, setReads] = useState(0); useEffect(() => { if (!organizationId) return; let active = true; readClosure(organizationId) .then((read) => active && setResult({ organizationId, view: read })) .catch((error: unknown) => active && setResult({ organizationId, view: error instanceof HttpError && isPermanentDenial(error.status) ? "denied" : "failed", })); return () => { active = false; }; }, [organizationId, readClosure, reads]); const closing = poll && Boolean(result && result.organizationId === organizationId && typeof result.view !== "string" && result.view.closure?.status === "closing"); // Only the closure read repeats; closing runs in server batches and nothing else shows its progress. useEffect(() => { if (!closing) return; const timer = window.setInterval(() => setReads((count) => count + 1), CLOSING_POLL_MS); return () => window.clearInterval(timer); }, [closing]); const readActive = result && result.organizationId === organizationId && typeof result.view !== "string" ? result.view.active : undefined; // The rest of the app keys its quiet on the practice being active, so it hears a closure or a reopen here. useEffect(() => { if (readActive === undefined || !currentOrganization || currentOrganization.id !== organizationId) return; if ((currentOrganization.active !== false) === readActive) return; setWpOrganization({ ...currentOrganization, active: readActive }); }, [readActive, currentOrganization, organizationId, setWpOrganization]); const reload = useCallback(() => setReads((count) => count + 1), []); const view: ClosureView = result && result.organizationId === organizationId ? result.view : "checking"; return { view, reload }; }; /** Staff of an inactive practice, refused everything but its closure; background reads stop. */ export const isStaffOfInactivePractice = ( user: { fhir?: { resourceType?: string } } | null | undefined, organization: { active?: boolean } | null | undefined, ) => user?.fhir?.resourceType === "Practitioner" && organization?.active === false; /** Closed to its staff: inactive, and no read has shown it free of a closure. */ type PracticeRef = { id?: string; active?: boolean; meta?: { versionId?: string } } | null | undefined; export type ClosureGate = "open" | "checking" | "closed" | "unconfirmed"; export const useClosureGateDetail = (organization: PracticeRef): { gate: ClosureGate; isOwner: boolean; retry: () => void } => { const { currentUser } = useAuthServiceContext(); const inactive = organization?.active === false; // The public copy read before sign-in carries no `active`; until the record or the closure answers, a // closed practice would look open and every page under the gate would read from it and be refused. // Signed out there is nothing to hold, and a closure read is refused. const unread = Boolean(currentUser) && Boolean(organization?.id) && organization?.active === undefined && !organization?.meta?.versionId; const { view, reload: retry } = usePracticeClosure(inactive || unread ? organization?.id : undefined, { poll: false }); const isOwner = typeof view !== "string" && view.isOwner; if (!inactive && !unread) return { gate: "open", isOwner, retry }; if (typeof view !== "string" && !isUnderClosure(view)) return { gate: "open", isOwner, retry }; if (view === "checking") return { gate: "checking", isOwner, retry }; // An unread practice whose closure cannot be read is neither open nor known closed: hold, offer a retry. return { gate: inactive || typeof view !== "string" ? "closed" : "unconfirmed", isOwner, retry }; }; export const useClosureGate = (organization: PracticeRef): ClosureGate => useClosureGateDetail(organization).gate; export const useClosedToStaff = (organization: PracticeRef) => useClosureGate(organization) !== "open";