// The REAL broker transport. Every route and every response shape below is transcribed from the // SERVER design doc §3.1 (`akashic-docs/e-visit/telehealth-video-server-implementation-design.md`), // which is the authoritative contract for this seam. Two things in the split plan are stale and were // NOT followed: it lists `GET /R4/video-session/available` (superseded by the collection route - see // `listAvailable` below) and types `/end` as a full session record (it returns `{sessionId, status}`). // // CONTROL PLANE = OPTION A: HTTP in, WebSocket out. There is deliberately NO socket dependency in // this file - inbound pushes are subscribed in `VideoService`, and there is no client server WS // traffic at all. A refusal arrives here as an `HttpError` with a real status code, which is strictly // more information than the `send() === false` it replaced (that only ever said "socket down"). import { useMemo } from 'react'; import { environment } from '../../environments/environment'; import { useHttpService } from '../HttpService'; import type { LeaveOptions, VideoTransport } from './VideoTransport'; import type { AvailableSession, CreateSessionResult, InviteTeamResult, JoinSessionResult, RemoveParticipantResult, SessionStatusResult, ShareItemResult, VideoContext, VideoSessionRecord, } from './videoContract'; interface Deps { get: (url: string) => Promise; post: (url: string, data: any, options?: LeaveOptions) => Promise; /** `environment.fhirBaseApi` in the app - it ALREADY ends in `/R4`. */ baseUrl: string; } /** What `GET /R4/video-session` actually puts on the wire (§3.1). The interface deliberately does not * carry this envelope: `AvailableSession[]` is the shape `call.available` pushes, and one type across * the push path and the poll path is what stops the two from drifting (§3.2). Unwrapped below. */ interface AvailableSessionsEnvelope { sessions?: AvailableSession[]; } /** Split from the hook ON PURPOSE - the wire contract is the part that can be wrong in a way no * component test can see, so it has to be checkable with no React, no jsdom and no fetch. */ export function buildVideoTransport({ get, post, baseUrl }: Deps): VideoTransport { // `fhirBaseApi` already ends in `/R4` - appending it again points at a path that does not exist // on a gateway that does, and a wrong base URL is a SILENT 404, never an error // (). The broker lives on `fhir-base` by Lane A's explicit // choice (§1: "claude-3's LiveVideoTransport posts to environment.fhirBaseApi"). const root = `${baseUrl}/video-session`; return { /** `POST /R4/video-session` `{sessionId, status, context, chime:{Meeting,Attendee}}`. * Only `context` is sent: the server derives the org, the roster and everything else from it, * and pulling the care team in is its own audited route (`inviteTeam`), not a create flag. */ createSession: (context: VideoContext) => post(root, { context }), /** `POST /R4/video-session/{id}/join` `{sessionId, chime:{Meeting,Attendee}}`. A FRESH * attendee per join; the gate is re-derived server-side from live state every time (§4.2). */ joinSession: (sessionId: string) => post(`${root}/${sessionId}/join`, {}), /** `POST /R4/video-session/{id}/leave` `{sessionId, status}`. * * A DIFFERENT ROUTE FROM `/end`, and the distinction is the defect Lane A caught in this * client (§3.3): routing "Leave" at `/end` meant a patient leaving did a Chime `DeleteMeeting` * and tore the meeting down for their own doctor, unrecoverably. `/leave` marks this caller * gone; the SERVER ends the meeting only when the last participant is out, and says which * happened in `status` (`'active'` = others are still in the room). */ leaveSession: (sessionId: string, options?: LeaveOptions) => options ? post(`${root}/${sessionId}/leave`, {}, options) : post(`${root}/${sessionId}/leave`, {}), /** `POST /R4/video-session/{id}/end` `{sessionId, status:'ended'}`. HOST ONLY - the route * is `PRACTITIONERS` and the handler additionally checks the caller IS the host, so a non-host * gets a 403. Always a `DeleteMeeting`: it is over for everyone and is not recoverable. */ endSession: (sessionId: string) => post(`${root}/${sessionId}/end`, {}), /** `POST /R4/video-session/{id}/remove` -> `{sessionId, participantRef, removed:true}`. Host * only. Ejects one other person; the meeting keeps running. Never route a departure here, and * never route this at `/end`. */ removeParticipant: (sessionId: string, participantRef: string) => post(`${root}/${sessionId}/remove`, { participantRef }), /** `POST /R4/video-session/{id}/share` -> `{sessionId, share}`. Host only; `null` stops. */ shareItem: (sessionId: string, reference: string | null) => post(`${root}/${sessionId}/share`, { reference }), /** `POST /R4/video-session/{id}/invite` `{sessionId, reached, missed[]}`. A disclosure * expansion, audited as one server-side (§5.5). `missed` is RETURNED rather than swallowed * (§7) precisely because a missed `call.available` has no second path - unlike a message, it * does not reappear on refresh - so "could not invite the care team" must be honest. */ /** - `teamId` names WHICH of the patient's collaboration teams to pull in. The server * 400s without it rather than choosing one, which is the whole point: the old derivation had no * tiebreaker across a patient's teams. */ inviteTeam: (sessionId: string, teamId: string) => post(`${root}/${sessionId}/invite`, { teamId }), /** The same route's one other action, exactly `{repair:'discovery'}`: no team is named or chosen. */ repairDiscovery: (sessionId: string) => post(`${root}/${sessionId}/invite`, { repair: 'discovery' }), /** `GET /R4/video-session` (the COLLECTION route) `{sessions:[...]}`. * * GET, not POST: a POST to a GET route returns 403 "Missing Authentication Token", which * reads like a broken deploy rather than a client bug. * The collection route, NOT `/video-session/available` (§3.1). A literal `/available` * segment collides with the `{id}` path parameter - API Gateway resolves the literal first but * `routes.ts`'s own matcher may resolve it to the `{id}` entry and apply THE WRONG * AUTHORIZATION CONFIG. The split plan still says `/available`; it is stale. * * An absent envelope yields `[]` rather than throwing: this feeds a join affordance that may * poll, and "nothing to join" is the safe reading - a crash in a poll loop is worse than a * missing banner. */ listAvailable: async (): Promise => { const body = await get(root); return body?.sessions ?? []; }, /** `GET /R4/video-session/{id}` the session record. GET, not POST - same 403 trap as above. */ getSession: (sessionId: string) => get(`${root}/${sessionId}`), }; } /** The app-side wiring. `useHttpService` attaches `Authorization` **and `X-Organization-ID`** to every * call - the latter is not optional: without it the authorizer resolves `roles: []` and denies * everything ( §0). */ export const useLiveVideoTransport = (): VideoTransport => { const { get, post } = useHttpService(); return useMemo( () => buildVideoTransport({ get, post, baseUrl: environment.fhirBaseApi }), [get, post], ); };