import { useEffect, useMemo, useState } from "react"; import { Plus } from "lucide-react"; import { Button } from "../../../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "../../../components/ui/select"; import { AppSpinner } from "../../../components"; import { useFhirAppointments, useFhirRelatedPersons } from "../../../hooks"; import { useRelatedPersonIdentity } from "../../../hooks/useRelatedPersonIdentity.tsx"; import type { RelatedPerson } from "fhir/r4"; import { relatedPersonOption } from "../../../utils/relatedPersonOption"; import { usePractitionerServiceContext, HttpError, buildReferralAuthorization, } from "../../../services"; import { Badge } from "../../../components/ui/badge"; import type { MemberClass, TeamMembership, ReferralAuthorizationRequest, ResolveInviteeQuery, ResolveInviteeResponse, ExternalInviteRequest, } from "../../../services"; import { InviteOutsidePractitioner } from "./InviteOutsidePractitioner"; import { ReferralAuthorizationDialog } from "./ReferralAuthorizationDialog"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; // "outside" ( S1a/S1b) covers a practitioner who is not in the patient's own directory - // including one who is not on Akashic at all. It is its own tab rather than an option inside the // Practitioner tab because that tab picks from practitioners the patient has APPOINTMENTS with, // which is exactly the set an outside referral is not in. type InviteMode = "practitioner" | "outside" | "related-person"; /** * IS HELD (Spencer, 2026-07-31) - the tab is deliberately visible but DISABLED. * * THE BLOCKER IS NOT THIS UI, AND NOT THE SERVER - both are complete. It is that an invited * external practitioner has NOWHERE TO LAND. Every mount in this plugin belongs to a practice's * WordPress site: the staff mount is gated on a workspace (which comes from a `PractitionerRole` * an org-less invitee deliberately does not have), the patient mount is the wrong experience, and * the public mount is unauthenticated. They can accept the emailed invitation and then reach * nothing. The standalone akashic.health client is the front door for exactly this user, and this * feature arrived ahead of it. * * The already-on-Akashic branch is disabled TOO, deliberately, even though that invitee has a * workspace of their own: S1c-b gates chart access on a PER-PRACTITIONER BAA, and there is no * surface anywhere to sign one - so that invite would mint no access either. Enabling half of this * tab would just produce a quieter dead end. * * TO RE-ENABLE, both must be true: (1) an org-less collaborator has somewhere to sign in - * the standalone client, or a membership-gated surface here; (2) a practitioner BAA can actually * be signed. Flip this to `true`; nothing else in the invite path is stubbed. * * RELEASED 2026-08-22. Both conditions above are met, and each was checked against a * DEPLOYED artifact rather than against source: * ① `practitioners.staging.akashic.health` renders **"Sign in to reach the patients who invited you"**, * and its accept screen - which collects the first and last name the server requires - was walked * end to end from a real invite email clicked in a real mail client. * ② the same deployed bundle carries a `sign-baa` screen with an h1 **"Business Associate Agreement"** * and a real signing form, plus the `practitioner-baa` route and `baa/sign`. * Spencer's beta ruling covers it: *"EVERYTHING is to be built for the beta."* * * WHAT TURNING THIS ON EXPOSES, so it is not a surprise: `externalInvite` verifies the NPI against the * **live NPPES registry and FAILS CLOSED** - an outage refuses new invites, deliberately, because with no * organization vouching for the invitee *"the registry was down"* must not become a way to hand out chart * access. An invite needs a REAL registered NPI; a checksum-valid dummy is refused. */ const OUTSIDE_PRACTITIONER_INVITE_ENABLED = true; interface InviteMemberDialogProps { teamId: string; /** True when the OWNING PATIENT is inviting; false when a team practitioner is referring. */ patientInitiated: boolean; resolveInvitee: (query: ResolveInviteeQuery) => Promise; inviteExternalPractitioner: (request: ExternalInviteRequest) => Promise<{ status: string; state: string }>; inviteMember: ( teamId: string, request: { memberRef: string; memberClass: MemberClass; role?: string; // S1c-c - required by the server for a patient-initiated PRACTITIONER invite. authorization?: ReferralAuthorizationRequest; }, ) => Promise; onInvited: () => Promise; /** Opens straight onto an outside-practitioner lookup for this email or NPI. */ prefillLookup?: string; } interface PractitionerCandidate { id: string; label: string; } const PRACTITIONER_REFERENCE_PREFIX = "Practitioner/"; // Invite/add picker for the team-detail page (Task 9; refined in the C1a R2 pass). Two sources: // // Practitioners now come from the patient's OWN appointment history (past or upcoming) rather than // an "any staff member at any org I'm associated with" roster - a care team is about people the // patient actually has a care relationship with. We derive candidates from every Practitioner/{id} // actor across the patient's appointments (useFhirAppointments - the same context AppointmentCard // on the patient Home page already populates), deduped by id, preferring the participant's own // actor.display and falling back to a live Practitioner lookup (fetchPractitioner, the same // resolution MemberIdentity uses) when a display is missing. Note: useFhirAppointments is scoped to // the patient's currently-active organization (X-Organization-ID header) because the Appointment // read endpoint is org-trapped server-side - a known, separate gap, not something to fix here. // // Related persons still come from the patient's own related-persons list (useFhirRelatedPersons, // lazy-loaded only once this dialog is open so a patient who never adds one never pays for that // fetch). Unlike a practitioner invite, adding a related person is now immediate - the server // activates a patient-added related person right away, no accept step - so that path's copy says // "Add", not "Invite"/"pending". A 409 from the server (already on the team) is shown inline rather // than crashing - every CollaborationTeamService call throws HttpError on a non-2xx response. /** Resolves the identity shown for one related-person option. */ const RelatedPersonOption = ({ relatedPerson }: { relatedPerson: RelatedPerson }) => { const { name, contact, loading } = useRelatedPersonIdentity(relatedPerson.id); const { label, selectable } = relatedPersonOption({ name, contact, relationshipText: relatedPerson?.relationship?.[0]?.text, loading, }); return ( {label} ); }; export const InviteMemberDialog = ({ teamId, patientInitiated, inviteMember, resolveInvitee, inviteExternalPractitioner, onInvited, prefillLookup, }: InviteMemberDialogProps) => { // Used for the first opening only, so a later "Invite someone" starts blank. const [prefill, setPrefill] = useState(OUTSIDE_PRACTITIONER_INVITE_ENABLED ? prefillLookup : undefined); const [open, setOpen] = useState(false); const [mode, setMode] = useState(prefill ? "outside" : "practitioner"); // Opened after mount rather than on it, so it stacks above the team dialog that holds it. useEffect(() => { if (prefill) setOpen(true); }, [prefill]); const { appointmentsList, loading: appointmentsLoading, loadAppointments, } = useFhirAppointments({ autoLoad: false }); const { fetchPractitioner } = usePractitionerServiceContext(); const [selectedPractitionerId, setSelectedPractitionerId] = useState(""); // Resolved live-lookup names for candidates whose appointment participant had no actor.display, // keyed by practitioner id. `null` marks an id that was looked up but failed/had no name, so it // isn't retried on every render; absent means "not yet attempted". const [resolvedNames, setResolvedNames] = useState>({}); const { relatedPersonsList, loading: relatedPersonsLoading, loadRelatedPersons, } = useFhirRelatedPersons({ autoLoad: false }); const [selectedRelatedPersonId, setSelectedRelatedPersonId] = useState(""); // S1c-c: inviting a practitioner is what hands them the chart once they accept, so the // patient must be SHOWN that and agree. Claiming a self-service capture without displaying the // terms would record an agreement to text the patient never saw. const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // The practitioner the server said needs the patient's permission before this invite can be sent. const [agreementFor, setAgreementFor] = useState(null); // Lazy-load related persons only once the dialog is open and that tab is (or becomes) active. useEffect(() => { if (open && mode === "related-person") { void loadRelatedPersons(); } }, [open, mode, loadRelatedPersons]); // Lazy-load the patient's own appointments only once the dialog is open and the practitioner tab // is (or becomes) active - appointments are what defines a practitioner "care relationship" here. useEffect(() => { if (open && mode === "practitioner") { void loadAppointments(); } }, [open, mode, loadAppointments]); // Every distinct Practitioner/{id} actor across the patient's appointments (past + upcoming - // useFhirAppointments doesn't filter by status or date), keyed by id, keeping the first non-empty // actor.display seen for that id (undefined if no participant carried one). const practitionerDisplaysById = useMemo(() => { const map = new Map(); appointmentsList.forEach((appointment) => { appointment.participant?.forEach((participant) => { const reference = participant.actor?.reference; if (!reference?.startsWith(PRACTITIONER_REFERENCE_PREFIX)) return; const id = reference.slice(PRACTITIONER_REFERENCE_PREFIX.length); if (!id) return; const display = participant.actor?.display?.trim(); if (display && !map.get(id)) { map.set(id, display); } else if (!map.has(id)) { map.set(id, undefined); } }); }); return map; }, [appointmentsList]); // Resolve a live Practitioner name for any candidate whose appointment participant had no // actor.display, mirroring MemberIdentity's fetchPractitioner fallback. useEffect(() => { if (!open || mode !== "practitioner") return; const idsToResolve = Array.from(practitionerDisplaysById.entries()) .filter(([id, display]) => !display && resolvedNames[id] === undefined) .map(([id]) => id); if (idsToResolve.length === 0) return; let cancelled = false; void Promise.all( idsToResolve.map(async (id): Promise<[string, string | null]> => { try { const { practitioner } = await fetchPractitioner(id); return [id, formatFhirHumanName(practitioner.name) || null]; } catch { return [id, null]; } }), ).then((results) => { if (cancelled) return; setResolvedNames((current) => { const next = { ...current }; results.forEach(([id, name]) => { next[id] = name; }); return next; }); }); return () => { cancelled = true; }; }, [open, mode, practitionerDisplaysById, resolvedNames, fetchPractitioner]); // Flat, deduped, sorted candidate list - replaces the old "pick org, then pick from its staff" // two-step flow with a single select. const practitionerCandidates = useMemo( () => Array.from(practitionerDisplaysById.entries()) .map(([id, display]) => ({ id, label: display || resolvedNames[id] || `Practitioner/${id}`, })) .sort((left, right) => left.label.localeCompare(right.label)), [practitionerDisplaysById, resolvedNames], ); const resetForm = () => { setMode("practitioner"); setPrefill(undefined); setSelectedPractitionerId(""); setSelectedRelatedPersonId(""); setAgreementFor(null); setError(null); }; const handleOpenChange = (next: boolean) => { if (!next) resetForm(); // Never carry a tick across openings - a considered agreement about one practitioner must not // silently authorize the next one. setOpen(next); }; const canSubmit = mode === "practitioner" ? Boolean(selectedPractitionerId) : Boolean(selectedRelatedPersonId); const handleInvite = async (authorization?: ReferralAuthorizationRequest) => { if (!canSubmit) return; setSubmitting(true); setError(null); try { if (mode === "practitioner") { // Sent bare first: only the server can tell whether the patient's care already covers this // practitioner. When it does not, it answers 428 and the patient is asked before a resend. await inviteMember(teamId, { memberRef: `Practitioner/${selectedPractitionerId}`, memberClass: "practitioner", ...(authorization ? { authorization } : {}), }); } else { await inviteMember(teamId, { memberRef: `RelatedPerson/${selectedRelatedPersonId}`, memberClass: "related-person", }); } handleOpenChange(false); await onInvited(); } catch (err) { if (mode === "practitioner" && !authorization && err instanceof HttpError && err.status === 428 && (err.body as { code?: string } | undefined)?.code === "PATIENT_AUTHORIZATION_REQUIRED") { setAgreementFor(selectedPractitionerId); return; } setAgreementFor(null); if (err instanceof HttpError && err.status === 409) { setError( mode === "practitioner" ? "This practitioner is already invited to (or already on) this team." : "This related person is already on this team.", ); } else { setError( mode === "practitioner" ? "Could not send the invitation. Try again." : "Failed to add this related person. Please try again.", ); } } finally { setSubmitting(false); } }; return ( <> {/* THE DECK'S WORDING, AND IT IS NOT A SYNONYM. A collaboration team holds a spouse and a cardiologist; `member` is the system's word for both of them and the patient's word for neither. `someone` is what a person thinking about who else should be here would say. Ghost blue rather than filled: the filled action on this page is the one that changes the record, and adding somebody to a team goes through a dialog that asks first. */} Invite to this collaboration team Invite a practitioner you have a past or upcoming appointment with — they'll need to accept. Or add a related person you've already added to your account; they'll join this team right away. setMode(value as InviteMode)}> Practitioner {/* Kept visible on purpose — it signals the capability is coming rather than silently omitting it, and it is where the feature reappears when unblocked. */} Outside practitioner {!OUTSIDE_PRACTITIONER_INVITE_ENABLED && ( Coming soon )} Related person {appointmentsLoading ? (
) : practitionerCandidates.length === 0 ? (

You haven't had an appointment with a practitioner yet.

) : ( )}
{/* Rendered only when enabled: a disabled trigger stops the tab being SELECTED, but the content would still mount, leaving a live invite form one devtools edit away. */} {!OUTSIDE_PRACTITIONER_INVITE_ENABLED ? (

Inviting a practitioner from outside your collaboration team is coming soon.

) : ( { handleOpenChange(false); await onInvited(); }} /> )}
{relatedPersonsLoading ? (
) : relatedPersonsList.length === 0 ? (

You haven't added any related persons yet. Add one from Related Persons first.

) : ( )}
{error &&

{error}

} {/* THE OUTSIDER'S AGREEMENT USED TO BE RAISED HERE, AND EVERY PERSON THIS DIALOG CAN OFFER IS A COLLEAGUE. Spencer ruled on 2026-09-06 that the three agreements are with the ORGANIZATION, so inviting a same-organization practitioner asks the patient for no new agreement and the build asking for one was a regression. Walked 2026-09-05: a practitioner from the patient's own practice raised the identical consent, the same three bullets and the same tick, as a stranger. AND IT WAS NOT SOMETIMES WRONG - IT WAS ALWAYS WRONG ON THIS PATH. The candidates come from the patient's own appointment history, and that read is organization-trapped on the server, so every practitioner this picker can ever offer is one the patient already has an authorization with. The population shown the consent was exactly the population the ruling says must not be asked. THE CROSS-ORG CASE HAS ITS OWN DOOR and keeps its own agreement: an outside practitioner is invited by email or NPI through InviteOutsidePractitioner, which is where somebody genuinely new to the patient's record is asked about. The server does not take the client's word for any of this. It refuses an invite for a practitioner the patient has no appointment with at this organization, and that refusal is what establishes the agreement rather than this tick ever having done so. */} {/* NO CANCEL, AND NOTHING REPLACES IT. The button standard forbids one in a modal, and the patient portal's own modal decks have never drawn one - measured across both, `self-booking-modal.html` and `task-modal.html` contain zero occurrences of the word. Their footers carry the ACTION, plus a Back where the dialog has steps. The way out is the one the dialog already has: the close control, the scrim and Escape. A Cancel is a second way to say the same thing, standing beside the one thing the dialog is for and competing with it for the eye. AND THE ACTION NAMES ITS OBJECT rather than being a bare verb. Both decks write the verb and the thing it acts on; `Add` on its own is the shape they avoid, and the trigger that opened this says `Invite someone`. */}
candidate.id === agreementFor)?.label} busy={submitting} agreeLabel="Agree and send invite" onAgree={() => void handleInvite(buildReferralAuthorization())} onCancel={() => setAgreementFor(null)} /> ); }; export default InviteMemberDialog;