// Which door this encounter's video control opens - and the whole point is that ONE of the four // answers is a route the server is guaranteed to refuse. // // THE DEFECT (live two-party visit, 2026-08-15): the pane offered "Start video" whenever no call // was running here, so a practitioner who had left their own still-running visit pressed a control // that calls `POST /R4/video-session` **409 `CONTEXT_TAKEN`**, *"A live session already exists for // this context"*. The re-entry route (`POST /{id}/join`) answered 200 in the same minute on the same // token. The server was never refusing the clinician; it was refusing a request to CREATE a second // session for a context that already had one. // // WORSE THAN THE PATIENT LOCKOUT IT MIRRORS, NOT MILDER: the practitioner is the only party who can // restart a visit, so when THEY are locked out nobody can recover it - the patient waits in a room the // clinician cannot re-enter, and the only message on offer is developer-facing. // // Pure, so the enumeration is cheap to check and nobody has to re-reason about it from a component. import { encounterContext, type VideoContext } from '../../services/video/videoContract'; import type { AvailableMeeting, CallPhase } from '../../services/video/callMachine'; // THE SECOND DEFECT, walked 2026-09-14 on the finished visit from journey C11: the enumeration // had no notion of the encounter's STATUS, so a FINISHED video visit fell through to `start` and // drew a full-width "Start video" beside a status chip reading Finished. Pressing it raised // **409 `ENCOUNTER_NOT_IN_PROGRESS`**, *"The visit has not been started"*. Same shape as the // 409 above and the same cost: a control that looks like the way into a visit, and is not. // // MY OWN TEST SAID THIS WAS FINE AND IT COULD NOT HAVE CAUGHT IT: it asserted `kind: 'start'` // after a finished visit and called that "renders no entry at all" in its comment. `start` is // not no-entry - it is the button. The browser is what disproved it. export type EncounterVideoEntry = /** This encounter's visit is running for this user right now - there is no entry to offer. */ | { kind: 'live' } /** Nothing exists for this context: creating one is correct. */ | { kind: 'start' } /** A session is live for this context and this user has not been in it. */ | { kind: 'join'; sessionId: string } /** A session is live for this context and this user has already been in it. */ | { kind: 'rejoin'; sessionId: string } /** There is no live session AND this encounter cannot open one - so there is no door at all. * The reason travels with it because the two reasons are different sentences to a clinician: * a visit that is over is not a visit that has not begun. */ | { kind: 'none'; reason: 'visit-is-over' | 'visit-has-not-started' | 'previsit-has-passed' } /** A previsit whose door has not opened yet; `opensAt` is when it will. */ | { kind: 'none'; reason: 'opens-later'; opensAt: string }; interface Args { encounterId: string; phase: CallPhase; /** The context of the call this user is IN, if any. */ callContext?: VideoContext; /** The meeting on offer, from the WebSocket push, the late-joiner lookup, or the re-offer a * departure makes when the visit outlived it. One slot, three writers, one shape - deliberately. */ available?: AvailableMeeting; /** The encounter's own FHIR status. Optional, and absent reads as "do not know" rather than * "closed": an unknown status keeps today's behaviour instead of silently withdrawing the * control from an open visit, which would be the worse of the two failures. */ encounterStatus?: string; /** A virtual visit that has not started: the server's previsit door admits it. */ previsit?: boolean; /** The previsit's booked slot, when known. Unknown keeps the offer, as an unknown status does. */ previsitSlot?: { start?: string; end?: string }; /** Now, in milliseconds; passed in so the enumeration stays pure. */ now?: number; } const PREVISIT_STATUSES = ['planned', 'arrived', 'triaged']; // The server's previsit door, `videoSessionGate.previsitAllows`: open from 15 minutes before the booked start // until 30 minutes after its end. Outside it a create is refused 409 `ENCOUNTER_NOT_IN_PROGRESS`. export const PREVISIT_OPENS_BEFORE_MS = 15 * 60 * 1000; export const PREVISIT_CLOSES_AFTER_END_MS = 30 * 60 * 1000; const previsitDoor = (slot?: { start?: string; end?: string }) => { const start = Date.parse(slot?.start ?? ''); const end = Date.parse(slot?.end ?? ''); return { opens: Number.isNaN(start) ? undefined : start - PREVISIT_OPENS_BEFORE_MS, closes: Number.isNaN(end) ? undefined : end + PREVISIT_CLOSES_AFTER_END_MS, }; }; /** The next moment the previsit door opens or closes after `now`, so a screen can change its offer then. */ export const nextPrevisitChange = (slot: { start?: string; end?: string } | undefined, now: number): number | undefined => { const { opens, closes } = previsitDoor(slot); return [opens, closes].find((at): at is number => at !== undefined && at > now); }; /** * THE SERVER'S RULE, MIRRORED RATHER THAN GUESSED AT: `videoSessionGate.evaluateEncounterVideoCreate` * refuses any create whose encounter is not `in-progress`. So this is not a tightening of our own * invention - it is the client stopping short of a route the server is certain to refuse. */ const canOpenAVisit = (status?: string, previsit = false) => status === undefined || status === 'in-progress' || (previsit && PREVISIT_STATUSES.includes(status)); export function encounterVideoEntry({ encounterId, phase, callContext, available, encounterStatus, previsit, previsitSlot, now = Date.now() }: Args): EncounterVideoEntry { const context = encounterContext(encounterId); // Anchored on `idle` rather than listing the live phases, so a phase added later fails SAFE - it // keeps reporting live instead of offering a second entry into a call already coming up. const inACall = phase !== 'idle'; if (callContext === context && inACall) return { kind: 'live' }; // SCOPED TO THIS ENCOUNTER'S CONTEXT, and that is not tidiness. There is one `available` slot // app-wide; an unscoped read would let a meeting offered for patient B capture patient A's start // button, walking the practitioner into the wrong visit. That is a PHI exposure, not a wrong label. if (available?.context === context) { // Absent `returning` reads as "first invitation" - never as rejoin. It is SERVER-derived // (the caller's own `leftAt`) because a reload destroys whatever the client knew, and a reload // is precisely the case the grace window exists for. Same rule the patient's banner follows. return { kind: available.returning ? 'rejoin' : 'join', sessionId: available.sessionId }; } // DELIBERATELY AFTER the two branches above, and that ordering is the whole safety argument. // A status change must never withdraw a way back into a room that is STILL OPEN: the server's // JOIN gate does not check status, for the stated reason that a participant already in the room // must not be thrown out by one - so if a live session for this context somehow outlives the // encounter, the only person who can end it is the one holding this button. Withdrawing it // there would strand the patient, which is the harm the whole card is about. // What IS withdrawn is the CREATE, because that one the server will certainly refuse. if (!canOpenAVisit(encounterStatus, previsit)) { const over = encounterStatus === 'finished' || encounterStatus === 'cancelled' || encounterStatus === 'entered-in-error'; return { kind: 'none', reason: over ? 'visit-is-over' : 'visit-has-not-started' }; } // A previsit is created only through its door, so outside it Start would be refused; say when it opens. if (previsit && encounterStatus !== 'in-progress') { const { opens, closes } = previsitDoor(previsitSlot); if (opens !== undefined && now < opens) return { kind: 'none', reason: 'opens-later', opensAt: new Date(opens).toISOString() }; if (closes !== undefined && now > closes) return { kind: 'none', reason: 'previsit-has-passed' }; } return { kind: 'start' }; }