Teleconsultation (Video Sessions)
Purpose: How the new schema supports teleconsultation video calls, participant management, and notification infrastructure.
Table of Contents
- Overview
- Architecture
- Database Schema
- Models
- API Endpoints
- Video Session Lifecycle
- Notification System
- Background Jobs
- Shared Notification Services
Overview
The teleconsultation feature enables real-time video consultations between practitioners and patients within the clinical domain. Key capabilities:
- Provider-agnostic video sessions via adapter pattern (Vonage, Twilio, Agora)
- Participant lifecycle tracking with join/leave/decline states and connection tokens
- Multi-channel notifications: Ably real-time, SMS, push, WhatsApp, Slack
- Scheduled reminders: Doctor (t-15min), patient (t-10min), and at-time push notifications
- Session timeout: 30-minute no-show auto-timeout with payment cleanup hooks
- Tenant isolation: RLS policies on all video session tables
- Monitoring: Slack alerts for session lifecycle (creation, delays, completion, timeout)
Architecture
Video Session Lifecycle
Create Session ──> Schedule Notifications ──> Reminders fire
│ │ │
▼ ▼ ▼
Ably: session_created SlackJob: 'new' TeleconsultationReminderJob
│ (doctor_15min / patient_10min / at_time)
▼
Patient joins ──> Doctor joins ──> Session active
│ │
▼ ▼
Ably: participant_joined Ably: call_started
│
┌───────────┴───────────┐
▼ ▼
Complete session Timeout (30min)
│ │
▼ ▼
SlackJob: 'complete' SlackJob: 'timed_out'
Ably: session_completed Ably: session_timed_outDomain Boundaries
| Layer | Location | Responsibility |
|---|---|---|
| Models | domains/clinical/models/ | VideoSession, VideoSessionParticipant |
| Controller | domains/clinical/controllers/ | REST API for session management |
| Services | domains/clinical/services/video_sessions/ | Notifications, scheduling, Slack alerts |
| Jobs | domains/clinical/jobs/ | Async reminders, Slack posts, timeout |
| Shared Services | domains/shared/services/notifications/ | SMS, push, Slack, WhatsApp delivery |
| Shared Jobs | domains/shared/jobs/ | SendSmsJob, SendPushJob |
| Shared Models | domains/shared/models/ | NotificationLog |
Database Schema
clinical.video_sessions
CREATE TABLE clinical.video_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES identity.tenants(id),
encounter_id UUID NOT NULL REFERENCES clinical.encounters(id),
provider VARCHAR DEFAULT 'vonage', -- vonage | twilio | agora
provider_session_id VARCHAR,
status VARCHAR DEFAULT 'created', -- created | active | completed | failed | timed_out
started_at TIMESTAMP,
completed_at TIMESTAMP,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Indexes
CREATE INDEX idx_vs_tenant ON clinical.video_sessions (tenant_id);
CREATE INDEX idx_vs_encounter ON clinical.video_sessions (encounter_id);
CREATE INDEX idx_vs_tenant_status ON clinical.video_sessions (tenant_id, status);
CREATE INDEX idx_vs_provider_session_id ON clinical.video_sessions (provider_session_id);clinical.video_session_participants
CREATE TABLE clinical.video_session_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES identity.tenants(id),
video_session_id UUID NOT NULL REFERENCES clinical.video_sessions(id),
participant_type VARCHAR NOT NULL, -- practitioner | patient | family
participant_id UUID NOT NULL,
role VARCHAR DEFAULT 'participant', -- host | participant | observer
token TEXT,
token_expires_at TIMESTAMP,
joined_at TIMESTAMP,
left_at TIMESTAMP,
declined_at TIMESTAMP,
connection_id VARCHAR,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (video_session_id, participant_type, participant_id)
);
-- Indexes
CREATE INDEX idx_vsp_tenant ON clinical.video_session_participants (tenant_id);
CREATE INDEX idx_vsp_session ON clinical.video_session_participants (video_session_id);shared.notification_logs
CREATE TABLE shared.notification_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
channel VARCHAR NOT NULL, -- sms | push | whatsapp | slack | email
status VARCHAR NOT NULL DEFAULT 'pending', -- pending | sent | delivered | failed | bounced
recipient VARCHAR NOT NULL,
content TEXT,
template_id VARCHAR,
provider VARCHAR, -- twilio | msg91 | sns | slack
provider_id VARCHAR,
failure_reason VARCHAR,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Indexes
CREATE INDEX idx_notif_logs_tenant ON shared.notification_logs (tenant_id);
CREATE INDEX idx_notif_logs_channel ON shared.notification_logs (channel);
CREATE INDEX idx_notif_logs_dedup ON shared.notification_logs (channel, recipient, created_at);
CREATE INDEX idx_notif_logs_created_at ON shared.notification_logs (created_at);Row-Level Security
Both video_sessions and video_session_participants have RLS enabled:
ALTER TABLE clinical.video_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinical.video_sessions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON clinical.video_sessions
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid);Models
Clinical::VideoSession
File: domains/clinical/models/video_session.rb
# Key constants
VALID_PROVIDERS = ['vonage', 'twilio', 'agora']
VALID_STATUSES = ['created', 'active', 'completed', 'failed', 'timed_out']| Association | Type | Target |
|---|---|---|
encounter | belongs_to | Clinical::Encounter |
participants | has_many | Clinical::VideoSessionParticipant |
| Scope | Description |
|---|---|
for_encounter(id) | Filter by encounter |
by_status(status) | Filter by status |
active_sessions | Status = 'active' |
created_sessions | Status = 'created' |
terminal | Status in completed/failed/timed_out |
| Method | Returns | Description |
|---|---|---|
duration_seconds | Integer | Seconds between started_at and completed_at |
terminal? | Boolean | Session in terminal state |
joinable? | Boolean | Session can be joined (created or active) |
active_participants | Relation | Participants who joined and have not left/declined |
Clinical::VideoSessionParticipant
File: domains/clinical/models/video_session_participant.rb
# Key constants
VALID_PARTICIPANT_TYPES = ['practitioner', 'patient', 'family']
VALID_ROLES = ['host', 'participant', 'observer']| Method | Returns | Description |
|---|---|---|
active? | Boolean | Currently connected |
token_expired? | Boolean | Provider token has expired |
declined? | Boolean | Participant declined the call |
API Endpoints
Base path: /clinical/encounters/:encounter_id/video_session
Uses singular resource routing (one active session per encounter).
| Method | Path | Action | Description |
|---|---|---|---|
| GET | /video_session | show | Get current session status |
| POST | /video_session | create | Create a new session |
| POST | /video_session/join | join | Join the session |
| POST | /video_session/token | token | Generate a provider token |
| POST | /video_session/start | start | Start the session |
| POST | /video_session/complete | complete | End the session |
| POST | /video_session/decline | decline | Decline the call invitation |
All endpoints require JWT authentication and tenant context.
Participant Resolution
The controller automatically resolves participant details from the authenticated user:
- Practitioners:
participant_type: 'practitioner',role: 'host' - Patients:
participant_type: 'patient',role: 'participant' - Others:
participant_type: 'family',role: 'observer'
Video Session Lifecycle
created ──> active ──> completed
│ │
│ └──> failed
│
└──> timed_out (30-min no-show)Status Transitions
| From | To | Trigger |
|---|---|---|
| created | active | First participant joins and session is started |
| created | timed_out | 30 minutes pass with no participant joining |
| active | completed | Practitioner ends the session |
| active | failed | Unrecoverable error occurs |
Timeout Flow
CreateServicecreates the sessionVideoSessionTimeoutJobis scheduled for t+30 minutes- If session is still in
createdstatus when the job runs, it transitions totimed_out - Slack alert sent, payment refund triggered (Phase 6)
Notification System
Three notification services handle different aspects:
1. NotificationService (Real-time + SMS/Push)
File: domains/clinical/services/video_sessions/notification_service.rb
Handles Ably real-time events and patient notifications.
| Event | Ably Channel | SMS | Push |
|---|---|---|---|
session_created | private-encounter-{id} | Patient invite | Patient push |
participant_joined | private-encounter-{id} | - | - |
session_started | private-encounter-{id} | - | - |
session_completed | private-encounter-{id} | - | - |
call_declined | private-encounter-{id} | - | - |
session_timed_out | private-encounter-{id} | - | - |
2. SlackNotificationService (Monitoring)
File: domains/clinical/services/video_sessions/slack_notification_service.rb
Posts color-coded alerts to the :telemedicine Slack channel.
| Event | Color | When |
|---|---|---|
session_created | Green | New session created |
check_delay | Orange | Session not active after 10 minutes |
session_completed | Green | Session ended normally |
session_timed_out | Red | 30-minute timeout |
session_cancelled | Red | Session cancelled |
3. ScheduleNotificationsService (Orchestrator)
File: domains/clinical/services/video_sessions/schedule_notifications_service.rb
Schedules all notification jobs when a video session is created.
Future Appointments (appointment_time > now)
| Timing | Job | Type | Recipients |
|---|---|---|---|
| t-15 min | TeleconsultationReminderJob | doctor_15min | SMS + Push to practitioner |
| t-10 min | TeleconsultationReminderJob | patient_10min | SMS + WhatsApp to patient |
| t=0 | TeleconsultationReminderJob | at_time | Push to both parties |
| t+5 sec | SlackNotificationJob | new | Slack team alert |
| t+10 min | SlackNotificationJob | delay | Slack delay check |
Instant Appointments (appointment_time is nil or in the past)
| Timing | Job | Type | Recipients |
|---|---|---|---|
| Immediate | TeleconsultationReminderJob | at_time | Push to both parties |
| +5 sec | SlackNotificationJob | new | Slack team alert |
| +10 min | SlackNotificationJob | delay | Slack delay check |
Time guards ensure reminders scheduled for the past are skipped.
Background Jobs
Clinical::SlackNotificationJob
Queue: slack_notifications
Dispatches to SlackNotificationService based on notification type.
# Valid types: 'new', 'delay', 'complete', 'timed_out', 'cancelled'
Clinical::SlackNotificationJob.set(wait: 5.seconds).perform_later(video_session.id, 'new')Clinical::TeleconsultationReminderJob
Queue: sms_messages
Sends pre-call reminders to doctors and patients.
# Valid types: 'doctor_15min', 'patient_10min', 'at_time'
Clinical::TeleconsultationReminderJob
.set(wait_until: slot_time - 15.minutes)
.perform_later(encounter.id, 'doctor_15min')Guard clauses (job silently exits if):
- Encounter not found or not
virtualclass - Encounter cancelled, entered_in_error, or finished
- Video session already in terminal state
| Type | Doctor | Patient | Channels |
|---|---|---|---|
doctor_15min | SMS + Push | - | Twilio/MSG91 + AWS SNS |
patient_10min | - | SMS + WhatsApp | Twilio/MSG91 + WhatsApp |
at_time | Push | Push (voice_call: true) | AWS SNS |
Clinical::VideoSessionTimeoutJob
Queue: consultations
Runs 30 minutes after session creation. Marks session as timed_out if no participant has joined.
Clinical::VideoSessionTimeoutJob.set(wait: 30.minutes).perform_later(video_session.id)Shared Notification Services
The teleconsultation feature uses all four shared notification channels. For full documentation on services, jobs, logging, and how to add notifications to new features, see:
Notification System Documentation
Quick reference for the services used by teleconsultation:
| Channel | Service | Used For |
|---|---|---|
| SMS | Shared::Notifications::SmsService | Patient invite, doctor reminders |
| Push | Shared::Notifications::PushService | Voice call alerts, reminders |
| Slack | Shared::Notifications::SlackService | Team monitoring alerts |
Shared::Notifications::WhatsappService | Patient reminders (10min before) |
Async jobs: Shared::SendSmsJob (queue: sms_messages), Shared::SendPushJob (queue: push_notifications).
All SMS deliveries are logged in shared.notification_logs -- see Notification Logging for query examples and schema.