Skip to content

Personalized Exercise Videos

Overview

Therapists can record custom exercise demonstration videos for individual patients directly from the mobile app. When a personalized video exists, it replaces the default exercise video during that patient's workout playback.

The feature is gated by the therapistPersonalizedExerciseVideo tenant feature flag. Videos are uploaded to S3, transcoded to Cloudflare Stream, and automatically attached to workout API responses.

Architecture

Mobile App (Doctor)                     API (OktaPT-API)
┌────────────────────┐                 ┌──────────────────────────┐
│ Select Patient     │                 │                          │
│        ↓           │                 │  POST /upload-url        │
│ Select Exercise    │                 │    → Create DB record    │
│        ↓           │                 │    → Presigned S3 URL    │
│ Record Video       │                 │                          │
│   (expo-camera)    │──PUT video────→ │  S3 (DigitalOcean Spaces)│
│        ↓           │                 │                          │
│ Review + Upload    │──POST──────────→│  POST /:id/upload-complete│
│        ↓           │                 │    → BullMQ job          │
│ Success Screen     │                 │                          │
└────────────────────┘                 │  Worker:                 │
                                       │    1. Sign S3 GET URL    │
                                       │    2. Copy to Cloudflare │
                                       │    3. Poll readiness     │
                                       │    4. Validate duration  │
                                       │    5. Activate (deactivate│
                                       │       old video if any)  │
                                       └──────────────────────────┘

Mobile App Flow (Okta-Mobile)

The recording flow is a 4-screen Stack navigator at app/(doctor)/(personalized-video)/. It is only accessible to doctors (wrapped in the (doctor) RoleGuard group).

Screen 1: Patient Picker (index.tsx)

  • Fetches the doctor's patients via GET /v2/personalized-exercise-videos/patients
  • Searchable FlatList with client-side filtering by first + last name
  • Each row shows initials avatar and full name
  • On selection, navigates to exercise search with patientId and patientName params

Screen 2: Exercise Search (exercise-search.tsx)

  • Debounced search (300ms) against GET /v2/exercises?q=&limit=700
  • On exercise selection, checks for an existing personalized video via GET /v2/personalized-exercise-videos/current?patientId=&exerciseId=
  • If one exists, shows a confirmation alert asking to replace it
  • Create exercise button in the list footer allows creating a new exercise inline (name from search text, category "OTHER")

Screen 3: Record (record.tsx)

  • Full-screen camera using expo-camera (CameraView, back-facing, video mode)
  • Camera flip button (disabled during recording)
  • Max recording duration: 120 seconds
  • Countdown timer during recording
  • After recording, shows a review screen with "Retake" and "Upload" buttons

Upload sequence: 1. POST /upload-url with { patientId, exerciseId } -> { id, uploadUrl } 2. Read local video file as blob 3. PUT uploadUrl with Content-Type: video/mp4 (direct S3 upload) 4. POST /:id/upload-complete to trigger processing 5. Navigate to success screen

Screen 4: Success (success.tsx)

  • Green checkmark, patient name, exercise name
  • Explanatory text: the video will appear during the patient's workout once processing completes
  • "Done" button returns to the main dashboard
  • Back navigation disabled

Entry Point

The DoctorDashboard component conditionally shows a "Record Exercise" button on each patient card when hasFeature("therapistPersonalizedExerciseVideo") is true (from TenantFeaturesContext). Tapping it navigates to the personalized video flow with the patient pre-selected.

Mobile Service

File: services/personalizedVideoService.ts

Method HTTP Call
listPatients() GET /v2/personalized-exercise-videos/patients
searchExercises(q, limit, offset) GET /v2/exercises?q=&limit=&offset=
createExercise({ name, categoryId }) POST /v2/exercises
getUploadUrl(patientId, exerciseId) POST /v2/personalized-exercise-videos/upload-url
uploadComplete(videoId) POST /v2/personalized-exercise-videos/{id}/upload-complete
getCurrentAssignment(patientId, exerciseId) GET /v2/personalized-exercise-videos/current?patientId=&exerciseId=

API Layer (OktaPT-API)

Routes

File: src/routes/personalized-exercise-videos/index.ts

All routes require authentication middleware. Each controller checks the therapistPersonalizedExerciseVideo feature flag and returns 404 if disabled.

Method Path Controller Description
GET /v2/personalized-exercise-videos/patients listPatients List patients available for video recording (tenant-aware)
POST /v2/personalized-exercise-videos/upload-url createUploadUrl Create video record + presigned S3 PUT URL
POST /v2/personalized-exercise-videos/:id/upload-complete uploadComplete Confirm upload, trigger processing
GET /v2/personalized-exercise-videos/current getCurrentAssignment Get active video for a (patient, exercise) pair

