07-voice-and-telemedwave: W4filled31 citations

Video Telemedicine — Jitsi Rooms, JWT, and Online Visits

Audiences: doctor, clinical-buyer, developer, internal, investor

Video Telemedicine — Jitsi Rooms, JWT, and Online Visits

Self-hosted Jitsi Meet rooms minted by NestJS, joined from a Dudoxx-branded Next.js client, recorded into per-tenant MinIO — every byte of a doctor-patient video visit stays inside the clinic's infrastructure perimeter.

Business Purpose

Telemedicine is no longer optional for a German GP/specialist suite — the post-2024 Krankenkassen reimbursement codes for Video-Sprechstunde (07211-07223) make it a billable line item, and patients increasingly self-select for video consults. The Dudoxx position is unusually strong:

  • In-product, not bundled — "Start visit" inside an appointment opens a Dudoxx-branded room. The patient never sees a Zoom logo, the doctor never has to copy-paste a meeting link, and the recording lives next to the chart, not in a vendor inbox.
  • Self-hosted, not SaaS — the full Jitsi stack (signaling, media bridge, recorder, branding) runs on the clinic's infra. No DPA chain, no cross-border data transfer concern, no per-minute fee.
  • Tied to FHIR Appointment / Encounter — meetings are not free-floating; they are children of a clinical context. Cancelling the appointment invalidates the room.

Revenue lever: telemedicine is a per-seat upsell on the core HMS subscription. Replacing a third-party Zoom/Doxy.me/Teams add-on with an in-product, recorded, FHIR-linked visit removes a per-doctor per-month line item from the clinic's stack and converts it into Dudoxx ARR.

Defensibility: every competitor that ships telemed via a hosted SaaS is exposed to the same procurement objection — "where does the video actually flow?". Dudoxx answers "our hardware, your DPA, end of conversation."

Audiences

  • Investor: video is a moat and a margin driver. Owning the recorder + the JWT issuer + the branding means the entire telemed line is captured ARR with no third-party per-minute cost. The architecture also makes "ddx-telemed for partner brands" a viable resale model — the same compose stack, different branding overlay.
  • Doctor: one click inside an appointment opens the call. Recording is one click. The MP4 is in the patient chart by the time the next appointment starts. No vendor admin panel, no extra login.
  • Clinical buyer (receptionist/clinic-admin): the meeting URL is auto-generated and copied into the appointment confirmation email; no manual link-pasting. The lobby keeps the patient out until the doctor admits them.
  • Developer/partner: NestJS mints HS256 JWTs (JitsiJwtService), Jitsi Meet stable-10978 validates them via the shared JWT_APP_SECRET, the doctor portal renders the Jitsi external API in DoctorVideoClient.tsx. No Jitsi business code is forked — only branding + JWT issuer + room-naming convention.
  • Internal (ops/support): 5-container stack, prosody uid=100 permission gotcha, JWT domain mismatch is the #2 support hit. See infra-jitsi operational notes for the canonical checklist.

Architecture

The video surface area straddles three layers:

diagram
┌────────────────── ddx-web (Next.js, 6200) ─────────────────────────┐
│   /portal/doctor/video/[appointmentId] → DoctorVideoClient.tsx     │
│   /portal/doctor/appointments/[id] → AppointmentDetailClient (tab) │
│                                                                    │
│   videoApi.getJitsiConfig() → /video/jitsi-config                  │
│   videoApi.createMeeting / joinMeeting / updateStatus / endMeeting │
└────────────┬───────────────────────────────────────────────────────┘
             │  Trusted Gateway v2.1 (X-API-Key + X-Gateway-User-*)
             ▼
┌────────────────── ddx-api (NestJS, 6100) ──────────────────────────┐
│   VideoModule                                                      │
│     ├── VideoController (HTTP)                                     │
│     ├── VideoService (Prisma, room naming, lobby, recording flags) │
│     ├── JitsiJwtService (HS256 mint + verify, JWT_APP_SECRET)      │
│     └── VideoEventsPublisher (SSE on session:{id})           │
└────────────┬───────────────────────────────────────────────────────┘
             │  short-lived JWT  +  signed meetingUrl
             ▼
