import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { YourVisitAnswer } from './YourVisitAnswer'; import { acceptIsClosed, acceptIsUnfinished, visitTimePhase, acceptedSentence, visitIsOver, visitIsHistory, historySentence, cleanDeclineReason, noOneToInviteSentence, readAnswerRefusal, refusalSentence, type VisitSheetAudience } from '../../utils/visitInvitationWords'; import { Loader2, ShieldCheck, UserPlus, Users } from 'lucide-react'; import { useAppointmentServiceContext } from '../../services/AppointmentService.tsx'; import { Badge } from '../ui/badge.tsx'; import { Checkbox } from '../ui/checkbox.tsx'; import { DismissibleAlert } from '../ui/dismissible-alert.tsx'; import { Label } from '../ui/label.tsx'; import { Separator } from '../ui/separator.tsx'; import { RadioGroup, RadioGroupItem } from '../ui/radio-group.tsx'; import { onViewerChange, viewerEpoch } from '../../utils/writeThroughHold'; import { useVisitClock } from '../../hooks/useVisitClock'; import { useOptionalWebSocketServiceContext } from '../../services/WebSocketService.tsx'; import type { VisitInviteeNotice, VisitInviteeView, VisitInvitationView, } from '../../types/visitInvitation.ts'; import { useFhirReferenceNames } from '../../hooks/useFhirReferenceNames.ts'; // The rule and the labels come from the module that already owns them for the video roster - // "NEVER RENDER THE RAW REFERENCE" is written there in capitals. Imported, never re-derived. import { PARTICIPANT_NEUTRAL_LABEL, parseParticipantReference, type ResolvableParticipantType, } from '../video/participantIdentity.ts'; import { mayHaveBeenWritten, relayServerFailure } from '../../utils/fetchFailureMessage'; import { DemoVisitRoleContext } from '../../pages/Demo/DemoRoleSwitch'; // - the two-party negotiation over who may JOIN a telehealth visit. // // PARTICIPATION IS NOT PRESENCE. Nothing here governs who may sit in the room with the patient or the // practitioner - that is their business and always was. This panel is about someone joining as a // separately authenticated, individually audited participant, which is why it needs both sides to agree. // // THE THREE LAYERS, rendered in the order the server enforces them: // 1. the practice's switch - staff may force a strict 1:1 session, and nothing below renders while off // 2. TWO-PARTY CONSENT - one side proposes, the OTHER must approve before anything is sent // 3. each invitee answers for themselves (a separate surface - see the note at the foot of this file) // // EVERY DECISION IS THE SERVER'S. This component never computes eligibility, never decides which party // the caller is, and never marks anything released - it renders what the server returned and posts intent // back. That is deliberate: a client-side gate is not a gate (), // and this one guards who can see a patient's visit. /** A change stopped before it was sent because the visit is now history. */ class VisitIsHistoryError extends Error {} /** A change whose panel, appointment or viewer has moved on: nothing more is sent or said for it. */ class ChangeRetiredError extends Error {} interface VisitInvitationPanelProps { appointmentId: string; /** Optional display resolver - the server returns refs + class only, never names. */ resolveDisplayName?: (ref: string) => string | undefined; className?: string; /** * - fired after a mutation that SUCCEEDED, so a parent holding its own copy of the same * facts can re-derive. `MyVisitInvitations` mounts this panel inside each of its rows and counts * invitations in its header; without this, removing the last invitee left that header counting an * invitation that no longer existed until a real page reload. * Deliberately not fired on failure - see the call site. */ onChanged?: () => void; /** Who is reading, for the words only; the practice's wording when not given. */ audience?: VisitSheetAudience; } const classLabel = (memberClass?: string) => memberClass === 'related-person' ? 'Family / caregiver' : memberClass === 'practitioner' ? 'Clinician' : undefined; /** * What a row says while we are still asking who someone is. * * A SEPARATE STATE FROM "we asked and got nothing", deliberately. Collapsing the two either * announces an unknown before anyone has looked, or leaves a resolved-as-nameless row sitting on a * placeholder forever. */ const RESOLVING_LABEL = 'Loading…'; /** * The name to show for a care-team reference - and it is NEVER the reference. * * THIS PANEL RENDERED RAW UUIDs. `displayFor` fell back to `ref.split('/')[1]`, and no call site * ever passed the optional `resolveDisplayName` - all three of them - so the front desk was asked to * invite a colleague by picking from **four 36-character ids**, each labelled only "Clinician". A * screen-reader user heard the id read out digit by digit, and the toast said *"336664c5-9b28-... has * been invited."* * * The fallback chain ends in a stated, PHI-free label - the rule and the labels both come from * `participantIdentity.ts`, which already says NEVER RENDER THE RAW REFERENCE in capitals and is * imported here rather than re-derived. */ const neutralLabelFor = (ref: string): string => { const parsed = parseParticipantReference(ref); if (parsed && parsed.type in PARTICIPANT_NEUTRAL_LABEL) { return PARTICIPANT_NEUTRAL_LABEL[parsed.type as ResolvableParticipantType]; } return 'Someone from the collaboration team'; }; // The approval succeeded; these sentences only say who the content-free email could not reach and why, // as the server reports it ('no-address' or 'send-failed'), never claiming delivery. const undeliveredNotice = (notified?: VisitInviteeNotice[]): string | undefined => { const missing = (notified ?? []).filter((entry) => entry.outcome === 'no-address').length; const failed = (notified ?? []).filter((entry) => entry.outcome === 'send-failed').length; const members = (count: number) => `${count} member${count === 1 ? '' : 's'}`; const said = [ ...(missing ? [`An invitation email address is not available for ${members(missing)}.`] : []), ...(failed ? [`The invitation email could not be queued for ${members(failed)}.`] : []), ]; if (!said.length) return undefined; return [...said, 'They will see the invitation when they next sign in.'].join(' '); }; /** * What the OTHER side still owes, phrased for the person reading it rather than as a state name. Once the visit is * history nothing is owed any more, so an unanswered row says what was recorded rather than that someone is waiting. */ const statusOf = (invitee: VisitInviteeView, released: string[], history = false): { text: string; tone: 'pending' | 'sent' | 'yes' | 'no' } => { if (history) { if (invitee.reservation && acceptIsUnfinished(invitee)) return { text: 'Accepted — time not held', tone: 'sent' }; if (invitee.response === 'accepted') return { text: 'Accepted', tone: 'yes' }; if (invitee.response === 'declined') return { text: 'Declined', tone: 'no' }; if (released.includes(invitee.ref)) return { text: 'Invited — no answer recorded', tone: 'sent' }; return { text: 'No agreement recorded', tone: 'sent' }; } // Whether another person's time is held is theirs to see: the organizer's view withholds it, and a withheld // reservation is no evidence the time is unconfirmed, so that accept reads Accepted. One the view carries is reported. if (invitee.reservation && acceptIsUnfinished(invitee)) return { text: 'Accepted — their time is not held yet', tone: 'pending' }; if (invitee.response === 'accepted') return { text: 'Accepted', tone: 'yes' }; if (invitee.response === 'declined') return { text: 'Declined', tone: 'no' }; if (released.includes(invitee.ref)) return { text: 'Invited — waiting for their answer', tone: 'sent' }; return { text: 'Waiting for the other party to agree', tone: 'pending' }; }; const OrdinaryVisitInvitationPanel = ({ appointmentId, resolveDisplayName, className, onChanged, audience, }: VisitInvitationPanelProps) => { const { fetchVisitInvitation, updateVisitInvitation, respondToVisitInvitation } = useAppointmentServiceContext(); const [view, setView] = useState(null); // The view as last rendered, for a change still waiting to be sent. const shown = useRef(view); shown.current = view; // The appointment and viewer as rendered now, before any effect has caught up with a change of either. const rendered = useRef(''); rendered.current = `${appointmentId}|${viewerEpoch()}`; const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); // An accept whose time is not held yet is not a success, so the notice carries its own tone. const [noticeTone, setNoticeTone] = useState<'success' | 'warning'>('success'); const [checked, setChecked] = useState>(new Set()); // The team the reader chose to ask from, when the patient has several and the visit has none yet. const [chosenTeam, setChosenTeam] = useState(undefined); // Which appointment the work in flight belongs to: a choice, a load or a save made for one appointment says // nothing once the panel shows another. const scope = useRef(0); // Each read is numbered; only the newest read may show its result. const reads = useRef(0); // Who is viewing (account, acted-as subject, practice): a change retires the panel's work like a new appointment. const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); // The opening this panel is for, and the opening what it shows was loaded for. They differ from the first render // after a change, before any effect runs, so that render shows only that it is loading: nothing of the last // opening (its team, roster, checks or Ask) is ever committed under the new one. const opening = `${appointmentId}|${viewer}`; const openingRef = useRef(opening); const [shownFor, setShownFor] = useState(null); const load = useCallback(async () => { const mine = scope.current; const read = ++reads.current; try { setError(null); const loaded = await fetchVisitInvitation(appointmentId); if (scope.current === mine && read === reads.current) { setView(loaded); setShownFor(openingRef.current); } } catch (loadError) { // Stated rather than swallowed: a panel that silently renders nothing is indistinguishable // from "this appointment has no invitations", which is a different and misleading fact. if (scope.current === mine && read === reads.current) { setError(relayServerFailure(loadError, 'Could not load visit invitations.')); setShownFor(openingRef.current); } } finally { if (scope.current === mine) setLoading(false); } }, [appointmentId, fetchVisitInvitation]); // After a saved change, read the view again. Only the newest read may show its result; a failed read reports // false and never undoes the save. const rereadAfterSave = useCallback(async (mine: number): Promise => { const read = ++reads.current; try { const refreshed = await fetchVisitInvitation(appointmentId); if (scope.current === mine && read === reads.current) setView(refreshed); return true; } catch { return false; } }, [appointmentId, fetchVisitInvitation]); // A new appointment, or a new viewer, retires everything shown for the last one at once: its roster, choice // and checks are not offered while the new answer loads. useEffect(() => { scope.current += 1; openingRef.current = `${appointmentId}|${viewer}`; setView(null); setLoading(true); setError(null); setChosenTeam(undefined); setChecked(new Set()); setNotice(null); setBusy(false); void load(); }, [load, viewer, appointmentId]); // Closing the panel retires its work too: nothing answered later is published or reported. useEffect(() => () => { scope.current += 1; }, []); // Another person's answer is announced content-free (appointment and revision); this opening re-reads its own // view. A failed refresh says so and keeps what was shown. const sockets = useOptionalWebSocketServiceContext(); const subscribe = sockets?.subscribe; const unsubscribe = sockets?.unsubscribe; useEffect(() => { if (!subscribe || !unsubscribe) return; const mine = scope.current; const onAnswered = (event: { data?: { appointmentId?: unknown } } | undefined) => { if (event?.data?.appointmentId !== appointmentId || scope.current !== mine) return; const read = ++reads.current; fetchVisitInvitation(appointmentId).then( (refreshed) => { if (scope.current !== mine || read !== reads.current) return; setView(refreshed); setShownFor(openingRef.current); // Fresh evidence replaces an earlier refresh's warning that the latest could not be read. setError(null); }, () => { if (scope.current !== mine || read !== reads.current) return; setError('Someone answered this invitation, but the latest could not be read. Reopen it to see the answer.'); }, ); }; subscribe(VISIT_INVITATION_ANSWERED, onAnswered); return () => unsubscribe(VISIT_INVITATION_ANSWERED, onAnswered); }, [subscribe, unsubscribe, appointmentId, viewer, fetchVisitInvitation]); const act = useCallback( async (request: Parameters[0], successMessage?: string) => { const mine = scope.current; const opening = rendered.current; const viewerAt = viewerEpoch(); let sent = false; // A change belongs to the panel, appointment and viewer it was made in, checked again at every step: the // viewer as it is now, not as last rendered. const ours = () => scope.current === mine && rendered.current === opening && viewerEpoch() === viewerAt; setBusy(true); setError(null); setNotice(null); try { // A visit that is history before the change is sent takes none, as the view already says; once sent // before its end, the change keeps its answer. const historyNow = () => Boolean(shown.current && visitIsHistory(shown.current, Date.now())); if (historyNow()) return; const result = await updateVisitInvitation(request, { admit: () => { if (!ours()) throw new ChangeRetiredError(); if (!sent && historyNow()) throw new VisitIsHistoryError(); }, onSend: () => { sent = true; }, }); // Answered after the panel moved to another appointment: nothing of it is said about that one. if (!ours()) return; // - DO NOT `setView(result)`. The type says it is safe // (`VisitInvitationWriteResult extends VisitInvitationView`) and **the server does not // honour that**: `AppointmentVisitInvitation/put.ts` returns appointmentId, allowed, // invitees, released, awaitingYourApproval, party, rejected and notified - but NEVER // `eligibleMembers`, which is computed only in `get.ts`. Storing the write response left // `eligibleMembers` undefined, so `addable` collapsed to `[]` and removing the last // invitee rendered "This patient has no care team members who can be invited to a visit." // while the server reported three. A false claim about the record, which is why this is // a card at all - the mild end of. // // A refetch rather than a merge, and the reason is not caution: the eligible set // genuinely CHANGES as a result of the mutation (a withdrawn invitee is restored to it), // so the previous view's copy is stale too. Only the server can compute it. This is the // same shape `respond()` below already uses. // The change is saved: it is reported once below whether the read that follows succeeds, fails or is // overtaken by a newer one. const reread = await rereadAfterSave(mine); if (!ours()) return; setChecked(new Set()); // Told only on SUCCESS - a parent that re-derives after a failed write convinces itself // something happened. Pinned by its own test. onChanged?.(); // The server accepts the good refs and reports the rest rather than failing the whole // call, so a legitimate invitee is never punished for a bad ref beside them - but the // caller has to be TOLD, or a name silently vanishing looks like the app losing data. // The approval SUCCEEDED - an undelivered nudge is not an error and must not read as one. // But an invitation recorded as sent with nothing delivered is exactly the evidentiary // gap -reminder-email-leg-has-no-address-check describes, and the practice is // the only party who can close it. The email is a nudge, never the mechanism: the invitee // still finds this in their own list, which is what the second half of the copy says. const undelivered = undeliveredNotice(result.notified); if (result.rejected?.length) { setError( `${result.rejected.length} selection${result.rejected.length === 1 ? ' is' : 's are'} no longer on this patient's collaboration team and ${result.rejected.length === 1 ? 'was' : 'were'} not added.`, ); } else if (undelivered) { setNotice(`${successMessage ?? 'Invitation approved.'} ${undelivered}`); } else if (successMessage) { setNotice(successMessage); } if (!reread && !result.rejected?.length) setError('Saved, but the latest could not be read. Reopen it to see it.'); } catch (actionError) { if (actionError instanceof VisitIsHistoryError || actionError instanceof ChangeRetiredError) return; if (!ours()) return; setError(sent && mayHaveBeenWritten(actionError) ? 'Whether that change was saved is not known. Reopen this visit to check before changing it again.' : relayServerFailure(actionError, 'That change could not be saved.')); } finally { if (scope.current === mine) setBusy(false); } }, [updateVisitInvitation, rereadAfterSave, onChanged], ); // The invitee's own answer. No ref is sent: the server takes the responder from the auth context, so // this call structurally cannot answer on someone else's behalf. // It sends the revision the person was looking at, so a time that moved underneath them is refused rather than // accepted unseen. What they are told afterwards comes from whether their time is actually held, never from the // appointment alone. const respond = useCallback( async (response: 'accepted' | 'declined', declineReason?: string) => { const mine = scope.current; setBusy(true); setError(null); setNotice(null); try { // Read the visit again just before answering: one cancelled meanwhile is shown as it is and not answered. // A failed read is not taken to mean cancelled; the answer is sent and the server decides. const current = await fetchVisitInvitation(appointmentId).catch(() => undefined); if (scope.current !== mine) return; if (current && visitIsOver(current.appointmentStatus)) { setView(current); return; } // The time is read now, at the moment of sending, from the visit as it was just read (or as shown, when // that read failed). Once its scheduled end has passed it takes no answer at all, a decline included: // it is history. From its start the server refuses an accept, so none is sent. Nothing more is said // here: the answer re-renders at this time and says it once, in its own place. const sending = current ?? view ?? {}; if (visitTimePhase(sending, Date.now()) === 'ended' || (response === 'accepted' && acceptIsClosed(sending, Date.now()))) { // A fresh copy, so the sheet is shown again at this time even when the read failed or came back // unchanged. const shown = current ?? view; if (shown) setView({ ...shown }); return; } const result = await respondToVisitInvitation(appointmentId, response, { revision: view?.revision, declineReason: cleanDeclineReason(declineReason), }); if (scope.current !== mine) return; // The answer is saved: confirmed and reported once below whatever becomes of the read that follows. const reread = await rereadAfterSave(mine); if (scope.current !== mine) return; if (response === 'declined') { setNoticeTone('success'); setNotice('You declined this visit.'); } else { // A result with no reservation cannot certify the hold, so it is a warning, never a finished accept. const said = acceptedSentence(result.reservation); setNoticeTone(said.tone); setNotice(said.text); } if (!reread) setError('Saved, but the latest could not be read. Reopen it to see it.'); onChanged?.(); } catch (respondError) { if (scope.current !== mine) return; const refusal = readAnswerRefusal(respondError); const said = refusalSentence(refusal); setError(refusal.code === 'OTHER' ? relayServerFailure(respondError, 'That answer could not be saved.') : said.text); // The answer was refused because what they saw is out of date: show them the current invitation. if (said.reread) { try { const current = await fetchVisitInvitation(appointmentId); if (scope.current === mine) setView(current); } catch { // The refusal is already on screen; a failed refresh must not replace it. } } } finally { if (scope.current === mine) setBusy(false); } }, [appointmentId, fetchVisitInvitation, respondToVisitInvitation, rereadAfterSave, view?.revision, onChanged], ); const invitedRefs = useMemo(() => new Set((view?.invitees ?? []).map((i) => i.ref)), [view]); // A visit asks from one team (TEAM_REQUIRED). The team in use is the visit's bound team; or, unbound, the // patient's only team with someone to ask; or the one the reader chose among several, never one chosen for them. // A bound team that is no longer available offers no one, never a member of another team. An older server // that names no teams keeps the list it gave. const teams = view?.teams; const offeredTeams = useMemo(() => (teams ?? []).filter((t) => t.eligibleMembers.length > 0), [teams]); const boundTeam = view?.teamId ? teams?.find((t) => t.teamId === view.teamId) : undefined; const bindingUnavailable = teams !== undefined && Boolean(view?.teamId) && !boundTeam; // Team members can be asked to join video visits only, which the write enforces. A visit the view says is in person, // or whose modality could not be read, offers no Ask and says why; a view that says nothing leaves it to the write. const askBlocked = view?.modality === 'in-person' || view?.modality === 'unknown'; // A cancelled, entered-in-error or ended visit is history to its organizers: shown as it was, with nothing to change. const now = useVisitClock(view ? [view] : []); const history = view ? visitIsHistory(view, now) : false; const askOff = askBlocked || history; const choosingTeam = !askOff && teams !== undefined && !view?.teamId && offeredTeams.length > 1; const teamInUse = teams === undefined ? undefined : view?.teamId ? boundTeam : offeredTeams.length === 1 ? offeredTeams[0] : offeredTeams.find((t) => t.teamId === chosenTeam); const eligible = useMemo( () => (teams === undefined ? view?.eligibleMembers ?? [] : teamInUse?.eligibleMembers ?? []).filter((m) => !invitedRefs.has(m.ref)), [teams, teamInUse, view, invitedRefs], ); // THIS ADDS NO NEW PROVIDER REQUIREMENT, and that was checked rather than assumed - a panel that // renders fine today must not start throwing because it learned to say a name. `useFhirReferenceNames` // needs the Practitioner, Patient and RelatedPerson contexts, and all three accessors throw when // absent; but `AppointmentServiceProvider` - which this panel ALREADY requires - is nested INSIDE // `PractitionerServiceProvider` and `RelatedPersonServiceProvider` in `App.tsx`'s // `BaseServiceProviders`, with `PatientServiceProvider` above both in the main pyramid. So anywhere // this component can mount at all, all three are necessarily already there. // // Every reference this panel will name, in one list, so one hook resolves them all and a person // named in two sections cannot be named two different ways. const referencedRefs = useMemo( () => [ ...(view?.awaitingYourApproval ?? []), ...(view?.invitees ?? []).map((i) => i.ref), ...(view?.eligibleMembers ?? []).map((m) => m.ref), ], [view], ); // Names read for this viewer only: another viewer's read never names, or approves, anyone for this one. const { names, attempted } = useFhirReferenceNames(referencedRefs, viewer); const displayFor = useCallback( (ref: string) => { // A caller-supplied resolver still wins - it is the surface's own, already-loaded answer. const supplied = resolveDisplayName?.(ref); if (supplied) return supplied; // The server's name for somebody on this visit, whom this caller's own read may not reach. const forApproval = view?.approvalNames?.[ref]; if (forApproval) return forApproval; const resolved = names[ref]; if (resolved) return resolved; // NEVER the raw reference. Still asking and asked-and-got-nothing are different states // and the reader is told which. return attempted.has(ref) ? neutralLabelFor(ref) : RESOLVING_LABEL; }, [resolveDisplayName, view, names, attempted], ); // // - YOU ARE NEVER ASKED TO INVITE SOMEONE WE CANNOT NAME. // // OBSERVED at the front desk: four rows, two of them reading **"A practitioner - Clinician"**, and // two `GET /R4/Practitioner/...` calls returning **403** at the same moment. A receptionist could not // tell the two apart, could not tell who they would be pulling into a patient's visit, and got no // explanation. // // THE REFUSAL IS NOT NECESSARILY THE BUG - OFFERING THEM ANYWAY IS. The roster comes from the // patient's COLLABORATION TEAMS, which are cross-organization by design, so a member this practice // genuinely may not read is an expected state rather than a fault. Two indistinguishable unnamed // rows are worse than none. // // AND THE FIX IS NOT A BETTER PLACEHOLDER. No wording makes "A practitioner" choosable when there // are two of them. They are removed from the offer and COUNTED, so the reader still knows the team is // larger than the list - an honest absence rather than an invented stranger. // // NOTHING HERE IS A PERMISSION DECISION, deliberately. `eligibleInvitees` on the server still // decides who may actually be proposed, and it is unchanged; this only stops the UI ASKING a question // the reader cannot answer. Widening the Practitioner read to "fix" the name would be the wrong // repair - inviting someone into a visit is PHI-adjacent and this sits on the consent surface. // const isNamed = useCallback( (ref: string) => Boolean(resolveDisplayName?.(ref) || names[ref]), [resolveDisplayName, names], ); // An approval invites a real person, so it needs a real name: the surface's own, the server's for this approval, // or this caller's read. A placeholder ("A practitioner") never approves; Decline needs no name. const approvalNamed = useCallback( (ref: string) => Boolean(resolveDisplayName?.(ref) || view?.approvalNames?.[ref] || names[ref]), [resolveDisplayName, view, names], ); // Only people with a name are offered. One still being asked for is held back rather than drawn // as a placeholder row, so the list only ever grows by people the reader can recognise; one asked // for and not named is counted below instead. Keyed on "finished asking", never on label text. const addable = useMemo(() => eligible.filter((m) => isNamed(m.ref)), [eligible, isNamed]); const resolvingCount = eligible.filter((m) => !isNamed(m.ref) && !attempted.has(m.ref)).length; const unnameableCount = eligible.length - addable.length - resolvingCount; if (loading || shownFor !== opening) { return (
); } if (!view) { return error ? ( setError(null)} className={className}> {error} ) : null; } const isStaff = view.party === 'practitioner'; // Slice 7 - the INVITEE's own view. Deliberately a different screen, not a variant of the // negotiation: they have exactly one decision to make and no business seeing who else was discussed. if (view.party === 'invitee') { return (
{error && ( setError(null)}> {error} )} {notice && ( setNotice(null)}> {notice} )} void respond(response, reason)} />
); } return (
{error && ( setError(null)}> {error} )} {notice && ( setNotice(null)}> {notice} )} {/* 🔴 THE VIEWER'S OWN INVITATION — when this staff member is themselves a released invitee. Found in a browser (card 114 UI spec, S4): the accept control used to be gated on `party === 'invitee'`, and the server only reports that party when the caller is NOT same-org staff. So an IN-ORG invitee — which, until akashic.health ships, is the only kind that can exist — had 32 controls on the page and no way to answer. 🚨 Keyed on `yourInvitation` being PRESENT, never on `party`. One code path then serves both populations, and the future outsider inherits a surface that has actually been exercised in a browser rather than one that has never run. Rendered ABOVE the negotiation deliberately: what is being asked of YOU outranks what you are administering for others. Their staff controls stay — hiding them would break their day job on the one appointment they were invited to, and protect nothing, since they can already see this roster on every other appointment in the practice. */} {view.yourInvitation && (

You have been asked to join this visit

void respond(response, reason)} />
)} {/* Layer 1 — the practice's switch. Staff only: a patient turning off the practitioner's ability to propose would be a veto dressed as a setting, and the patient already holds a real veto — refusing to approve. */} {history && (

{historySentence(view.appointmentStatus)}

)} {isStaff && !history && (
void act( { appointmentId, action: 'set-allowed', allowed: next === true }, next === true ? 'Collaboration team invitations enabled.' : 'This visit is now one-to-one only.', ) } />

Turn this off to keep the visit strictly between the patient and the practitioner.

)} {!view.allowed ? (

{isStaff ? 'Collaboration team invitations are turned off for this visit.' : 'This visit is one-to-one. Your practice has not enabled collaboration team invitations for it.'}

) : ( <> {/* Layer 2 — what THIS caller still owes a decision on. The server tells us; we never infer it, because "whose turn is it" is the whole two-party gate. */} {!history && view.awaitingYourApproval.length > 0 && (

{isStaff ? 'The patient asked for these people to join. They are not invited until you agree.' : 'Your practice asked for these people to join. They are not invited until you agree.'}

    {view.awaitingYourApproval.map((ref) => (
  • {displayFor(ref)} {!approvalNamed(ref) && attempted.has(ref) && ( Their name could not be shown, so they cannot be approved yet.{' '} )} {/* Declining is a WITHDRAWAL, and that is the honest verb: it removes the proposal rather than recording a "no" the other side could mistake for the invitee's own answer. Removing needs no approval — nobody objects to fewer people. */}
  • ))}
)} {view.invitees.length > 0 && (
    {view.invitees.map((invitee) => { const status = statusOf(invitee, view.released, history); return (
  • {invitee.display || displayFor(invitee.ref)} {classLabel(invitee.class) && ( {classLabel(invitee.class)} )} {/* Their own words, shown only to the organizer (the server sends it to no one else). */} {invitee.response === 'declined' && invitee.declineReason && ( Reason: “{invitee.declineReason}” )} {status.text} {!history && }
  • ); })}
)} {!history && bindingUnavailable && (

This visit’s collaboration team is no longer available, so no one else can be asked to join.

)} {askBlocked && !history && (

{view?.modality === 'in-person' ? 'Collaboration team members can be asked to join video visits only, and this visit is in person.' : 'Whether this is a video visit could not be confirmed, so no one can be asked to join right now.'}

)} {choosingTeam && (

Ask from which collaboration team?

{ // A new team starts with nobody checked: a member of the last one may not be on it. setChosenTeam(next); setChecked(new Set()); }} disabled={busy} > {offeredTeams.map((team) => (
))}
)} {!askOff && !choosingTeam && teamInUse && (

From {teamInUse.name}

)} {!askOff && (addable.length > 0 || unnameableCount > 0) && } {/* The checklist. Per-member, never per-team: the team is the ELIGIBLE SET, not the unit of invitation — fanning an entire cardiac team into a routine follow-up is noise and tells eight people about one appointment. */} {!askOff && (addable.length > 0 || unnameableCount > 0) && (
    {addable.map((member) => (
  • setChecked((previous) => { const updated = new Set(previous); if (next === true) updated.add(member.ref); else updated.delete(member.ref); return updated; }) } />
  • ))}
{/* * CARD 508 — the honest remainder. The reader is told the team is larger than * the list rather than being handed a stranger to click. * ⭐ It says WHY as far as we honestly know it — "we can't show you their * details" — and does not assert which practice they are at, because from here * a refused read and a missing record look identical. */} {unnameableCount > 0 && (

{unnameableCount === 1 ? '1 other person on this collaboration team isn’t shown here — we can’t show you their details.' : `${unnameableCount} other people on this collaboration team aren’t shown here — we can’t show you their details.`}

)} {/* * 🗣️ CARD 479 — TWO PROPERTIES THIS SENTENCE HAS TO KEEP. Spencer, 2026-08-18, * approved refining the wording before release; these are what must survive it. * * 1. ⛔ NO CONSENT VOCABULARY. *"agree"* and *"invited"* both read as a fresh * authorization, and this panel mints **no `Consent`** — `utils/visitInvitation.ts` * says so in its own words, and the roster it offers is the patient's * COLLABORATION TEAM, approved earlier. Consent words here teach a receptionist * that an authorization is being taken when none is. That is the confusion that * produced this card: a same-practice colleague appeared to need the patient's * permission twice. * 2. ⭐ NAME THIS VISIT. That phrase is the entire distinction between record access * (already granted, org-wide, and genuinely needing nothing new) and visit * participation (approved per call). The old line named neither. * * ⚖️ *"approves"* rather than *"has to agree"* deliberately: it states what happens * instead of imposing a gate, and it reads the same from the patient's side of the * panel. The heading above already says *"Ask someone from the collaboration team to join"*, * so the care-team fact needs no restating and one sentence is enough. */}

{isStaff ? 'The patient approves anyone joining this visit.' : 'Your practice approves anyone joining this visit.'}

)} {!history && view.invitees.length === 0 && addable.length === 0 && resolvingCount === 0 && !choosingTeam && !bindingUnavailable && (

{/* An empty state that names the REASON. "No one to add" would send a receptionist hunting a bug; the real cause is that this patient has no care team set up, which is a different and actionable fact. Said to the reader: the patient hears "your" team, a helper hears whose. */} {noOneToInviteSentence(audience)}

)} )}
); }; /** The content-free notice the server sends after an invitee's answer is saved (respond.ts, announceAnswer). */ export const VISIT_INVITATION_ANSWERED = 'com.akashichealthrecords.fhir.visitinvitation.answered'; export const VisitInvitationPanel = (props: VisitInvitationPanelProps) => { const inDemoVisit = useContext(DemoVisitRoleContext) !== undefined; return inDemoVisit ?

Visit invitations are unavailable in this demo.

: ; }; export default VisitInvitationPanel; // THE INVITEE'S OWN ACCEPT/DECLINE IS NOT HERE, AND THAT IS A KNOWN GAP, NOT AN OMISSION. // `PUT /R4/appointment-visit-invitation-response` exists and is tested, but an invitee has no way to // DISCOVER the invitation yet: // - the read route above refuses anyone who is not the patient or same-org staff, so a cross-org // invitee cannot load it; // - a released invitee is written onto the appointment as a bare `Practitioner/{id}` participant, and // the ORG-scoped appointment query joins on `actor=PractitionerRole/...` - their role is only added // when they ACCEPT, which they cannot do until they can see it. That is circular for exactly the // cross-org member collaboration teams exist for. // Resolving it is a product decision (a notification? a patient-scoped read? widening the read route?), // so it is recorded on rather than answered here by inventing a channel.