import { useState } from "react"; import { Search } from "lucide-react"; import { HttpError } from "../../../services/HttpService"; import { mayHaveBeenWritten } from "../../../utils/fetchFailureMessage"; import { Button } from "../../../components/ui/button"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Checkbox } from "../../../components/ui/checkbox"; import { buildResolveInviteeQuery, buildExternalInviteRequest, buildReferralAuthorization, REFERRAL_AUTH_SUMMARY, } from "../../../services"; import type { MemberClass, TeamMembership, ResolveInviteeQuery, ResolveInviteeResponse, ExternalInviteRequest, ReferralAuthorizationRequest, } from "../../../services"; interface InviteOutsidePractitionerProps { teamId: string; /** True when the OWNING PATIENT is inviting; false when a team practitioner is referring. */ patientInitiated: boolean; resolveInvitee: (query: ResolveInviteeQuery) => Promise; inviteMember: ( teamId: string, request: { memberRef: string; memberClass: MemberClass; role?: string; authorization?: ReferralAuthorizationRequest; }, ) => Promise; inviteExternalPractitioner: (request: ExternalInviteRequest) => Promise<{ status: string; state: string }>; onInvited: () => Promise; /** An email or NPI to start from, such as a referral's recipient; still looked up by the patient. */ initialQuery?: string; } // S1a/S1b (client half) - invite a practitioner who is not already on the team. // // ONE ENTRY FIELD, TWO OUTCOMES. Spencer's requirement is that the invite "rolls seamlessly" // across who the invitee is: the person inviting should not have to know, or care, whether the // practitioner already uses Akashic. So they type an email or an NPI, we resolve it, and the branch // is chosen for them: // // found -> an ordinary member invite by Practitioner/{id} // not found -> an EXTERNAL invite: an emailed single-use token to claim an account // // The external branch additionally needs an NPI, because S1c-a verifies it against the NPPES // registry BEFORE anything is written or emailed - with no organization vouching for a stranger, // that check is the only structural evidence they are a real practitioner. Asking here means a bad // NPI is a form error rather than an unexplained refusal from the server. /** * The server's own sentence for a refusal it curated for this caller, or undefined. * * READS `body.message`, NOT `err.message`. The transport prefixes the thrown message with * "Request failed: ", so showing that would put debugging language in front of a patient. The parsed * body rides along on HttpError precisely so a caller can take the clean text. * * 422 ONLY. That is the status this endpoint uses for a validation refusal it means the person to * read; any other status may carry internal detail and must keep the generic message. */ const refusalMessage = (err: unknown): string | undefined => { if (!(err instanceof HttpError) || err.status !== 422) return undefined; const message = (err.body as { message?: unknown } | undefined)?.message; return typeof message === "string" && message.trim().length > 0 ? message.trim() : undefined; }; /** The practitioner already holds a live invitation to this team, which is resent from the pending list, not here. */ const alreadyInvited = (err: unknown): boolean => err instanceof HttpError && err.status === 409 && (err.body as { code?: unknown } | undefined)?.code === "INVITE_OUTSTANDING"; export const InviteOutsidePractitioner = ({ teamId, patientInitiated, resolveInvitee, inviteMember, inviteExternalPractitioner, onInvited, initialQuery, }: InviteOutsidePractitionerProps) => { const [typed, setTyped] = useState(initialQuery ?? ""); const [npi, setNpi] = useState(""); const [resolved, setResolved] = useState(null); const [agreed, setAgreed] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const lookUp = async () => { const query = buildResolveInviteeQuery(typed); if (!query) { // Refused before the request: the server accepts exactly one of email|npi, so a guess here // would surface as an unexplained 400 instead of a fixable form message. setError("Enter an email address or a 10-digit NPI."); setResolved(null); return; } setBusy(true); setError(null); try { const result = await resolveInvitee(query); setResolved(result); // Carry a typed NPI straight through, so the external branch does not ask for it twice. if (!result.found && query.npi) setNpi(query.npi); } catch { setError("Could not look up that practitioner. Please try again."); } finally { setBusy(false); } }; const isExternal = resolved !== null && !resolved.found; // The patient's agreement gates only the patient's own invite; a practitioner referring // authorizes nothing - the patient does that at approval. const canSend = resolved !== null && (patientInitiated ? agreed : true) && (!isExternal || buildResolveInviteeQuery(npi)?.npi !== undefined || /^\d{10}$/.test(npi.replace(/\D/g, ""))); const send = async () => { if (!resolved || !canSend) return; setBusy(true); setError(null); const authorization = patientInitiated ? buildReferralAuthorization() : undefined; try { if (resolved.found && resolved.memberRef) { await inviteMember(teamId, { memberRef: resolved.memberRef, memberClass: "practitioner", ...(authorization ? { authorization } : {}), }); } else { await inviteExternalPractitioner( buildExternalInviteRequest({ teamId, email: typed, npi, authorization }), ); } setTyped(""); setNpi(""); setResolved(null); setAgreed(false); await onInvited(); } catch (err) { // THE GENERIC MESSAGE WAS NOT MERELY VAGUE - IT GAVE THE WRONG INSTRUCTION. // // Observed on staging: an unverifiable NPI answers 422 with "That NPI could not be verified // against the national registry. Please check it and try again." The dialog replaced that with // "Failed to send the invite. Please try again." - so the patient was told to retry the exact // thing that cannot ever succeed, on the screen where they have just agreed to give somebody // their entire medical record. // // SCOPED TO 422 ON PURPOSE. That status is the server's curated, user-safe refusal for this // call; any other status may carry internal detail, so those keep the generic text. // A lost answer may have come after the invitation was written, and repeating a live one is refused, so // neither is told to try again here: the pending invitations say what happened, and resend lives there. if (alreadyInvited(err)) { setError("This practitioner already has an invitation to this team. You can send it again from the pending invitations."); } else if (mayHaveBeenWritten(err)) { setError("The invitation may already have been sent. Check the pending invitations before sending it again."); } else { setError(refusalMessage(err) ?? "Could not send the invitation. Try again."); } } finally { setBusy(false); } }; return (
{ setTyped(e.target.value); setResolved(null); setAgreed(false); }} placeholder="name@practice.com or 1234567890" /> {/* THE SAME HOUSE ESCAPE THE SEND CONTROL BELOW ALREADY TAKES, applied to the control the send fix stopped short of. `variant="outline"` asks for a border and computed `0px none` with a transparent fill, because the id-scoped rule at index.css:2136 strips fill, border and shadow from every [data-slot="button"] to keep the WordPress theme out. So this read as a label sitting beside the field rather than a control attached to it. `.btn` at index.css:2308 scores the same (1,1,0) as that rule and is LATER in the file, so it wins on source order - which is why the escape works at all. `btn-blue-ghost` is the right variant: this is a READ (it resolves an identifier and grants nothing), so it must not draw as loudly as `Send invite`. Kept on Button rather than a plain button element for the same reason the send control is - the two differ by 5px of height, and here the neighbour is the Input this control sits against. */}
{error &&

{error}

} {resolved?.found && (

Found on Akashic: {resolved.name ?? "this practitioner"}

)} {isExternal && (
{/* `found: false` also covers accounts hidden from this caller, so the copy cannot assert whether the practitioner has an Akashic account. */}

We will email them an invitation to join this collaboration team — it is free for them.

setNpi(e.target.value)} placeholder="1234567890" />

We check this against the national practitioner registry before sending.

)} {resolved !== null && patientInitiated && (

What you are agreeing to

    {REFERRAL_AUTH_SUMMARY.map((line) => (
  • {line}
  • ))}
)} {/* THE HOUSE ESCAPE, NOT A NEW TREATMENT. A bare shadcn Button is stripped of fill, border and shadow by the id-scoped rule at index.css:2090, so this drew as prose: transparent background, no border, weight 350. The dialog footer that wraps this tab already writes the escape - InviteMemberDialog.tsx uses className="btn btn-blue" on a Button - and on the practitioner tab that same footer control prints these very words, "Send invite". Matching it is what makes one action name look like itself on both tabs. Keep it on Button rather than a plain button element: the two differ by 5px of height, and the neighbour is a Button. */} {resolved !== null && ( )}
); }; export default InviteOutsidePractitioner;