import { useEffect, useState, type ReactNode } from "react"; import { CheckboxControl } from "../../../components/ui/checkbox-control"; import { RadioGroup, RadioGroupItem } from "../../../components/ui/radio-group"; import { useAuthServiceContext } from "../../../services/AuthService"; import { useOrganizationOwnerService, type OwnerState } from "../../../services/OrganizationOwnerService.tsx"; import { useRefreshSessionOrganization } from "../../../hooks/useRefreshSessionOrganization"; import { isSaveRetired, useSaveScope, type SaveTransport } from "../../../utils/saveOperation"; import { holdSettingsWrite, nextSettingsAttempt, recordSettingsAttempt, unresolvedSettingsWrite, useUnresolvedSettingsWrite, watchSettingsWrite, } from "../../../utils/unresolvedSettingsWrite"; import { OWNER_ACTION_SETTLING, OWNER_ACTION_SUPERSEDED, OWNER_SAVED_REFRESH_FAILED, OWNER_UNFINISHED, OWNER_UNFINISHED_BY_LINK, newOwnerOperation, operationOf, operationSettlement, ownerActionHolder, ownerRecoveryWords, ownerStateShows, readOwnerAction, resumeRouteOf, unconfirmedOwnerAction, unfinishedOwnerAction, type OwnerAction, } from "../../../components/OrganizationOwnerNotice/ownerActionHold"; import { referencesPractitioner } from "../../../components/OrganizationOwnerNotice/ownerReference"; interface ConfirmPracticeOwnerProps { organizationId: string; onConfirmed: () => void; /** Shown instead unless the practice was founded directly or the reader is the owner its agency named. */ nominatePath: ReactNode; /** Whether anybody on staff besides the reader could be named; the server names only existing staff. */ canNameSomeoneElse: boolean; /** Opens adding a staff member, for a practice whose owner is not on its staff yet. */ onAddStaff: () => void; /** What the Staff page still needs before anyone can be added, so this card names it instead. */ staffBlockers?: string[]; } // A practice its founder set up directly, or the owner its agency named: they attest in their own words // and become its Owner. A direct founder setting it up for someone else, and anybody at an agency or of // unknown origin, nominates an owner instead. // // The read, the answer and what follows it belong to the practice and the person reading (AuthService's viewer // key names the practice too), and to this opening: a change of either starts from a fresh statement and choice, // and nothing started before is sent, shown or reported. An answer sent with no reply is held for its sender until // this practice's owner record shows it. const ConfirmPracticeOwner = ({ organizationId, onConfirmed, nominatePath, canNameSomeoneElse, onAddStaff, staffBlockers = [], }: ConfirmPracticeOwnerProps) => { const { fetchOwner, nominateOwner, acceptOwner, declineOwner, resumeOwnerOperation } = useOrganizationOwnerService(); const refreshOrganization = useRefreshSessionOrganization(); const auth = useAuthServiceContext() as { currentUser?: { fhir?: { resourceType?: string; id?: string } } }; const fhir = auth?.currentUser?.fhir; const practitionerId = fhir?.resourceType === "Practitioner" ? fhir.id : undefined; // undefined while reading, null when the read failed. const [state, setState] = useState(undefined); const [agreed, setAgreed] = useState(false); // A direct founder may own the practice or be setting it up for its owner; nothing is assumed. const [who, setWho] = useState<"" | "self" | "other">(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [declined, setDeclined] = useState(false); // A held answer that a check of the owner record did not find yet. That is not proof it will not land, so it stays held. const [notFound, setNotFound] = useState(false); const [reads, setReads] = useState(0); // Said once an answer turned out to have been done already, with ownership changed again since. const [superseded, setSuperseded] = useState(false); const scope = useSaveScope(`${organizationId}|${practitionerId ?? ""}`, () => { setState(undefined); setAgreed(false); setWho(""); setSaving(false); setError(null); setDeclined(false); setNotFound(false); setSuperseded(false); }); const holder = ownerActionHolder(); const heldWrite = useUnresolvedSettingsWrite(holder); const heldAction = heldWrite ? readOwnerAction(heldWrite.detail) : undefined; // Admitted with stages still owed: only the same operation finishes it. const heldSettling = heldWrite?.state === "recovering"; useEffect(() => { const operation = scope.start(); const heldFor = holder; operation.step(() => fetchOwner(organizationId, operation.transport)) .then((next) => { // The owner record settles a held answer it shows; one it does not show may still be on its way. const held = unresolvedSettingsWrite(heldFor); const action = held ? readOwnerAction(held.detail) : undefined; const unfinished = unfinishedOwnerAction(next, practitionerId); if (held && action && ownerStateShows(action, next, practitionerId)) holdSettingsWrite(heldFor, undefined); else if (held && action && operationSettlement(next, operationOf(action)?.id) === "settling") { holdSettingsWrite(heldFor, { ...held, state: "recovering" }); } else if (action) setNotFound(true); // Reopened after an answer was admitted and not finished: the same operation is recovered from the read. else if (unfinished) holdSettingsWrite(heldFor, { kind: "practice-owner", state: "recovering", detail: JSON.stringify(unfinished) }); setState(next); }) .catch((err) => { if (isSaveRetired(err) || !operation.belongs()) return; setState(null); }) .finally(() => operation.retire()); return () => operation.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId, practitionerId, reads, fetchOwner]); // One owner answer for this practice and reader: confirmed, refused (nothing recorded), unknown (held with what // it asked) or retired (nothing shown). A confirmed one stays confirmed whatever the refresh after it does. const answer = async ( action: OwnerAction, send: (transport: SaveTransport) => Promise, refused: string, confirmed: (next: OwnerState, transport: SaveTransport, belongs: () => boolean) => Promise, ) => { const operation = scope.start(); const heldFor = holder; const startedWith = unresolvedSettingsWrite(heldFor); const attempt = nextSettingsAttempt(startedWith); const write = watchSettingsWrite((onSend) => send({ ...operation.transport, onSend })); setSaving(true); setError(null); setNotFound(false); try { let next: OwnerState; try { next = await operation.step(write.run, true); } catch (err) { recordSettingsAttempt(heldFor, attempt, startedWith, write.unconfirmed() ? { kind: "practice-owner", state: "unconfirmed", detail: JSON.stringify(action) } : undefined); if (isSaveRetired(err) || !operation.belongs() || write.unconfirmed()) return; setError(refused); return; } // Done only once its operation settled: designation alone is not done. One admitted with stages still owed // is held for the same operation to finish; one a later change superseded was done and is said so. An answer // with no transition comes from a server that predates them and is read as before. const op = operationOf(action); const settlement = op && next.transition ? operationSettlement(next, op.id) : "settled"; if (settlement === "settling") { holdSettingsWrite(heldFor, { kind: "practice-owner", state: "recovering", detail: JSON.stringify(action) }); return; } holdSettingsWrite(heldFor, undefined); if (settlement === "superseded") { if (operation.belongs()) { setSuperseded(true); setReads((n) => n + 1); } return; } try { await confirmed(next, operation.transport, operation.belongs); } catch (err) { if (!isSaveRetired(err)) throw err; } } finally { if (operation.belongs()) setSaving(false); operation.retire(); } }; // After an ownership change: the session's practice re-read for this same answer. Its failure is said, and the // change is still the change it was. const settled = async (transport: SaveTransport, belongs: () => boolean) => { const fresh = await refreshOrganization(transport); if (!belongs()) return; if (!fresh) setError(OWNER_SAVED_REFRESH_FAILED); onConfirmed(); }; const attestation = state?.attestation; const named = state?.foundingOrigin === "agency" && state.youAreTheNamedOwner === true; const nominee = state?.status === "nominated" && state.youAreTheNominee === true; // Finishes the held operation; nothing is asked again. const finish = async (held: OwnerAction) => { const route = resumeRouteOf(held); const op = operationOf(held); if (!route || !op) return; await answer(held, (transport) => resumeOwnerOperation(organizationId, route, op, held.action === "nominate" ? held.practitionerId : undefined, transport), "Ownership could not be finished just now. It is still recorded; try again in a minute.", async (_next, transport, belongs) => { await settled(transport, belongs); }); }; // Ownership started from the owner email is finished only from that email's link. const linkUnfinished = state?.transition?.state === "settling" && state.transition.route === "accept-link" && Boolean(state.transition.operationId); if (!heldAction && linkUnfinished) { return (