Patient Access

Patient access follows the same pattern as clinical assessments: - If allActivePatientsDashboard flag is enabled: any active patient in the tenant - Otherwise: only the doctor's directly connected patients

S3 Storage

Videos are stored at personalized-video/{videoId}/{timestamp}.mp4 in DigitalOcean Spaces. Presigned URLs expire after 1 hour.

Processing Pipeline

File: src/routes/personalized-exercise-videos/personalizedVideoProcessor.ts

When uploadComplete is called: 1. Status set to PROCESSING_QUEUED 2. BullMQ job enqueued (or inline processing in dev when no REDIS_URL)

The processor runs these steps:

  1. Generate signed S3 GET URL for the uploaded video
  2. Copy to Cloudflare Stream via POST /stream/copy to the Cloudflare API
  3. Poll readiness -- up to 30 attempts, 5 seconds apart (~2.5 minutes max), waiting for readyToStream === true
  4. Validate duration -- if over 120 seconds, delete the Cloudflare asset and mark as FAILED
  5. Activate in a Prisma transaction:
  6. Deactivate any existing active video for the same (patient, exercise) pair (isActive: false, status: INACTIVE)
  7. Set the new video to isActive: true, status: COMPLETED

BullMQ Queue

File: src/queue/personalizedVideoQueue.ts

  • Queue name: personalized-exercise-video
  • Job ID: personalized-video-{id} (prevents duplicates)
  • Retry: 3 attempts with exponential backoff (10s base)
  • Retention: 100 completed, 200 failed jobs
  • Worker concurrency: 1 (in src/worker.ts)

Workout Integration

File: src/services/personalizedVideoService.ts

The attachPersonalizedVideos() function is called during GET /v2/workouts/patient-upcoming. It: 1. Collects all exercise IDs from the workout response 2. Batch-fetches active COMPLETED personalized videos for those exercises 3. Mutates each exercise object to include personalizedVideo: { id, cloudflareStreamId, updatedAt } | null

Both the web and mobile video players prioritize personalizedVideo.cloudflareStreamId as the highest-priority video source, above Kinescope and default Cloudflare Stream videos. On web, the shared getVideoSource() resolver in OktaPT-FE/lib/utils/exercise.ts handles the priority logic for VideoPreviewModal, ExerciseThumbnailRow, and the workout page. On mobile, WorkoutVideoPlayer.tsx uses the same priority order.

Database Model

enum PersonalizedExerciseVideoStatus {
  CREATED
  UPLOAD_URL_CREATED
  PROCESSING_QUEUED
  COMPLETED
  FAILED
  INACTIVE
}

model PersonalizedExerciseVideo {
  id                 Int                              @id @default(autoincrement())
  patientId          Int
  exerciseId         Int
  doctorId           Int
  storageKey         String?
  cloudflareStreamId String?
  durationSec        Float?
  status             PersonalizedExerciseVideoStatus  @default(CREATED)
  isActive           Boolean                          @default(false)
  tenantId           Int
  createdAt          DateTime                         @default(now())
  updatedAt          DateTime                         @updatedAt

  @@index([patientId, exerciseId, isActive, createdAt])
  @@index([tenantId, status])
}

Status State Machine

CREATED -> UPLOAD_URL_CREATED -> PROCESSING_QUEUED -> COMPLETED
                                       |
                                       v
                                     FAILED (retryable via uploadComplete)

COMPLETED -> INACTIVE (when replaced by a newer video for the same patient+exercise)

Key Files

File Role
Okta-Mobile/app/(doctor)/(personalized-video)/ 4-screen mobile recording flow
Okta-Mobile/services/personalizedVideoService.ts Mobile API service
Okta-Mobile/components/doctor/DoctorDashboard.tsx Entry point (Record Exercise button)
OktaPT-API/src/routes/personalized-exercise-videos/controllers.ts API controllers (4 endpoints)
OktaPT-API/src/routes/personalized-exercise-videos/personalizedVideoProcessor.ts S3 -> Cloudflare Stream processing
OktaPT-API/src/services/personalizedVideoService.ts Workout response integration (attachPersonalizedVideos)
OktaPT-API/src/queue/personalizedVideoQueue.ts BullMQ queue definition
OktaPT-API/src/worker.ts Background worker (processes all queues)
Okta-Mobile/components/workout/WorkoutVideoPlayer.tsx Video player (personalized video as priority source)
Okta-Mobile/lib/exerciseUtils.ts Thumbnail/video source utilities