import { useEffect, useRef, useState } from 'react'; import { useEncounterServiceContext } from '../../services/EncounterService'; /** * - WHICH PATIENT is this visit for, resolved from the call context alone. * * THE DEFECT THIS EXISTS TO MAKE UNREACHABLE. The only patient identity otherwise reachable inside * `MeetingWindow` is the ACTIVE-ENCOUNTER slot's, and `isHost` is *defined* as "the slot's encounter is * this call's encounter" - so a NON-HOST, by construction, holds a slot for a DIFFERENT patient. Dr. B is * working Bob's encounter, is invited into Alice's visit, and joins; joining sets no slot. Rendering * `PatientNotes` from the slot would file her notes about ALICE into BOB's chart. The visit's own * `Encounter.subject` is the only sound source. * * THE IDENTITY GATE IS AT RENDER, NOT IN AN EFFECT, and it is inherited deliberately from * `useActiveEncounterPatient`, where it was paid for by a real shipped defect. The resolved id ALWAYS lags * the encounter id by at least one commit; an effect cannot repair that, because the first committed frame * is already wrong. Storing the answer WITH the question it answers - `{ encounterId, patientId }` as one * value - is what makes "this visit's panel showing another visit's patient" unrepresentable rather than * merely unlikely. * * The access gate here is CONSENT, not organization. A collaborator's `GET /R4/Encounter/{id}` is a * DERIVED grant that waives `requireOrganizationId` (`isCollaboratorReadGrant`), so what actually binds is * `requirePatientAccess` an active team membership + the patient's authorization + the collaborator's own * BAA. Membership revokes instantly from the row even though the token claim lives until expiry a refusal * is a REACHABLE state for exactly this audience, which is why `unavailable` is a first-class outcome and * not an error branch nobody expects. */ export type MeetingVisitPatientStatus = 'idle' | 'resolving' | 'ready' | 'unavailable'; /** The answer stored WITH the question, so the two can never drift apart. */ interface Resolution { encounterId: string; patientId?: string; } export const useMeetingVisitPatientId = ( encounterId: string | null, enabled: boolean, ): { patientId: string | undefined; status: MeetingVisitPatientStatus } => { const { fetchEncounter } = useEncounterServiceContext(); const [resolution, setResolution] = useState(); const token = useRef(0); useEffect(() => { // Every run claims a token; only the newest may write. Without it a slow answer for a visit the // viewer has already left overwrites the one they are looking at - the same wrong-patient defect // arriving by a different route, and the harder one to see because both fetches succeeded. const mine = ++token.current; if (!encounterId || !enabled) { setResolution(undefined); return; } void fetchEncounter(encounterId) .then((encounter) => { if (mine !== token.current) return; const reference = encounter?.subject?.reference; // Only a Patient reference yields an id. `Encounter.subject` is optional in R4 and may // point at a Group; slicing an id out of anything would hand `PatientNotes` a non-patient. const patientId = reference?.startsWith('Patient/') ? reference.slice('Patient/'.length) || undefined : undefined; setResolution({ encounterId, patientId }); }) .catch(() => { // Deliberately NOT rethrown and NOT surfaced as an error boundary: this runs inside the // tree holding Leave during a live, billed call. The caller renders `unavailable`. if (mine === token.current) setResolution({ encounterId, patientId: undefined }); }); // ⚠️ `fetchEncounter` MUST BE REFERENTIALLY STABLE (it is — `EncounterService` wraps it in // `useCallback`). If a caller ever supplies a fresh function per render, this effect re-runs, sets // state, re-renders, and loops forever — and this component holds Leave during a live, billed // call, so the failure is a locked-up call surface rather than a slow page. Cost me a hung test // file when a mock returned `{ fetchEncounter: vi.fn() }` per call; the harnesses now say so too. }, [encounterId, enabled, fetchEncounter]); // THE GATE. A resolution answers exactly one encounter; asked about any other, it yields nothing. const mine = resolution?.encounterId === encounterId ? resolution : undefined; const status: MeetingVisitPatientStatus = !encounterId || !enabled ? 'idle' : mine === undefined ? 'resolving' : mine.patientId ? 'ready' // Resolved, but there is no patient to show - a refusal, or a subject we cannot use. Distinct from // 'resolving' so the caller can say "no access" rather than rendering an empty-looking success. : 'unavailable'; return { patientId: mine?.patientId, status }; };