{OWNER_UNFINISHED_BY_LINK}

); } if (heldAction && heldSettling) { return (

{state?.transition?.operationId === operationOf(heldAction)?.id ? OWNER_UNFINISHED : OWNER_ACTION_SETTLING}

{error &&

{error}

}
); } if (heldAction) { return (

{unconfirmedOwnerAction(heldAction)}

{notFound && (

This practice's owner record does not show it yet. It may still be recorded, so it is not sent again. Check again in a minute.

)}
); } // A settled owner without administrator standing is told the lawful next step, never offered a confirmation the // server would refuse. const recovery = ownerRecoveryWords(state ?? undefined, referencesPractitioner(state?.ownerPractitionerId, practitionerId)); if (recovery) { return (

{recovery}

); } if (state && state.foundingOrigin !== "direct" && !named && !nominee) return <>{nominatePath}; const answerNomination = async (accepting: boolean) => { if (accepting && (!attestation || !agreed)) return; if (!accepting) { const kept = { attestation, foundingOrigin: state?.foundingOrigin }; await answer({ action: "decline" }, (transport) => declineOwner(organizationId, transport), "Your answer was not recorded. Nothing changed. Please try again.", async (next, _transport, belongs) => { if (!belongs()) return; setState({ ...next, ...kept }); setDeclined(true); }); return; } const acceptance = { action: "accept" as const, operation: newOwnerOperation("accept", organizationId, practitionerId) }; await answer(acceptance, (transport) => acceptOwner(organizationId, { statementVersion: (attestation as { version: string }).version }, transport, acceptance.operation), "Ownership was not recorded. Nothing else changed. Please try again.", async (next, transport, belongs) => { if (next.needsOwner) { if (belongs()) setError("Ownership was not recorded. Nothing else changed. Please try again."); return; } await settled(transport, belongs); }); }; if (nominee) { return (
You have been named as this practice's owner
{state?.nominatedByName ?? "An administrator"} named you as the owner of this practice. Accept to take it on, or decline if it should be someone else.
{attestation ? (
) : (

The owner statement could not be loaded just now. Reload the page to try again.

)} {error &&

{error}

} {superseded &&

{OWNER_ACTION_SUPERSEDED}

}
); } const confirm = async () => { if (!attestation || !practitionerId || !agreed) return; const naming = { action: "nominate" as const, practitionerId, operation: newOwnerOperation("nominate", organizationId, practitionerId) }; await answer(naming, (transport) => nominateOwner(organizationId, practitionerId, { statementVersion: attestation.version }, undefined, transport, naming.operation), "Ownership was not recorded. Nothing else changed. Please try again.", async (next, transport, belongs) => { if (next.needsOwner) { if (belongs()) setError("Ownership was not recorded. Nothing else changed. Please try again."); return; } await settled(transport, belongs); }); }; const asking = !named; const attesting = named || who === "self"; return ( <>
{asking ? "Who owns this practice?" : "Confirm you own this practice"}
{declined &&
You declined being named as owner.
} {superseded &&
{OWNER_ACTION_SUPERSEDED}
}
{asking ? "The owner is the administrator this practice's subscription and continuity belong to." : "The contractor that set up this practice named you as its owner. Confirm to accept ownership."}
{asking && ( { setWho(value as "self" | "other"); setAgreed(false); setError(null); }} className="practice-setup__choices flex flex-col gap-2" disabled={saving} aria-label="Who owns this practice?" >
)} {!attesting ? null : state === undefined ? (
Checking this practice's owner
) : !attestation ? (

The owner statement could not be loaded just now. Reload the page to try again.

) : ( // Set apart from the choices: flush under the second option it read as part of it.
)} {error &&

{error}

} {attesting && attestation && (
)}
{asking && who === "other" && (canNameSomeoneElse ? nominatePath : (
The owner has to be on this practice's staff before you can name them. {staffBlockers.length > 0 ? ` Staff can be added once this practice has ${staffBlockers.join(", ")}. Add those to the practice details first; this question waits here.` : " Add them as a staff member first; this question waits here until they are added."}
{staffBlockers.length === 0 && (
)}
))} ); }; export default ConfirmPracticeOwner;