┌────────────────── ddx-docker-jitsi (5 containers) ─────────────────┐
│   jitsi-web (Nginx, custom branding) :15180/:15543                 │
│   jitsi-prosody (XMPP + JWT validator) :5347 internal              │
│   jitsi-jicofo (conference focus)        internal                  │
│   jitsi-jvb (SFU, WebRTC media) UDP :15100 / TCP :15443            │
│   jitsi-jibri (privileged, Chrome headless recorder) → ./recordings│
└──────────────────────────────────┬─────────────────────────────────┘
                                   │ recording webhook
                                   ▼
                       MinIO  {clinic-slug}-recordings bucket

Boundaries that matter:

  • Jitsi vs LiveKit — Jitsi handles video rooms (doctor-patient, multi-party, recorded, longer-lived). LiveKit (infra-livekit) handles the voice agent (real-time STT + turn-taking + ambient room audio). They are NOT substitutes. The REMINDERS doc and feedback_voice_state_machine_design memory both call this out as a high-frequency confusion.
  • NestJS does NOT proxy mediaddx-api mints a JWT and stores meeting metadata. WebRTC media flows browser → JVB directly over UDP 15100 (or TCP 15443 fallback). The API is on the control plane, never the data plane.
  • Browser does NOT call Jitsi directly without a JWT — the doctor portal hits videoApi.joinMeeting() to obtain a participant-scoped token, then opens the Jitsi external API. Prosody rejects unsigned join attempts.
  • Recording is server-side only — Jibri is the recorder; the moderator's "Record" button signals Jicofo, which spawns a Jibri instance. No client-side recording — that path leaks PHI to whatever device the doctor is on.

For the underlying container stack, port matrix, and prosody pitfalls see infra-jitsi.

Tech Stack & Choices

  • Jitsi Meet stable-10978 (Apache 2.0) — chosen because it is the only mature self-hostable WebRTC SFU + signaling + recording stack with a JWT-gated entry. LiveKit ships an SFU but has no recorder + no browser-JWT plug-in story for video rooms.
  • JWT HS256 (jsonwebtoken) — symmetric secret shared between ddx-api (issuer, JITSI_JWT_SECRET) and prosody (validator). Asymmetric RS256 was rejected — same trust domain, no third-party verifier needed, HS256 halves CPU on the signing path.
  • @nestjs/jwt was NOT usedJitsiJwtService uses the lower-level jsonwebtoken API directly because the payload shape is Jitsi-specific (context.user, context.features) and the abstraction would have leaked everywhere.
  • Room naming conventionddx-{clinic-code}-{appointment-short}-{random} (see ROOM_NAME_PREFIX constant). Predictable enough to debug, random enough to prevent collision; the random suffix uses crypto.randomBytes.
  • One meeting per appointmentvideoMeeting.appointmentId is @unique. Trying to create a second meeting throws MEETING_ALREADY_EXISTS. Restarts go through updateMeetingStatus, not createMeeting.
  • Lobby + recording flags are per-meeting, with clinic-level defaultslobbyEnabled and recordingEnabled are stored on the VideoMeeting row at create time; if absent, the values come from ClinicVideoConfig.defaultLobbyEnabled / defaultRecordingEnabled (video.service.ts:107-122).
  • recording JWT feature is doctor-scopedjitsiFeatures.recording = features?.recording ?? participant.isDoctor (jitsi-jwt.service.ts:52). Patients cannot record even if they figure out the moderator endpoint.
  • Recording target = MinIO, not S3 — recordings go to the per-tenant bucket {clinic-slug}-recordings, matching the rest of infra-minio.
  • Branding overlay only — Dudoxx forks zero Jitsi business code; only config/web/css/custom.css + branding.json. Any upstream Jitsi security fix lands without merge conflicts.

