import { ReactNode, useEffect, useId, useRef, useState } from "react"; import { saveOverCurrent } from "../../../utils/saveOverCurrent"; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, SaveRetiredError, startSaveOperation } from "../../../utils/saveOperation"; import { eitherAborted, stillOpen, type ReferralRequestScope } from "../../../utils/referralRequestScope"; import { Address, Patient } from "fhir/r4"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../../components/ui/dialog.tsx"; import { Input } from "../../../components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../../components/ui/select.tsx"; import { useAuthServiceContext, useFhirServiceContext, usePatientServiceContext, } from "../../../services"; import { GENDER_OPTIONS, MARITAL_STATUS_OPTIONS, MARITAL_STATUS_SYSTEM, } from "../../../components/PatientProfileCard/PatientProfileCard"; import { isValidPhoneNumber } from "../../../utils/phoneNumber"; import { StateSelect } from "../../../components/ui/state-select"; import { ADDRESS_CITY_CLASS, ADDRESS_ROW_CLASS, ADDRESS_STATE_CLASS, ADDRESS_STATE_ZIP_GROUP_CLASS, ADDRESS_ZIP_CLASS, } from "../../../components/ui/address-row"; import { sanitizeUsPostalCode } from "../../../utils/usStates"; import { EMAIL_ABSENCE_OPTIONS, type AbsenceReason, } from "../../../utils/patientFieldAbsence"; import { canRegisterPatient, hasPostalAddress, identifiableWithoutEmail, } from "../../../utils/registerPatientRequirements"; import type { MatchCandidate, PatientMatchAdvice, PatientMatchInput } from "../../../services/PatientService"; import { environment } from '../../../environments/environment'; import { createCandidateAdoptionHoldStore, type CandidateAdoptionHold, type CandidateAdoptionOwner } from '../../../utils/candidateAdoptionHold'; import DuplicateCandidateCard from "./DuplicateCandidateCard"; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { accountLines, newRegistrationId, RECOVERED_REGISTRATION_COPY, RegistrationUnresolvedError, type RegistrationReceipt } from '../../../services/registrationReceipt'; import RegistrationRecoveryPanel from './RegistrationRecoveryPanel'; import { authorityFailureMayRetry, RelatedPersonAuthorityError, RelatedPersonInviteAbandoned, submitRelatedPersonInvite, } from '../../../services/relatedPersonInvite'; import { useLifetime } from '../../../hooks/useLifetime'; import { onViewerChange } from '../../../utils/writeThroughHold'; import './ReceptionistRegisterPatientDialog.css'; import { useRelatedPersonServiceContext } from '../../../services/RelatedPersonService.tsx'; import { useRelatedPersonAuthorityService } from '../../../services/RelatedPersonAuthorityService.tsx'; import { DESIGNATION_ATTESTATION_LABEL, DESIGNATION_HEADLINE, DESIGNATION_SUMMARY, buildDesignationAttestation, } from '../../../services/relatedPersonDesignation'; /** A step's check that it still belongs to the opening it started in, with that opening's cancellation. */ type Owned = (() => boolean) & { signal: AbortSignal; organizationId?: string }; /** * What a surface handing this dialog a patient already knows about them. * * ONLY THE FIELDS THE OTHER SURFACE CAN HAVE COLLECTED, deliberately narrow. The Add Patient form asks * for a name and an email; widening this to the whole `RegisterForm` would invite a caller to seed * demographics it never asked a receptionist about, which is how a record acquires a birth date nobody * typed. */ export interface RegisterPatientPrefill { firstName?: string; lastName?: string; email?: string; phone?: string; /** The reason already selected by the sending form. */ emailAbsenceReason?: AbsenceReason; } interface ReceptionistRegisterPatientDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onRegistered: (patient: Patient) => void; /** * Seed the form with what the receptionist already typed somewhere else. * * A HANDOFF THAT MAKES SOMEBODY RETYPE TWO NAMES THEY JUST TYPED IS CALLED A BUG. The Add Patient form * cannot register a patient with no email - it collects neither the birth date nor the phone or address * the server needs to identify one - so it sends them here. What they had already entered comes with * them. */ prefill?: RegisterPatientPrefill; /** * The opening of the screen this dialog is embedded in (a referral being matched). Every request here is admitted, * cancelled and practice-pinned by it as well as by this dialog's own opening. Without one, nothing changes. */ scope?: ReferralRequestScope; } interface RegisterForm { firstName: string; lastName: string; email: string; phone: string; birthDate: string; /** - why there is no email. Empty means an email was given. */ emailAbsenceReason: AbsenceReason | ""; /** How the patient will use the portal: their own email, no account, or a related person. */ portalUse: PortalUse; /* * WHO MANAGES THIS RECORD, when the patient will not be managing it themselves. * * Ruled 2026-09-06: a patient who cannot or will not use the portal may be managed by a related * person designated at the desk and attested like consent. Before this, registering that patient * ended with a record nobody could act for - and somebody who cannot use a portal is exactly the * person most likely to need someone acting for them. */ managerEmail: string; managerFirstName: string; managerLastName: string; managerAttested: boolean; gender: string; maritalStatus: string; line1: string; line2: string; city: string; state: string; postalCode: string; } type PortalUse = "email" | "none" | "managed"; const PORTAL_USE_OPTIONS: ReadonlyArray<{ value: PortalUse; label: string; detail: string }> = [ { value: "email", label: "Has an email address", detail: "We invite them and they sign in as themselves." }, { value: "none", label: "No email", detail: "Create a patient record without a login for booking, charting, and invoicing. Enter a date of birth, a phone number or address, and the reason there is no email.", }, { value: "managed", label: "Managed by a related person", detail: "Invite the person who manages this patient's account. They sign in as themselves, then switch to the patient's record.", }, ]; const EMPTY_FORM: RegisterForm = { firstName: "", lastName: "", email: "", phone: "", birthDate: "", emailAbsenceReason: "", portalUse: "email", managerEmail: "", managerFirstName: "", managerLastName: "", managerAttested: false, gender: "", maritalStatus: "", line1: "", line2: "", city: "", state: "", postalCode: "", }; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // The reasons a patient may have no email now live beside ABSENCE_LABEL, because a third surface - the // Add Patient form - needed the same list and three copies is how two front-desk screens come to offer // different options for one question. Imported above; the order and wording are unchanged. // fix wave (Important 2) - /Patient/link-existing returns curated, user-safe 400 text (e.g. // "That match has expired. Search again for the patient before linking."), so surfacing it verbatim // is intentional and scoped to this call site. Mirrors the identical helper in // components/dashboard/DuplicateMatchPrompt.tsx (also a curated-message endpoint) - do NOT generalize // this pattern to endpoints whose error bodies are not curated. HttpService.tsx prefixes every // non-OperationOutcome message with "Request failed: "; strip that so what's shown reads as a // sentence, not a wrapped error. const serverMessage = (err: unknown): string | undefined => { const raw = relayServerFailure(err, "").trim(); return raw.length > 0 ? raw : undefined; }; // A field with one control names it through `htmlFor`; a field of several parts is a group named by its label. const Field = ({ label, required, htmlFor, children, }: { label: string; required?: boolean; htmlFor?: string; children: ReactNode; }) => { const labelId = useId(); return (
{children}
); }; // Receptionist "Register Patient" modal. The server's create contract only ingests name + email + org, so we POST // that minimal record, then enrich the demographics (phone / DOB / gender / marital / address) with a guardrail-1 // full-resource PUT - never a partial (that would strip the server-set generalPractitioner org link). Email is // required so the account's login-link notification can be delivered. On success the parent opens the workspace on // the new patient to finish anything not captured here. export default function ReceptionistRegisterPatientDialog({ open, onOpenChange, onRegistered, prefill, scope, }: ReceptionistRegisterPatientDialogProps) { const { wpOrganization, currentUser, actingAsRef } = useAuthServiceContext(); const { createReferenceObject } = useFhirServiceContext(); const { createRelatedPerson } = useRelatedPersonServiceContext(); const { putAuthority } = useRelatedPersonAuthorityService(); const lifetime = useLifetime(); const { createPatient, resumeRegistration, listRegistrations, fetchPatient, updatePatient, matchCandidates, linkExistingPatient, } = usePatientServiceContext(); // The registration whose outcome this owner could not confirm. Sending the same form again reuses its id, so the // server resumes it; a changed form waits until the desk checks it or explicitly registers a separate patient. const firstNameInput = useRef(null); const unresolvedRegistration = useRef<{ id: string; body: string; owner: string } | undefined>(undefined); const separateRegistration = useRef(false); const [unresolvedChoice, setUnresolvedChoice] = useState(false); // A Check and a create never overlap: each takes the next action number, and only the latest may publish. const actionSeq = useRef(0); const recoveringRef = useRef(false); // Set in the same tick a create starts, so a Check clicked before the next render still sees it. Each opening // starts clear: a create still pending from an earlier opening does not hold this one. const creatingRef = useRef(null); const [recovering, setRecovering] = useState(false); // A new viewer or practice remounts the recovery list, so nothing it loaded under the last one is shown. const [recoveryEpoch, setRecoveryEpoch] = useState(0); const [form, setForm] = useState(EMPTY_FORM); const fieldId = useId(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); // fix wave (Important 1b) - ALL candidates the server returned (up to MAX_CANDIDATES), // not just the first. `truncated` is surfaced rather than dropped: the receptionist should know // more matches existed than are shown. const [candidates, setCandidates] = useState([]); const [truncated, setTruncated] = useState(false); // The candidateToken currently being linked (if any). Doubles as a lock: a second link click // while one is already in flight is ignored, so two concurrent link attempts can never both // resolve and both call onRegistered. const [linkingToken, setLinkingToken] = useState(); const [advice, setAdvice] = useState(); const [adoptionHold, setAdoptionHold] = useState(); const [holdUnreadable, setHoldUnreadable] = useState(false); const [boundAccounts, setBoundAccounts] = useState>(new Set()); const holdStore = useRef(createCandidateAdoptionHoldStore()); const registeredCallback = useRef(onRegistered); registeredCallback.current = onRegistered; const revision = useRef(0); const probeBasis = useRef<{ revision: number; payload: PatientMatchInput; started: number } | undefined>(undefined); const adoptionBusy = useRef(false); // Which retry of a held linked record holds adoptionBusy, so only that one releases it. const linkedReadOwner = useRef(null); const ownerFor = (): CandidateAdoptionOwner | undefined => { const signedIn = currentUser?.cognito?.sub; const actor = currentUser?.fhir; const organizationId = scope?.organizationId ?? wpOrganization.current?.id; if (!signedIn || !actor?.resourceType || !actor.id || !organizationId) return undefined; const acting = actingAsRef?.current; return { apiContext: environment.fhirIdentityApi, storeContext: `${environment.localStoragePrefix}:${environment.cognitoUserPoolId}`, signedIn, actedAs: JSON.stringify([`${actor.resourceType}/${actor.id}`, acting ? [acting.relatedPersonId, acting.subjectPatientId, acting.scope ?? ''] : 'self']), organizationId, }; }; const readAdoptionHold = () => { const owner = ownerFor(); if (!owner) { setAdoptionHold(undefined); setHoldUnreadable(false); return undefined; } try { const stored = holdStore.current.read(owner); const held = stored?.state === 'opened' ? undefined : stored; setAdoptionHold(held); setHoldUnreadable(false); return held; } catch { setAdoptionHold(undefined); setHoldUnreadable(true); throw new Error('The earlier add-to-practice result could not be read safely. Registration is held in this practice.'); } }; // Each opening (and each close) of the dialog is its own: a registration started in one never writes on, // or speaks, in the next. The patient it already created stays created. const openingRef = useRef(0); // Cancels what the current opening sent: aborted when it closes, is replaced, the viewer changes or the dialog goes. const openingAbort = useRef(new AbortController()); const nextOpening = () => { setBoundAccounts(new Set()); revision.current += 1; probeBasis.current = undefined; openingRef.current += 1; openingAbort.current.abort(); openingAbort.current = new AbortController(); }; useEffect(() => { openingAbort.current = new AbortController(); return () => openingAbort.current.abort(); }, []); // Which opening, for which viewer, a step belongs to. Taken before the first thing a submit or a link awaits // and carried to its last effect, so nothing of an earlier opening or viewer is admitted into the one on screen. const own = (): Owned => { const alive = lifetime(); const opening = openingRef.current; const changed = revision.current; const practice = wpOrganization.current?.id; const organizationId = scope?.organizationId ?? practice; const { signal } = openingAbort.current; // The abort check repeats the counter on purpose: an aborted opening is never live, whatever aborted it. return Object.assign(() => alive() && openingRef.current === opening && revision.current === changed && wpOrganization.current?.id === practice && !signal.aborted && stillOpen(scope), { signal, organizationId }); }; // Both desk and embedded requests retain the practice and opening that prepared them. const transportFor = (current: Owned) => ({ admit: () => { if (!current()) throw new SaveRetiredError(); }, signal: scope ? eitherAborted(scope.signal, current.signal) : current.signal, organizationId: current.organizationId, }); const withDeadline = async (current: Owned, work: (transport: ReturnType) => Promise): Promise => { const operation = startSaveOperation(current); const transport = transportFor(current); try { return await operation.step(() => work({ ...transport, signal: eitherAborted(transport.signal, operation.transport.signal), admit: () => { transport.admit(); operation.admit(); }, })); } finally { operation.retire(); } }; // Another viewer or practice is another opening: the dialog is usable again and shows nothing of the last one's. useEffect(() => onViewerChange(() => { nextOpening(); actionSeq.current += 1; recoveringRef.current = false; creatingRef.current = null; setRecovering(false); unresolvedRegistration.current = undefined; separateRegistration.current = false; setUnresolvedChoice(false); setRecoveryEpoch((epoch) => epoch + 1); setSubmitting(false); setError(undefined); setCandidates([]); setTruncated(false); setLinkingToken(undefined); setAdvice(undefined); setAdoptionHold(undefined); setHoldUnreadable(false); }), []); useEffect(() => { nextOpening(); actionSeq.current += 1; creatingRef.current = null; recoveringRef.current = false; setRecovering(false); if (open) { // SEEDED FROM EMPTY, NEVER MERGED INTO WHAT WAS THERE. This effect exists to make every opening a // clean one, and a prefill spread over leftover state would carry the previous patient's fields // into the next registration. The seed is applied here for the same reason: it is the one place // that decides what "the form, freshly open" means. setForm({ ...EMPTY_FORM, ...(prefill?.firstName ? { firstName: prefill.firstName } : {}), ...(prefill?.lastName ? { lastName: prefill.lastName } : {}), ...(prefill?.email ? { email: prefill.email } : {}), ...(prefill?.phone ? { phone: prefill.phone } : {}), ...(prefill?.emailAbsenceReason ? { emailAbsenceReason: prefill.emailAbsenceReason, portalUse: "none" as const } : {}), }); setError(undefined); setSubmitting(false); setCandidates([]); setTruncated(false); setLinkingToken(undefined); setAdvice(undefined); } // `open` ALONE, and that is deliberate. A prefill object rebuilt by the parent on every render // would re-seed the form under a receptionist mid-typing; what matters is the moment it opens. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const currentLane = JSON.stringify(ownerFor()); useEffect(() => { if (!open) return; try { readAdoptionHold(); } catch { /* The held warning is rendered independently of the form. */ } // Each identity tuple is a different durable lane; returning to it does not discard its hold. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, currentLane]); /** * The fields the duplicate check is actually computed from. * * Listed rather than "any change", because the panel is expensive to lose: a receptionist * reading a real match should not have it vanish because they typed a house number. These are * exactly the keys `matchPayload` sends, and the two lists have to move together. */ const MATCHED_FIELDS: Array = [ "firstName", "lastName", "email", "phone", "birthDate", "postalCode", ]; /* * A MATCH PANEL STOPS SPEAKING THE MOMENT IT IS OUT OF DATE. * * Walked 2026-09-07: registering raised *"Possible existing record"* on first name, phone and * date of birth. Correcting the phone and the date of birth in the fields directly beneath it * left the panel showing the OLD phone and the OLD date of birth, each still labelled * **matches**, and still blocking. * * => The panel is a snapshot of the last submission wearing the present tense, and the block it * justifies is therefore unjustified: a receptionist who has just fixed the very fields being * matched on is told they still collide, with evidence the form no longer contains. * * CLEARED RATHER THAN RE-QUERIED, and that is the fix rather than a cheaper version of it. * Re-running the match on every keystroke would be a request per character against a global * identity search. Clearing puts Register back as the action, and Register is what runs the * check - so the next press re-evaluates against what is actually typed, and the panel either * comes back with current values or does not come back at all. Nothing stale can be on screen. */ const choosePortalUse = (portalUse: PortalUse) => set({ portalUse, email: portalUse === "email" ? form.email : "", // A managed patient has no address of their own; the related person is invited instead. emailAbsenceReason: portalUse === "managed" ? "not-applicable" : portalUse === "none" && form.portalUse === "none" ? form.emailAbsenceReason : "", ...(portalUse === "managed" ? {} : { managerEmail: "", managerFirstName: "", managerLastName: "", managerAttested: false }), }); const set = (patch: Partial) => { if (MATCHED_FIELDS.some((field) => field in patch)) { revision.current += 1; probeBasis.current = undefined; setCandidates([]); setTruncated(false); setAdvice(undefined); setSubmitting(false); setLinkingToken(undefined); } setForm((current) => ({ ...current, ...patch })); }; const emailValid = EMAIL_PATTERN.test(form.email.trim()); const boundAccount = boundAccounts.has(form.email.trim().toLowerCase()); const phoneValid = isValidPhoneNumber(form.phone); /** * - A PATIENT WHO CANNOT GIVE AN EMAIL CAN STILL BE REGISTERED AND TREATED. * * **Spencer, 2026-08-22:** *"maybe there's situations where the patient is incapacitated and can't * give it or just doesn't have one. The org still needs to treat the patient which means they need our * system to do the encounter."* * * **NOBODY SHOULD EVER HAVE TO INVENT A FAKE EMAIL TO GET PAST THIS FORM** - that is the card's own * fourth control, and a fabricated address is worse than a blank one: it is unreachable AND it looks * reachable, so the account invite goes nowhere and nothing says so. * * The absence is RECORDED, never blank. The server refuses an empty string exactly as firmly as a * missing field, so this is not the client being strict on its own initiative - the two halves state * the same rule and `patientCreateRequirements` is the one that enforces it. */ const emailDeclined = Boolean(form.emailAbsenceReason); /* * THE DESIGNATION IS OFFERED ONLY WHERE IT IS THE ANSWER TO SOMETHING. * * A patient with an email is invited and signs in as themselves; there is nothing to designate. * A patient with none has no way into their own account, and the 2026-09-06 ruling is what fills * that gap - somebody designated at the desk and attested like consent. * * OPTIONAL, and that is not a hedge. A no-email record with nobody managing it is a legitimate * outcome the drawing states in its own words: "a record you can book, chart and invoice against, * with no account". Forcing a designation would invent a proxy for a patient who never asked for * one, which is a worse error than leaving the record as it is. */ const managed = form.portalUse === "managed"; const designating = managed && Boolean(form.managerEmail.trim()); const managerEmailValid = EMAIL_PATTERN.test(form.managerEmail.trim()); const designationReady = (!managed && !designating) || (designating && managerEmailValid && Boolean(form.managerFirstName.trim()) && Boolean(form.managerLastName.trim()) && form.managerAttested); const hasAddress = hasPostalAddress(form); /** * THE RULE ITSELF LIVES IN `utils/registerPatientRequirements`, and it is asked here rather than * restated. It mirrors the server's `patientCreateRequirements` case for case, and both are held to the * same table of tests - which is the only thing that can keep two repositories honest with each other. * Do not re-derive any part of it inline; that is how the halves drift and a receptionist learns * about the disagreement from a 400. */ // The designation, when one is being made, is part of what makes this form submittable - an // attestation half-filled is not an attestation, and the tick is the whole of it. const canSubmit = canRegisterPatient(form) && !submitting && designationReady; // While possible existing records are shown, or one is being added, the choice is on the cards. The // footer stays shut so a second press cannot re-check and create beside an adoption. Kept apart from // `canSubmit`, which "Different person" still needs true to reach the create. const choosingAmongCandidates = candidates.length > 0 || Boolean(linkingToken); const identifiable = identifiableWithoutEmail(form); const buildAddress = (): Address[] | undefined => { const line = [form.line1.trim(), form.line2.trim()].filter(Boolean); const hasAny = line.length || form.city.trim() || form.state.trim() || form.postalCode.trim(); if (!hasAny) return undefined; return [ { line: line.length ? line : undefined, city: form.city.trim() || undefined, state: form.state.trim() || undefined, postalCode: form.postalCode.trim() || undefined, }, ]; }; const matchPayload = () => ({ firstname: form.firstName.trim(), lastname: form.lastName.trim(), email: form.email.trim(), phone: form.phone.trim(), birthDate: form.birthDate, postalCode: form.postalCode.trim() || undefined, }); const requireNoHeldAdoption = () => { if (readAdoptionHold()) throw new Error('An earlier add-to-practice result is still held. Check that original result before starting another registration.'); }; const handleSubmit = async (retry = false) => { if (creatingRef.current || recoveringRef.current) return; const token = {}; creatingRef.current = token; try { await submitRegistration(retry); } finally { if (creatingRef.current === token) creatingRef.current = null; } }; const submitRegistration = async (retry: boolean) => { if (!canSubmit || (!retry && choosingAmongCandidates) || adoptionBusy.current) return; actionSeq.current += 1; revision.current += 1; const current = own(); setError(undefined); try { requireNoHeldAdoption(); } catch (heldError) { setError((heldError as Error).message); return; } const payload = matchPayload(); const started = performance.now(); setSubmitting(true); setAdvice(undefined); probeBasis.current = undefined; try { const result = await withDeadline(current, (transport) => matchCandidates(payload, transport)); if (!current()) return; setCandidates(result.candidates); setTruncated(result.displayLimited || result.truncated === true); setAdvice(result.advisory); probeBasis.current = { revision: revision.current, payload, started }; if (result.candidates.length > 0 || result.advisory !== 'complete' || boundAccount) { setSubmitting(false); return; } } catch { if (!current()) return; setAdvice('unavailable'); probeBasis.current = { revision: revision.current, payload, started }; setSubmitting(false); return; } await performCreate(current); }; const retryMatching = () => { if (submitting || linkingToken || adoptionBusy.current) return; setCandidates([]); setAdvice(undefined); probeBasis.current = undefined; void handleSubmit(true); }; const fetchKnownLinked = async (patientId: string, owned: Owned, expectedOwner: CandidateAdoptionOwner) => { const current = () => owned() && JSON.stringify(ownerFor()) === JSON.stringify(expectedOwner); if (!current()) return false; const linked = await withDeadline(owned, (transport) => fetchPatient(patientId, false, undefined, true, transport)); if (!current()) return false; if (!linked || linked.resourceType !== 'Patient' || linked.id !== patientId) throw new Error('The added patient record could not be confirmed.'); toast.success('Added to their existing Akashic record. No new login was created.'); setCandidates([]); setTruncated(false); registeredCallback.current(linked); return true; }; const handleLink = async (target: MatchCandidate) => { const basis = probeBasis.current; if (recoveringRef.current) return; if (adoptionBusy.current || linkingToken || !basis || basis.revision !== revision.current || !candidates.some((candidate) => candidate.candidateToken === target.candidateToken)) return; if (performance.now() - basis.started >= 600_000) { setError('These matches may have expired. Retry matching before adding an existing patient.'); return; } const owned = own(); const owner = ownerFor(); if (!owner) { setError('Your signed-in practice identity is unavailable. Sign in again before adding an existing patient.'); return; } const payload = { ...basis.payload }; adoptionBusy.current = true; setLinkingToken(target.candidateToken); setError(undefined); try { requireNoHeldAdoption(); const intentId = crypto.randomUUID(); const salt = crypto.randomUUID(); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([salt, payload, target.candidateToken]))); if (!owned()) return; const payloadDigest = `${salt}:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`; await holdStore.current.withReservation(owner, { intentId, payloadDigest }, async (lease) => { let sent = false; let answerOpen = true; try { const linked = await withDeadline(owned, async (transport) => { const result = await linkExistingPatient({ ...payload, candidateToken: target.candidateToken }, { ...transport, admit: (phase?: 'answer') => { if (phase === 'answer') { if (!answerOpen || !sent) throw new SaveRetiredError(); return; } transport.admit(); }, onSend: () => { lease.onSend(); sent = true; }, }); if (!sent || result.linked !== true || typeof result.patientId !== 'string' || !/^[A-Za-z0-9.-]{1,64}$/.test(result.patientId)) { throw new Error('The add-to-practice result could not be confirmed.'); } // Durable settlement precedes publication checks for the original executor. lease.knownLinked(result.patientId); return result; }); if (!owned()) return; readAdoptionHold(); if (await fetchKnownLinked(linked.patientId, owned, owner)) { lease.opened(); if (owned()) readAdoptionHold(); } } catch (failure) { if (!sent) lease.releaseUnsent(); throw failure; } finally { answerOpen = false; } }); } catch (linkError) { if (!owned()) return; let held: CandidateAdoptionHold | undefined; try { held = readAdoptionHold(); } catch { /* Keep the unreadable-state refusal. */ } setError(held?.state === 'linked' ? 'This patient was added to the practice, but their record could not be opened. Retry opening that same record.' : held ? 'The add-to-practice result is unconfirmed. Do not register or add another record for this attempt.' : serverMessage(linkError) ?? 'Could not prepare the add-to-practice request. Search again before trying.'); setCandidates([]); setTruncated(false); setAdvice(undefined); probeBasis.current = undefined; } finally { adoptionBusy.current = false; if (owned()) setLinkingToken(undefined); } }; // Opening the held linked record is an action like a Check or a create: refused while either is out, and it takes // the next action number, so only this action publishes, reports or releases its guard. const retryLinkedRead = async () => { if (adoptionBusy.current || creatingRef.current || recoveringRef.current) return; const seq = actionSeq.current + 1; actionSeq.current = seq; const opened = own(); const owned: Owned = Object.assign(() => opened() && actionSeq.current === seq, { signal: opened.signal, organizationId: opened.organizationId }); const mine = {}; try { const held = readAdoptionHold(); if (held?.state !== 'linked') return; const owner = ownerFor(); if (!owner) return; adoptionBusy.current = true; linkedReadOwner.current = mine; setSubmitting(true); await holdStore.current.withLinkedHandoff(owner, held, () => fetchKnownLinked(held.patientId, owned, owner)); if (owned()) readAdoptionHold(); } catch { if (owned()) setError('The added patient record could not be opened. Retry opening that same record.'); } finally { if (linkedReadOwner.current === mine) { linkedReadOwner.current = null; adoptionBusy.current = false; } if (owned()) setSubmitting(false); } }; const handleDismissCandidates = async () => { if (creatingRef.current || recoveringRef.current) return; const token = {}; creatingRef.current = token; try { await dismissCandidatesAndCreate(); } finally { if (creatingRef.current === token) creatingRef.current = null; } }; const dismissCandidatesAndCreate = async () => { if (linkingToken || adoptionBusy.current || boundAccount || candidates.some((candidate) => candidate.sameAccount === true) || probeBasis.current?.revision !== revision.current) return; const current = own(); try { requireNoHeldAdoption(); } catch (heldError) { setError((heldError as Error).message); return; } setCandidates([]); setTruncated(false); setAdvice(undefined); await performCreate(current); }; // A registration found again from the server's receipts: the record is shown, and nothing from the original form is // replayed, because what it asked for beyond the record cannot be confirmed from here. const handleRecovered = (receipt: RegistrationReceipt) => { if (unresolvedRegistration.current?.id === receipt.registrationId) unresolvedRegistration.current = undefined; setUnresolvedChoice(false); toast.success([RECOVERED_REGISTRATION_COPY, ...accountLines(receipt)].join(" ")); onRegistered(receipt.patient); }; // A Check runs alone: it is refused while a create or adoption is out, and publishes only if nothing started since. const checkRegistration = async (registrationId: string): Promise => { if (creatingRef.current || submitting || adoptionBusy.current || linkingToken || recoveringRef.current) throw new SaveRetiredError(); const seq = actionSeq.current + 1; actionSeq.current = seq; recoveringRef.current = true; setRecovering(true); const current = own(); try { const receipt = await withDeadline(current, (transport) => resumeRegistration(registrationId, transport)); if (!current() || actionSeq.current !== seq) throw new SaveRetiredError(); return receipt; } finally { if (actionSeq.current === seq) { recoveringRef.current = false; setRecovering(false); } } }; const loadRegistrations = async (cursor?: string) => { const current = own(); const page = await withDeadline(current, (transport) => listRegistrations(cursor, transport)); if (!current()) throw new SaveRetiredError(); return page; }; // The desk chose to register someone else while an earlier registration is unconfirmed: that one stays listed. const registerSeparately = () => { separateRegistration.current = true; setUnresolvedChoice(false); void handleSubmit(); }; // `current` is the ownership its caller took before the first thing it awaited (the duplicate pre-check), so the // registration, the details, the designation, every message, the callback and the busy state belong to the // opening, viewer and practice it was started for. What it already wrote stands. const performCreate = async (current: Owned) => { if (!current() || adoptionBusy.current) return; if (boundAccount) { setSubmitting(false); setError('This email already belongs to a patient account. Retry matching before adding that existing patient.'); return; } try { requireNoHeldAdoption(); } catch (heldError) { if (current()) { setError((heldError as Error).message); setSubmitting(false); } return; } const organization = wpOrganization.current; if (!organization?.id) { // Fix round 1 ( review): `performCreate` can now be reached AFTER `handleSubmit` // already set `submitting` true (the match-check fallthrough), so this early return must undo // that itself or the "Register Patient" button is stuck disabled behind an error message that // tells the receptionist to "try again" when trying again is impossible without closing and // reopening the dialog. Before this guard ran BEFORE `submitting` was ever set, so it // never needed to reset anything - that invariant no longer holds now that this function has a // caller (handleSubmit's fallthrough) which sets `submitting` before invoking it. setSubmitting(false); setError("Organization information is missing. Please try again."); return; } // NOTE (same review): the `!canSubmit` guard below does NOT need the same fix. `canSubmit` is a // plain const captured by this closure at render time, not a live read of state - and every path // that reaches `performCreate` already re-checks the identical `canSubmit` value first: // `handleSubmit` (the only caller that sets `submitting` true beforehand) already returned at its // own `if (!canSubmit) return;` before ever setting `submitting` or calling this function, so by // the time this line runs in that path, `canSubmit` is guaranteed true here too (same closure, // same value) - this guard cannot fire there. `handleDismissCandidate` (the only other caller) // never sets `submitting` true before calling this function, so even if `canSubmit` has gone // stale-false by here, nothing is stranded (see deferred item 1 in the review for the // separate "silent no-op" behavior that path already has - not this bug, and not fixed here). if (!canSubmit) return; setSubmitting(true); setError(undefined); try { const createOwner = ownerFor(); if (!createOwner) throw new Error('Your signed-in practice identity is unavailable. Sign in again before registering a patient.'); const createTransport = transportFor(current); // 1. Minimal create (server contract: name + email + org), as an operation this opening names. const createBody = { firstname: form.firstName.trim(), lastname: form.lastName.trim(), // - one of these two, never both and never neither. The server enforces exactly that // pairing; sending an empty string alongside a reason would be the blank this card abolishes. email: emailDeclined ? undefined : form.email.trim(), emailAbsenceReason: emailDeclined ? form.emailAbsenceReason : undefined, // The dedup material the server requires in place of an email. It is sent on BOTH paths - it // costs nothing when an email is present, and omitting it on the no-email path is a 400. birthDate: form.birthDate || undefined, phone: phoneValid ? form.phone.trim() : undefined, address: hasAddress ? { line: [form.line1.trim()].filter(Boolean), city: form.city.trim(), postalCode: form.postalCode.trim() } : undefined, role: "Patients", organization: createReferenceObject( "Organization", organization.id, organization.name, ), }; const bodyKey = JSON.stringify(createBody); const owner = JSON.stringify(createOwner); const pending = unresolvedRegistration.current?.owner === owner ? unresolvedRegistration.current : undefined; // A changed form is never silently a new registration while the last one is unconfirmed. if (pending && pending.body !== bodyKey && !separateRegistration.current) { setUnresolvedChoice(true); setError("Your last registration was not confirmed. Check it first, or register this as a separate patient."); return; } separateRegistration.current = false; setUnresolvedChoice(false); const registrationId = pending?.body === bodyKey ? pending.id : newRegistrationId(); unresolvedRegistration.current = { id: registrationId, body: bodyKey, owner }; const receipt = await createPatient(registrationId, createBody, { ...createTransport, admit: () => { createTransport.admit(); if (JSON.stringify(ownerFor()) !== JSON.stringify(createOwner)) throw new SaveRetiredError(); }, dispatch: (prepare) => holdStore.current.dispatchCreate(createOwner, prepare).catch((failure) => { if (current()) { try { readAdoptionHold(); } catch { /* Preserve the unreadable-lane hold. */ } } throw failure; }), }); if (!current()) return; // A validated receipt settles this registration for its owner; its server-side follow-ups stay listed. if (unresolvedRegistration.current?.id === registrationId) unresolvedRegistration.current = undefined; const created = receipt.patient; const loginLines = emailDeclined ? [] : accountLines(receipt, form.email.trim()); // 2. Enrich the rest via guardrail-1 full fetch -> merge -> PUT. Best-effort: if it fails the record still // exists, so we open the workspace with what we have rather than risk a duplicate on retry. const phone = form.phone.trim(); const address = buildAddress(); const hasExtras = Boolean( phone || form.birthDate || form.gender || form.maritalStatus || address, ); let finalPatient = created; let enrichFailed = false; // Its write was sent and no definite answer came back: the details may or may not be on the record. let enrichUnconfirmed = false; if (hasExtras) { // What the write itself threw, so a failed read is never taken for a write that may have landed. let writeFailure: unknown; try { // Saved over this new record as it is now, only while what it adds is still as it was created, and // conditional on its version. const owned = (record: Patient) => JSON.stringify([ record.birthDate ?? null, record.gender ?? null, record.maritalStatus ?? null, record.address ?? null, (record.telecom ?? []).filter((point) => point.system === "phone"), ]); if (!current()) return; // Part of this registration: retired with it (closed, or another started), and bounded by a deadline. const operation = startSaveOperation(current); // Retired with the embedding opening too, and sent under the practice it was opened in. scope?.signal.addEventListener("abort", () => operation.retire(), { once: true }); if (scope) current.signal.addEventListener("abort", () => operation.retire(), { once: true }); const enrichTransport = scope ? { ...operation.transport, organizationId: scope.organizationId } : operation.transport; finalPatient = await saveOverCurrent({ operation, // A desk with no care relationship yet reads the demographic projection, which carries no version, and every // Patient write names one. The version this desk was handed is the create's own; the server still refuses it if // anything has changed since. read: async () => { const record = await fetchPatient(created.id as string, false, undefined, true, enrichTransport); return record.meta?.versionId || !created.meta?.versionId ? record : { ...record, meta: { ...record.meta, versionId: created.meta.versionId } }; }, stillHolds: (record) => owned(record) === owned(created), apply: (draft) => { if (form.birthDate) draft.birthDate = form.birthDate; if (form.gender) draft.gender = form.gender as Patient["gender"]; if (phone) { const telecom = [...(draft.telecom ?? [])]; const index = telecom.findIndex((point) => point.system === "phone"); if (index >= 0) telecom[index] = { ...telecom[index], value: phone }; else telecom.push({ system: "phone", value: phone }); draft.telecom = telecom; } if (form.maritalStatus) { const option = MARITAL_STATUS_OPTIONS.find( (o) => o.value === form.maritalStatus, ); draft.maritalStatus = { coding: [ { system: MARITAL_STATUS_SYSTEM, code: form.maritalStatus, display: option?.label, }, ], text: option?.label, }; } if (address) draft.address = address; return draft; }, write: (candidate, basedOn) => updatePatient(candidate, basedOn, enrichTransport).catch((error: unknown) => { writeFailure = error; throw error; }), }); } catch (enrichError) { console.error( "Patient created but demographic enrich failed:", enrichError, ); enrichFailed = true; enrichUnconfirmed = (isSaveTimedOut(enrichError) && enrichError.mayHaveSaved) || (enrichError === writeFailure && outcomeUnknown(enrichError)); } } /* * THE DESIGNATION, AND IT RUNS AFTER THE PATIENT EXISTS FOR THE OBVIOUS REASON: the link * points at them. * * ITS FAILURE MUST NOT LOSE THE PATIENT. The record is already created and correct by this * point, so a failed invite is reported and the registration still completes - the alternative * is a receptionist who watched a patient be registered, saw an error, and has no way to know * whether to type it all again. `submitRelatedPersonInvite` already distinguishes the two * halves for us: a `RelatedPersonAuthorityError` means the LINK exists and only the tier * failed, which is retryable from the patient's record without minting a duplicate. * * THE TIER IS `scheduling-forms` AND THAT IS THE CEILING THIS PATH MAY GRANT. It is the tier * that matches what the designation is FOR - handling appointments and paperwork for somebody * who cannot use the portal. Full proxy is a larger grant, it is hard-gated behind identity * proofing, and a counter is not where it should be handed out. */ if (!current()) return; let designated = false; // Keyed on the CREATED patient, never on the enriched one. The demographic enrich above is // best-effort and its result is reassigned over `finalPatient` - so on a failed or empty enrich // that variable is no longer a patient, and reading an id off it threw and took the whole // registration with it. The id that matters was minted by the create and cannot change. const registeredPatientId = created?.id; if (designating && registeredPatientId) { try { await submitRelatedPersonInvite( { email: form.managerEmail.trim(), firstname: form.managerFirstName.trim(), lastname: form.managerLastName.trim(), tier: 'scheduling-forms', patient: { reference: `Patient/${registeredPatientId}` }, attestation: buildDesignationAttestation(), }, (payload) => createRelatedPerson(payload, transportFor(current)), (intent, relatedPersonId, tier, allowMessaging, allowBillingView, allowBillingPay, attestation) => putAuthority(intent, relatedPersonId, tier, allowMessaging, allowBillingView, allowBillingPay, attestation, transportFor(current)), current, ); designated = true; } catch (designationError) { console.error('Patient created but the designation failed:', designationError); if (!current()) return; const manager = `${form.managerFirstName.trim()} ${form.managerLastName.trim()}`; if (designationError instanceof RelatedPersonInviteAbandoned) { // Left before its access was sent: the invitation stands at the assist-only floor. designated = true; } else if (designationError instanceof RelatedPersonAuthorityError) { // THE LINK EXISTS: they were invited. What is not known, or not done, is the access they were given, // and an access that may have been applied is not reported as missing. designated = true; toast.error( [authorityFailureMayRetry(designationError) ? `Patient registered and ${manager} was invited, but we couldn't confirm the access they were given was saved.` : `Patient registered and ${manager} was invited, but the access they were given was not saved.`, ...loginLines].join(" "), { duration: Infinity }, ); } else { // No staff screen can add a related person afterwards, so this names no next step it cannot // keep. It stays until dismissed: a patient registered with nobody able to act for them is // not a notice the desk can miss while the success line fades. toast.error( [`Patient registered, but ${manager} could not be invited, so nobody manages this patient's account yet.`, ...loginLines].join(" "), { duration: Infinity }, ); } } } if (!current()) return; if (enrichFailed) { toast.error( [enrichUnconfirmed ? "Patient created, but we could not confirm whether some details saved. Check them in the profile." : "Patient created, but some details didn't save. Add them in the profile.", ...loginLines].join(" "), ); } else { /* * THE SENTENCE HAS TO MATCH WHAT ACTUALLY HAPPENED, and it did not. * * Registering with **No email? -> None** produced *"A login invite was emailed to the * patient"* - one line under a field group that had just said, correctly, that this patient * will have no portal login. Walked 2026-09-07 and confirmed on the record afterwards: * Email None, Login Email No login. Nothing was sent and nothing could have been. * * A FALSE REPORT OF AN ACTION TAKEN IS WORSE THAN A MISSING FEATURE, because nobody plans * around a feature that is not there. A receptionist who reads this tells the patient to * watch for an email, and the patient waits at home instead of being handed the paper form * that was the whole point of registering them without one. * * The behaviour is right and only the sentence was wrong - at the desk an email means an * invitation and no email means a record with no account, ruled 2026-09-06, with no * "decide later". So this branches on the same state the form does and says the second half * out loud: what the receptionist has to do NEXT, which is the reason they are reading it. */ toast.success( emailDeclined ? designated ? "Patient registered, and the person managing their account has been invited. The patient has no portal login of their own." : "Patient registered. They have no portal login, so anything they need to sign or fill in has to be done in the office." : ["Patient registered.", ...loginLines].join(" "), ); } onRegistered(finalPatient); } catch (err) { console.error("Failed to register patient:", err); if (!current()) return; // Unconfirmed is not failed: the patient may exist, so the same form resumes the same registration. const status = (err as { status?: unknown } | null)?.status; const attempted = unresolvedRegistration.current; if (attempted && (err instanceof RegistrationUnresolvedError || outcomeUnknown(err) || (typeof status === "number" && status >= 500))) { setError("We could not confirm this registration. Register again with the same details to check it, or check it under Your recent registrations."); return; } // A definite refusal of this attempt: it created nothing, so it is no longer outstanding. if (attempted && !(err instanceof RegistrationUnresolvedError)) unresolvedRegistration.current = undefined; const message = relayServerFailure(err, ""); // The server 409s when this email already has a patient record (dedup lives in the shared Patient/post // handler). Surface that as a clear, actionable message instead of the raw "Request failed:..." text. if (/already has a Patient resource|already exists/i.test(message)) { const refusal = err as { status?: unknown; body?: { error?: unknown } } | null; if (refusal?.status === 409 && refusal.body?.error === 'User already has a Patient resource') { setBoundAccounts((known) => new Set([...known, form.email.trim().toLowerCase()])); } setError( "A patient with this email is already registered. Search for them instead of creating a new record.", ); } else { setError( `Failed to register patient: ${message || "Please try again."}`, ); } } finally { if (current()) setSubmitting(false); } }; return ( { event.preventDefault(); firstNameInput.current?.focus(); }} > Register a new patient {/* The deck draws no line under the title; each portal answer says what follows from it. */} {/* The standing description made the same claim unconditionally, and it is on screen the whole time the receptionist is deciding. It now says what is true before the answer is given, and the email field's own help text says what follows from it. */} Create a patient record. If you enter an email, the patient receives sign-in instructions. You can fill in the remaining details later.
{unresolvedChoice && unresolvedRegistration.current && (

Your last registration was not confirmed, and this form has changed since. It may already have created that patient.

)} {boundAccount && !adoptionHold && !holdUnreadable && (

This email already belongs to a patient account. A new registration cannot replace it. Retry matching to confirm an existing record.

)} {(adoptionHold || holdUnreadable) && (

{adoptionHold?.state === 'linked' ? 'This patient was added to this practice. Only that same record can be opened for this attempt.' : 'An earlier add-to-practice result is unconfirmed. Another registration or adoption is held in this practice.'}

{adoptionHold?.state === 'linked' && ( )}
)} {advice && advice !== 'complete' && !adoptionHold && !holdUnreadable && !boundAccount && (

{advice === 'rateLimited' ? 'Matching is temporarily limited. Other existing records may not have been checked.' : advice === 'unavailable' ? 'Matching is unavailable. Existing records could not be checked completely.' : 'Matching is incomplete. Other existing records may not have been checked.'}

)} {candidates.length > 0 && (
{candidates.map((c) => ( handleLink(c)} onDismiss={handleDismissCandidates} allowCreate={!boundAccount && !candidates.some((candidate) => candidate.sameAccount === true)} // Every card locks while any link is in flight, so no sibling's "Different person" can fire // performCreate() mid-link; the idle cards' Link also reads as linking, which is true of the hold. linking={Boolean(linkingToken)} /> ))} {truncated && (

More possible matches exist than are shown here — narrow the details above if none of these look right.

)} {advice === 'complete' && !boundAccount && ( )}
)}
set({ firstName: event.target.value })} disabled={submitting} ref={firstNameInput} /> set({ lastName: event.target.value })} disabled={submitting} />
How will the patient use the portal? {PORTAL_USE_OPTIONS.map((option) => ( ))}
{form.portalUse === "email" && ( set({ email: event.target.value })} disabled={submitting} placeholder="patient@example.com" /> {form.email.trim() && !emailValid && (

Enter a valid email address.

)}
)} {form.portalUse === "none" && (
{EMAIL_ABSENCE_OPTIONS.map((option) => ( ))}

This patient will have no portal login. They can be treated normally, and an account can be added later from their record. {!identifiable && ( <> {" "} A date of birth and either a phone number or an address are needed, so the record can be matched to them later. )}

)} {/* WHO MANAGES IT, for a patient who will not be managing it themselves. Ruled 2026-09-06: a patient who cannot or will not use the portal may be managed by a related person designated at the desk and attested like consent. Until this existed, registering that patient ended with a record nobody could act for. LEFT BLANK IS A REAL ANSWER and the common one - a record with no account and nobody managing it is exactly what the approved drawing describes. The whole section is optional, and it only asks for the attestation once an address has been typed. */} {managed && (

The invitation goes to them, not to the patient. The patient has no login of their own.

set({ managerEmail: event.target.value })} disabled={submitting} placeholder="Their email address" /> {form.managerEmail.trim() && !managerEmailValid && (

Enter a valid email address.

)} {designating && (
set({ managerFirstName: event.target.value })} disabled={submitting} placeholder="Their first name" /> set({ managerLastName: event.target.value })} disabled={submitting} placeholder="Their last name" />
{/* THE BOUNDARY IS PART OF THE STATEMENT, not a footnote under it. The last line is a constraint on what this grant may ever do: consent to treat is the patient's to give or attested here separately, and a designation must never quietly confer it. */}

{DESIGNATION_HEADLINE}

{DESIGNATION_SUMMARY.map((line) => (

{line}

))}
)}
)}
set({ phone: event.target.value })} disabled={submitting} /> {form.phone.trim() && !phoneValid && (

Enter a valid 10-digit phone number.

)}
set({ birthDate: event.target.value })} disabled={submitting} />
set({ line1: event.target.value })} disabled={submitting} placeholder="Street address" /> set({ line2: event.target.value })} disabled={submitting} placeholder="Apt, suite, etc. (optional)" /> {/* Was `grid grid-cols-3`, which gave City and ZIP an equal third each - the same room for an unbounded city name as for five digits. The widths now come from one place shared with the patient card's editor, which rendered this row with different numbers. */}
set({ city: event.target.value })} disabled={submitting} placeholder="City" />
set({ state: next ?? "" })} disabled={submitting} /> set({ postalCode: sanitizeUsPostalCode(event.target.value) }) } disabled={submitting} placeholder="ZIP" />
{/* Beside the pinned button, so a failure is seen wherever the form is scrolled. */} {error && (
{error}
)}
); }