// Pull one of the patient's collaboration teams INTO a visit already in progress - the last piece of // spec §1.2: a collaboration visit is the same path as a 1:1, still an Encounter for one patient, with // more participants. // // THIS IS A DISCLOSURE EXPANSION, and every behaviour here follows from that. The server audits it as // one (design §5.5). It is chosen before it fires, it never claims more reach than it got, and a refusal // does not say which condition failed. // // (Spencer's option (a), 2026-08-02) - THE PRACTITIONER NAMES THE TEAM. A patient may own // several purpose-specific teams (cardiac, rehab, mental health); the server used to CHOOSE one via a // PatientIndex query whose keys have no tiebreaker, so a cardiac visit could notify and admit the // mental-health team. The client now sends a `teamId` and the server 400s without one. // // SO THE ONE THING THIS COMPONENT MUST NEVER DO IS PICK FOR THE USER when there is more than one // candidate - that would move the arbitrary pick from the server into here and change nothing. Every // candidate is listed and each is invited by its own explicit action, including when there is exactly // one: a single row is also the confirmation step, so the two cases need no separate code path. // // THE CANDIDATE SET IS THE CALLER'S TEAMS, WHICH IS A REAL LIMIT, NOT AN OVERSIGHT. `listTeams()` // returns teams the CALLER is on, so a practitioner in the visit who is on none of this patient's teams // sees the empty state even though the server would let them invite. That is the safe direction - you // should not fan a live PHI disclosure at a team you are not part of and cannot see - and it is why the // empty state says what is actually true rather than implying the patient has no team. import { useEffect, useRef, useState } from 'react'; import { Users } from 'lucide-react'; import { useVideoServiceContext } from '../../services/VideoService'; // The MODULE, not the `../../services` barrel. Every other file in this folder is barrel-free, and // importing it here would drag the whole service graph into the video chunk - and into the scoped // type-check, which the barrel makes unusable (). import { useCollaborationTeamServiceContext } from '../../services/CollaborationTeamService'; import type { InviteTeamResult } from '../../services/video/videoContract'; import { onViewerChange } from '../../utils/writeThroughHold'; interface Props { /** The visit's subject patient. Without it there is no candidate set to filter against, and offering * "every team you are on" would be exactly the wrong answer - so the control does not render. */ patientId?: string; } interface Candidate { teamId: string; name: string } type Outcome = | { kind: 'ok'; sessionId: string; reached: number; missed: number; /** `pending`/`unconfirmed`/`noticeUnconfirmed` are optional (P3): an older server sends * none of them, and that must read as today's unqualified success, never as this team's * new findability guarantee. */ discovery?: 'known' | 'pending' | 'unconfirmed'; noticeUnconfirmed?: string[]; } | { kind: 'refused' } | { kind: 'failed' }; type Owed = 'pending' | 'unconfirmed'; const owedOf = (discovery?: string): Owed | undefined => discovery === 'pending' || discovery === 'unconfirmed' ? discovery : undefined; export const InviteCareTeamButton: React.FC = ({ patientId }) => { const { call, inviteTeam, repairDiscovery } = useVideoServiceContext(); const { listTeams } = useCollaborationTeamServiceContext(); const [picking, setPicking] = useState(false); const [loading, setLoading] = useState(false); const [candidates, setCandidates] = useState(null); const [loadFailed, setLoadFailed] = useState(false); const [busyTeamId, setBusyTeamId] = useState(null); const [outcome, setOutcome] = useState(null); const [repaired, setRepaired] = useState(null); // The call's discovery debt, seeded by its create and raised by any owed answer. Only a repair answering // known settles it; a team invite's own answer accounts for that team, never the call's roster. const [debt, setDebt] = useState<{ sessionId: string; owed: Owed | null } | null>(null); // A picker list belongs to its own opening as well; a reopened picker retires the earlier list only. const pickerOpening = useRef(0); // Every invite and repair belongs to the call and viewer it began in. A new call or viewer retires it, and // its answer, failure or busy state is then never shown here. const generation = useRef(0); const retire = () => { generation.current += 1; setOutcome(null); setRepaired(null); setBusyTeamId(null); setPicking(false); setLoading(false); setLoadFailed(false); setCandidates(null); }; useEffect(() => { retire(); }, [call.sessionId]); useEffect(() => onViewerChange(retire), []); // A CASE CONFERENCE IS ALREADY THE TEAM. The server answers 409 `NOT_APPLICABLE` for a // `collabteam#` session, so offering this there would train staff to click something that always // fails. Mirroring the server's rule rather than discovering it at runtime. if (!call.context?.startsWith('encounter#') || !patientId) return null; const openPicker = async () => { const mine = generation.current; const opening = ++pickerOpening.current; const current = () => mine === generation.current && opening === pickerOpening.current; setPicking(true); setOutcome(null); setLoadFailed(false); setLoading(true); try { const teams = await listTeams(); if (!current()) return; setCandidates( teams // 🚨 THIS PATIENT'S TEAMS ONLY. `listTeams()` spans every patient the caller has a // team for; offering another patient's team would let a practitioner fan "a visit is // happening right now" at an unrelated care team. The server refuses it (it pins the // named team to the encounter's subject), but a control whose outcome is a guaranteed // 404 should not exist. .filter((t) => t.patientId === patientId) // The server's own `canWrite` semantics: active or dormant may be invited, dissolved // may not — dissolving the team is the patient's revocation lever for exactly this // cross-org disclosure. .filter((t) => t.status === 'active' || t.status === 'dormant') .map((t) => ({ teamId: t.teamId, name: t.name })), ); } catch { if (!current()) return; setLoadFailed(true); setCandidates(null); } finally { if (current()) setLoading(false); } }; const initialOwed = owedOf(call.findability?.discovery); const owed = call.sessionId ? (debt?.sessionId === call.sessionId ? debt.owed : initialOwed) ?? undefined : undefined; const notificationReport = outcome?.kind === 'ok' ? outcome : repaired?.kind === 'ok' ? repaired : null; const attempt = async ( busy: string, send: () => Promise, sessionId: string, isRepair: boolean, ) => { const mine = generation.current; const owedAtSend = initialOwed; const report = isRepair ? setRepaired : setOutcome; setBusyTeamId(busy); (isRepair ? setOutcome : setRepaired)(null); try { const result = await send(); if (mine !== generation.current) return; const answered = owedOf(result.discovery); setDebt((d) => { const prior = d?.sessionId === sessionId ? d.owed : owedAtSend ?? null; if (isRepair) return { sessionId, owed: result.discovery === 'known' ? null : answered ?? prior }; return { sessionId, owed: answered ?? prior }; }); report({ kind: 'ok', sessionId, reached: result.reached, missed: result.missed.length, discovery: result.discovery, noticeUnconfirmed: result.noticeUnconfirmed, }); setPicking(false); } catch (e: any) { if (mine !== generation.current) return; // 403 is "you may not do this"; anything else is "it did not get there". report({ kind: e?.status === 403 ? 'refused' : 'failed' }); setPicking(false); } finally { if (mine === generation.current) setBusyTeamId(null); } }; const run = async (teamId: string) => { const sessionId = call.sessionId; if (sessionId) await attempt(teamId, () => inviteTeam(teamId), sessionId, false); }; // The repair names the session its standing came from, and no team: it completes what that call already // made and never becomes a fresh selection. const repair = (sessionId: string) => attempt('repair', () => repairDiscovery(sessionId), sessionId, true); return (
{picking ? (
{loading && (

Loading collaboration teams…

)} {!loading && loadFailed && (

Could not load this patient’s collaboration teams. Nobody was notified — try again.

)} {/* The honest empty state: it says what is actually true — that the CALLER is on no usable team for this patient — rather than implying the patient has none. */} {!loading && !loadFailed && candidates?.length === 0 && (

You are not on a collaboration team for this patient, so there is none to notify.

)} {!loading && !loadFailed && !!candidates?.length && ( <>

Notify which collaboration team about this visit?

{candidates.map((c) => (
{c.name}
))} )}
) : ( )} {/* 🔴 THE REPORT IS THE POINT, not a nicety. `missed` is RETURNED rather than swallowed (§7) because a missed `call.available` HAS NO SECOND PATH — unlike a message it does not reappear on a refresh. A practitioner told "invited" would sit waiting for colleagues who were never reached at all, which is the exact failure this wording exists to prevent. */} {outcome?.kind === 'ok' && outcome.missed > 0 && (

Notified {outcome.reached} of {outcome.reached + outcome.missed} — {outcome.missed} could not be reached. They will not know about this visit.

)} {/* P3: discovery the call still owes, from its create or any invite. Never "resend": the repair completes discovery for the invitations this call made, not a second notification. */} {owed && (
{owed === 'pending' ? 'Some people may not be able to find the call yet.' : 'Could not confirm everyone can find the call.'}
)} {/* A repair's own answer. A lost or refused one proves nothing about who was already told. */} {repaired?.kind === 'ok' && repaired.missed > 0 && (

Notified {repaired.reached} of {repaired.reached + repaired.missed} — {repaired.missed} could not be reached. They will not know about this visit.

)} {repaired?.kind === 'ok' && repaired.missed === 0 && repaired.reached > 0 && !owedOf(repaired.discovery) && !repaired.noticeUnconfirmed?.length && (

Collaboration team notified ({repaired.reached}).

)} {repaired?.kind === 'ok' && repaired.missed === 0 && repaired.reached === 0 && repaired.discovery === 'known' && !repaired.noticeUnconfirmed?.length && (

Everyone invited can now find the call.

)} {repaired?.kind === 'refused' && (

You cannot make this call findable.

)} {repaired?.kind === 'failed' && (

Could not confirm the call is findable yet. Try again.

)} {/* One or more notices were admitted but their outcome is unknown — those refs are not counted as notified. */} {!!notificationReport?.noticeUnconfirmed?.length && (

Could not confirm some notifications.

{notificationReport.missed === 0 &&

Confirmed notifications: {notificationReport.reached}.

}
)} {outcome?.kind === 'ok' && outcome.missed === 0 && outcome.reached > 0 && outcome.discovery !== 'pending' && outcome.discovery !== 'unconfirmed' && !outcome.noticeUnconfirmed?.length && (

Collaboration team notified ({outcome.reached}).

)} {/* "Invited 0" reads as success. Nobody was reached because there was nobody to reach, and saying so is the only honest report — the practitioner may need to add someone. */} {outcome?.kind === 'ok' && outcome.missed === 0 && outcome.reached === 0 && !outcome.noticeUnconfirmed?.length && (

Nobody was notified — this team has no other members to notify.

)} {/* 🚨 A PLAIN REFUSAL THAT DOES NOT ENUMERATE WHY (card 073's anti-oracle convention). The server now refuses a nonexistent team and another patient's team IDENTICALLY, so there is deliberately nothing here to relay. */} {outcome?.kind === 'refused' && (

You cannot notify that collaboration team about this visit.

)} {outcome?.kind === 'failed' && (

Could not notify the collaboration team. Nobody was notified — try again.

)}
); };