Data Flow

Meeting creation

  1. Doctor portal calls POST /video/meetings (or "Start meeting" CTA from VideoTab.tsx).
  2. VideoControllerVideoService.createMeeting(dto, organizationId, createdBy).
  3. videoMeeting.findUnique({ appointmentId }) — duplicate guard.
  4. generateRoomName(organizationId, appointmentId)ddx-{clinic-code}-{appointment-short}-{random}.
  5. jitsiJwtService.buildBaseMeetingUrl(jitsiRoomName) → composes the public meeting URL.
  6. getOrCreateClinicConfig(organizationId) reads/writes default lobby + recording flags.
  7. prisma.videoMeeting.create({ ... status: CREATED }) — single row tying the FHIR appointment to a Jitsi room.
  8. publishMeetingCreated(meeting) fires SSE on SSEChannels.session(id) (wire session:{id}) so any other open tab updates.
  9. Response includes meetingUrl, jitsiRoomName, and the freshly-minted moderator JWT (only for the requesting doctor).

Join flow (doctor)

  1. Doctor portal renders /portal/doctor/video/[appointmentId]DoctorVideoClient.tsx.
  2. useQuery(queryKeys.jitsiConfig()) fetches global Jitsi config (domain, public URL, default features) once per 30 min.
  3. useVideoMeeting(appointmentId) polls for the meeting row.
  4. videoApi.joinMeeting(meetingId) (POST /video/meetings/:id/join) → VideoService.joinMeeting():
    • Computes the participant role (HOST vs ATTENDEE) from request.user.
    • Mints JitsiJwtService.generateToken(roomName, participant, features) with JWT_TOKEN_DURATION_HOURS (default short-lived) and aud: jitsi, iss: dudoxx_clinic, sub: meet.jitsi.
    • Returns { token, meetingUrl, participantInfo }.
  5. The component instantiates the Jitsi external API into a div; the JWT goes in the URL fragment, never persisted.
  6. Prosody validates the JWT against the shared JITSI_JWT_SECRET; on success, Jicofo allocates a JVB; WebRTC starts on UDP 15100.

Join flow (patient)

Same as above, but rendered from the patient portal route. The minted JWT carries moderator: false, recording: false, livestreaming: false. The patient is held in the lobby if lobbyEnabled=true on the meeting; the doctor admits via the Jitsi UI.

Recording

  1. Doctor clicks "Record" in the Jitsi UI; the client-side prosody-validated moderator claim allows the command.
  2. Jicofo allocates a jitsi-jibri instance from the brewery MUC.
  3. Jibri launches headless Chromium and joins the room as recorder@recorder.meet.jitsi.
  4. Stream is captured to the volume-mounted ./recordings/<room>-<date>.mp4.
  5. A post-recording webhook calls back into ddx-api (consumed by VideoController), which:
    • Moves the file into MinIO bucket {clinic-slug}-recordings.
    • Persists a VideoRecording row tied to the VideoMeeting.
    • Optionally hands the recording off to the Doxing OCR + summarisation pipeline.

End / post-call

  1. Doctor exits the Jitsi UI.
  2. Portal calls PATCH /video/meetings/:id/status with IN_PROGRESS → COMPLETED.
  3. VideoService.endMeeting() updates the row, optionally writes endSummary with the post-meeting note form payload (updatePostMeetingDto).
  4. VideoEventsPublisher.publishMeetingEnded(...) fires SSE so any open appointment tab refreshes.

SSE channels used

The video domain rides the canonical SSE bus (packages/ddx-sse-contract/, @ddx/sse-contract 4.1.0):

  • video.created, video.status_changed, video.ended events emitted on the appointment's session channel.
  • No Jitsi-specific event types — meeting state changes funnel through the standard inventory.

Implicated Code

