export type MeetingPanel = 'video' | 'encounterNote' | 'patientNotes' | 'chart' | 'messages' | 'participants'; export type MeetingViewerRole = 'practitioner' | 'patient' | 'relatedPerson' | 'unknown'; export interface MeetingPanelInputs { contextKind: 'encounter' | 'collabteam' | 'unknown'; viewerRole: MeetingViewerRole; isHost: boolean; hasOrg: boolean; /** A video visit the server has not started yet: nothing is written against it until both are in. */ previsit?: boolean; } /** * THIS IS A DISCLOSURE BOUNDARY, NOT A LAYOUT PREFERENCE. The encounter note carries a private mode * labelled "Private - org only, not shown to the patient". A panel a viewer may not have is NEVER * RETURNED here - it must not be rendered-then-hidden, because a CSS or conditional-render bug then * shows a patient the practitioner's private note. * * What "fails closed" means here, exactly * An UNKNOWN CONTEXT yields `['video']` and nothing else. Not just no notes - no messages AND no * participants either. `participants` used to be pushed unconditionally while the JSDoc claimed the * function failed closed, so a context this resolver could not identify still handed back a NAMED * ROSTER of everyone in the meeting (`MeetingParticipantsPanel` resolves `externalUserId`s to real * Patient/Practitioner names). "Fails closed" now means what it says: if we cannot say what kind of * room this is, we do not say who is standing in it. * An UNKNOWN VIEWER ROLE yields no NOTES panel. It is deliberately NOT stripped of video/messages/ * participants: whoever they are, they are already IN the meeting - the SDK admitted them and their * audio and video are live - so hiding the roster from them protects nothing while removing the only * signal of who else is present. The note is different: it carries content they may not read. * * `video` is seeded unconditionally and stays that way - it is the surface itself, and * `MeetingWindow` deliberately keeps the Leave control OUTSIDE the panel loop for exactly that reason. */ /** * Show from record is the host's, in a visit only. The server applies the same rule; this only * avoids offering a control it would refuse. */ export const mayShowFromRecord = (i: MeetingPanelInputs): boolean => i.contextKind === 'encounter' && i.viewerRole === 'practitioner' && i.isHost && i.hasOrg; export function resolveMeetingPanels(i: MeetingPanelInputs): MeetingPanel[] { const panels: MeetingPanel[] = ['video']; if (i.contextKind === 'encounter' && i.viewerRole === 'practitioner') { // The HOST is the practitioner holding the active-encounter slot for this room, so they have an // Encounter to write against. A non-host has none - hence the patient-scoped note instead. // A host's video visit that has not started has nothing to write against yet, so no note at all. if (i.isHost) { if (!i.previsit) panels.push('encounterNote'); } // ✅ CARD 192 CLOSED THE `hasOrg` GATE THAT USED TO BE HERE. Patient notes were hard-scoped to // an organization on both read and write server-side (`Basic/post.ts` and `get.ts` both 403'd // without one), so an org-less collaborator was given NO notes panel rather than one that // refused on save. The server now scopes such a note to the AUTHOR's own Practitioner id // instead, so the panel works for them — their notes are private to them by construction, and // the visibility control is absent rather than defaulted (Spencer, 2026-08-07). // ⇒ infra `utils/orgNote.ts` (resolveNoteScope / noteMatchesScope). // 📌 `hasOrg` is still an input because callers pass it and it remains "does this viewer have an // org AT ALL" — it simply no longer withholds this panel. else panels.push('patientNotes'); // Host only: the chart reads the ambient patient, which for anyone else is another visit's. // Never a patient or related person, and never without an organization to read it through. if (i.isHost && i.hasOrg) panels.push('chart'); } // ONE gate for both, deliberately: a message thread and a participant roster are the same kind of // claim - "these people are in this room" - and an unidentifiable room cannot support either. if (i.contextKind !== 'unknown') { panels.push('messages'); panels.push('participants'); } return panels; }