Kulcare Docs
DeveloperTeleconsultation

Teleconsultation (Video Sessions)

Purpose: How the new schema supports teleconsultation video calls, participant management, and notification infrastructure.


Table of Contents

  1. Overview
  2. Architecture
  3. Database Schema
  4. Models
  5. API Endpoints
  6. Video Session Lifecycle
  7. Notification System
  8. Background Jobs
  9. 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_out

Domain Boundaries

LayerLocationResponsibility
Modelsdomains/clinical/models/VideoSession, VideoSessionParticipant
Controllerdomains/clinical/controllers/REST API for session management
Servicesdomains/clinical/services/video_sessions/Notifications, scheduling, Slack alerts
Jobsdomains/clinical/jobs/Async reminders, Slack posts, timeout
Shared Servicesdomains/shared/services/notifications/SMS, push, Slack, WhatsApp delivery
Shared Jobsdomains/shared/jobs/SendSmsJob, SendPushJob
Shared Modelsdomains/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']
AssociationTypeTarget
encounterbelongs_toClinical::Encounter
participantshas_manyClinical::VideoSessionParticipant
ScopeDescription
for_encounter(id)Filter by encounter
by_status(status)Filter by status
active_sessionsStatus = 'active'
created_sessionsStatus = 'created'
terminalStatus in completed/failed/timed_out
MethodReturnsDescription
duration_secondsIntegerSeconds between started_at and completed_at
terminal?BooleanSession in terminal state
joinable?BooleanSession can be joined (created or active)
active_participantsRelationParticipants 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']
MethodReturnsDescription
active?BooleanCurrently connected
token_expired?BooleanProvider token has expired
declined?BooleanParticipant declined the call

API Endpoints

Base path: /clinical/encounters/:encounter_id/video_session

Uses singular resource routing (one active session per encounter).

MethodPathActionDescription
GET/video_sessionshowGet current session status
POST/video_sessioncreateCreate a new session
POST/video_session/joinjoinJoin the session
POST/video_session/tokentokenGenerate a provider token
POST/video_session/startstartStart the session
POST/video_session/completecompleteEnd the session
POST/video_session/declinedeclineDecline 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

FromToTrigger
createdactiveFirst participant joins and session is started
createdtimed_out30 minutes pass with no participant joining
activecompletedPractitioner ends the session
activefailedUnrecoverable error occurs

Timeout Flow

  1. CreateService creates the session
  2. VideoSessionTimeoutJob is scheduled for t+30 minutes
  3. If session is still in created status when the job runs, it transitions to timed_out
  4. 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.

EventAbly ChannelSMSPush
session_createdprivate-encounter-{id}Patient invitePatient push
participant_joinedprivate-encounter-{id}--
session_startedprivate-encounter-{id}--
session_completedprivate-encounter-{id}--
call_declinedprivate-encounter-{id}--
session_timed_outprivate-encounter-{id}--

2. SlackNotificationService (Monitoring)

File: domains/clinical/services/video_sessions/slack_notification_service.rb

Posts color-coded alerts to the :telemedicine Slack channel.

EventColorWhen
session_createdGreenNew session created
check_delayOrangeSession not active after 10 minutes
session_completedGreenSession ended normally
session_timed_outRed30-minute timeout
session_cancelledRedSession 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)

TimingJobTypeRecipients
t-15 minTeleconsultationReminderJobdoctor_15minSMS + Push to practitioner
t-10 minTeleconsultationReminderJobpatient_10minSMS + WhatsApp to patient
t=0TeleconsultationReminderJobat_timePush to both parties
t+5 secSlackNotificationJobnewSlack team alert
t+10 minSlackNotificationJobdelaySlack delay check

Instant Appointments (appointment_time is nil or in the past)

TimingJobTypeRecipients
ImmediateTeleconsultationReminderJobat_timePush to both parties
+5 secSlackNotificationJobnewSlack team alert
+10 minSlackNotificationJobdelaySlack 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 virtual class
  • Encounter cancelled, entered_in_error, or finished
  • Video session already in terminal state
TypeDoctorPatientChannels
doctor_15minSMS + Push-Twilio/MSG91 + AWS SNS
patient_10min-SMS + WhatsAppTwilio/MSG91 + WhatsApp
at_timePushPush (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:

ChannelServiceUsed For
SMSShared::Notifications::SmsServicePatient invite, doctor reminders
PushShared::Notifications::PushServiceVoice call alerts, reminders
SlackShared::Notifications::SlackServiceTeam monitoring alerts
WhatsAppShared::Notifications::WhatsappServicePatient 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.

On this page