MANDATORY: ≥3 file:line citations.

  • ddx-api/src/voice-video/video/video.service.ts:11import { PrismaService } from '../../platform/prisma/prisma.service' — main Prisma client for the videoMeeting table.
  • ddx-api/src/voice-video/video/video.service.ts:12import { JitsiJwtService } from './jitsi-jwt.service' — JWT minter is a peer service in the same module.
  • ddx-api/src/voice-video/video/video.service.ts:42import { VideoEventsPublisher } from './publishers/video-events.publisher' — SSE publisher for video lifecycle events.
  • ddx-api/src/voice-video/video/video.service.ts:84async createMeeting(dto, organizationId, createdBy) — the single entry point for room creation.
  • ddx-api/src/voice-video/video/video.service.ts:90prisma.videoMeeting.findUnique({ where: { appointmentId: dto.appointmentId } }) — duplicate guard.
  • ddx-api/src/voice-video/video/video.service.ts:101generateRoomName(organizationId, dto.appointmentId) — naming convention applied here.
  • ddx-api/src/voice-video/video/video.service.ts:105meetingUrl = this.jitsiJwtService.buildBaseMeetingUrl(jitsiRoomName) — composes the public Jitsi URL.
  • ddx-api/src/voice-video/video/video.service.ts:120-122 — lobby/recording flags fall back to clinicConfig.default* when not in DTO.
  • ddx-api/src/voice-video/video/video.service.ts:125status: VideoMeetingStatus.CREATED — initial state of every meeting row.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:13export class JitsiJwtService — sole JWT issuer.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:21JITSI_JWT_APP_ID || 'dudoxx_clinic' — default iss claim.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:22JITSI_JWT_SECRET shared with prosody.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:24JITSI_DOMAIN || 'meet.jitsi' — internal XMPP domain, becomes the sub claim.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:52recording: features?.recording ?? participant.isDoctor — only doctors get the recording feature.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:58-69 — full JWT payload assembly (aud: jitsi, iss, sub, room, exp, context.user, context.features).
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:75-77jwt.sign(payload, this.appSecret, { algorithm: 'HS256' }).
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:83-87verifyToken() symmetric verification of inbound tokens for the webhook path.
  • ddx-docker-jitsi/docker-compose.yml:17image: jitsi/web:stable-10978 — pinned upstream version.
  • ddx-docker-jitsi/docker-compose.yml:21${JITSI_HTTP_PORT:-15180}:80 + ${JITSI_HTTPS_PORT:-15543}:443 — HMS port range, not the upstream 8000-range.
  • ddx-docker-jitsi/docker-compose.yml:150${JITSI_JVB_PORT:-15100}/udp media port + 15443 TCP fallback.
  • ddx-docker-jitsi/docker-compose.yml:176jitsi-jibri privileged container for headless-Chrome recording.
  • ddx-docker-jitsi/docker-compose.yml:214ddx-jitsi bridge network — isolates all internal signaling.
  • ddx-docker-jitsi/ARCHITECTURE.md:147 — JWT token flow sequence diagram (Client → NestJS → Jitsi → Prosody).
  • ddx-docker-full-hms/docker-compose.yml:702NEXT_PUBLIC_JITSI_DOMAIN: ${NEXT_PUBLIC_JITSI_DOMAIN:-localhost:36443} — build-time arg into ddx-web image.
  • ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:55export function DoctorVideoClient({ appointmentId, locale, user }) — top-level doctor video page.
  • ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:75useQuery(queryKeys.jitsiConfig()) — fetches Jitsi config from videoApi.getJitsiConfig() (cache 30 min).
  • ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:93useVideoMeeting(appointmentId) — shared meeting query.
  • ddx-web/src/app/[locale]/portal/doctor/(with-nav)/appointments/[id]/tabs/VideoTab.tsx:24jitsiRoomName: string — surfaced inside the appointment detail tab for ops debug.
  • ddx-web/src/lib/api/rbac-permissions/role-patient.ts:213p('/video/jitsi-config', 'GET') — explicit RBAC grant for patients to fetch Jitsi config (room joins are gated by per-meeting JWT, not by RBAC on this route).

Operational Notes

  • JWT_APP_SECRET drift is silent and fatal. If JITSI_JWT_SECRET in ddx-api differs from prosody's JWT_APP_SECRET, the browser shows connection.passwordRequired with zero useful logs. Verify after any redeploy:
    bash
    docker exec ddx-hms-jitsi-prosody-1 env | grep JWT_APP_SECRET
    grep JITSI_JWT_SECRET ddx-api/.env
    
  • JITSI_DOMAIN vs JITSI_PUBLIC_URL pitfall (#2 support hit per infra-jitsi and MEMORY.md):
    • JITSI_DOMAIN=meet.jitsi — the internal XMPP virtualhost, used as the sub claim. NEVER the public URL.
    • JITSI_PUBLIC_URL=https://meet-tucan.dudoxx.com — what the browser hits. Surfaced as NEXT_PUBLIC_JITSI_DOMAIN to the build.
    • Mixing them produces Token error: Invalid sub in the prosody log.
  • Prosody uid=100 permission pitfall (#1 support hit): jitsi-config-1/prosody/config/data/ MUST be owned by dhcpcd:tss (uid=100:gid=102). Symptom: Failed to load roster storage … Permission denied. Fix: sudo ./fix-permissions.sh from ddx-docker-jitsi/. Sourced in infra-jitsi operational notes.
  • Apache vhost rule (production edge): do NOT add ProxyPass /css/ ! or ProxyPass /images/ !. Jitsi serves its own static assets — Apache must proxy everything.
  • LiveKit-vs-Jitsi confusion (REMINDERS pitfall + feedback_voice_state_machine_design memory): LiveKit (infra-livekit) is for the voice agent (RTC + STT/TTS turn-taking). Jitsi is for video visits. Routing a video visit through LiveKit (or VoiceAgent through Jitsi) is a category error — they share no protocol.
  • Recording orphans: Jibri writes to ./recordings first; the post-recording webhook moves into MinIO. If the webhook fails (ddx-api down at the wrong moment) the MP4 stays on disk. Cleanup script needed in ops — listed in infra-jitsi "Operational Notes".
  • Firewall: only ${JITSI_JVB_PORT:-15100}/udp must be open externally for media. TCP fallback 15443 covers restrictive client networks. HTTPS web port 15543.
  • One meeting per appointment is enforced at the DB level. To recreate, mark the existing meeting CANCELLED and create a new appointment — never delete the meeting row.
  • Per-meeting features cannot be widened after mint. A JWT issued with recording: false cannot be promoted to recording: true without a new join call. This is by design — moderator escalation is server-controlled.
  • Health validation:
    bash
    curl -k https://meet.dudoxx.local:15543/about/                                  # web reachable
    docker logs ddx-hms-jitsi-prosody-1 --tail 30 | grep "Authenticated as"         # JWT OK
    curl -sf http://localhost:6100/api/v1/video/jitsi-config -H "X-API-Key: $KEY"   # API contract live
    
  • infra-jitsi — the underlying 5-container stack (web/prosody/jicofo/jvb/jibri), port matrix, prosody permission pitfall, branding overlay
  • infra-livekit — voice-agent RTC; NOT a substitute for video — the boundary is deliberate
  • infra-minio — recording archive bucket {clinic-slug}-recordings
  • infra-traefik — TLS termination + routing for meet-*.dudoxx.com
  • on-prem-stt-tts-llm — sibling on-prem stack for STT/TTS/LLM; voice rooms vs video rooms share no SFU but share the same data-residency story
  • flowagent-scenarios — voice-agent state-machine that runs alongside a video call when both are active
  • CLAUDE_DEPLOYMENT_STT_ENGINE — companion deployment guide that also covers Jitsi prosody fix-permissions
    Video Telemedicine — Jitsi Rooms, JWT, and Online Visits — Dudoxx Docs | Dudoxx