Skip to content

Personalized Exercise Videos

Overview

Therapists can record custom exercise demonstration videos for individual patients directly from the mobile app or choose an existing patient video from the phone's camera roll. 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 or Choose   │                 │                          │
│   Video            │──PUT video────→ │  S3 (DigitalOcean Spaces)│
│        ↓           │                 │                          │
│ Review + Upload    │──POST──────────→│  POST /:id/upload-complete│
│        ↓           │                 │    → BullMQ job          │
│ Success Screen     │                 │                          │
└────────────────────┘                 │  Worker (video):         │
                                       │    1. Sign S3 GET URL    │
                                       │    2. Copy to Cloudflare │
                                       │    3. Poll readiness     │
                                       │    4. Validate duration  │
                                       │    5. Activate (deactivate│
                                       │       old video if any)  │
                                       │           ↓              │
                                       │  Worker (injection):     │
                                       │    6. AI prescription gen │
                                       │    7. Inject into HEP    │
                                       └──────────────────────────┘

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 or Choose (record.tsx)

  • Full-screen camera using expo-camera (CameraView, back-facing, video mode)
  • Camera flip button (disabled during recording)
  • Choose from Library button opens the device camera roll via expo-image-picker
  • Library selection remains available even if camera or microphone permissions are denied
  • Max recording duration: 120 seconds
  • Library videos with known metadata over 120 seconds are rejected before upload; the backend Cloudflare duration check remains the final validator
  • Countdown timer during recording
  • After recording or choosing a video, shows an inline expo-video playback preview with "Retake"/"Change Video" and "Upload" buttons

Upload sequence: 1. POST /upload-url with { patientId, exerciseId, contentType } -> { id, uploadUrl } 2. Upload the local recorded or library-picked video URI 3. PUT uploadUrl with the selected video MIME type, defaulting to video/mp4 (direct S3 upload) 4. POST /:id/upload-complete to trigger processing and immediate HEP injection 5. Navigate to Manage HEP, poll until the injected exercise appears, then open dosage editing for the inserted exercise rows

Screen 4: Manage HEP Follow-Through (../(hep)/manage.tsx)

  • Shows the patient's current HEP immediately after upload
  • Displays the injected exercise while the video is still processing
  • Opens the existing dosage editor so the therapist can adjust sets, reps/duration, weight, pain/exertion targets, and patient instructions
  • After dosage save, persists the updated HEP and shows two actions:
  • Further manage HEP
  • Create another video for the patient

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=
getPatientVideos(patientId) GET /v2/personalized-exercise-videos/patient/{patientId}
getMyVideos() GET /v2/personalized-exercise-videos/my-videos

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/prescription-suggestions startPrescriptionSuggestion Start async prescription suggestion for a (patient, exercise) pair (doctor only)
GET /v2/personalized-exercise-videos/prescription-suggestions/:id getPrescriptionSuggestion Poll prescription suggestion status/result (doctor only)
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
GET /v2/personalized-exercise-videos/my-videos getMyVideos List all personalized videos for the current patient
GET /v2/personalized-exercise-videos/patient/:patientId getPatientVideos List all personalized videos for a specific patient (doctor only, includes processing-state videos)

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 in DigitalOcean Spaces under a deterministic, region- and env-scoped path:

personalized-video/{region}/{env}/tenant-{tenantId}/video-{videoId}-{uuid}.mp4

The matching Cloudflare Stream asset name is Personalized Video - {region} - {env} - tenant-{tenantId} - video-{videoId}. Both names are built in src/routes/personalized-exercise-videos/storageNaming.ts (buildPersonalizedVideoStorageKey, buildPersonalizedVideoCloudflareName). {region} falls back to REGION env var, {env} to NODE_ENV, with safe region-unknown / env-unknown defaults; each segment is lowercased, hyphenated, and stripped of non-alphanumerics. Presigned URLs expire after 1 hour.

Pre-2026-05-15 videos remain at their old personalized-video/{videoId}/{timestamp}.mp4 paths in S3 and keep their original Cloudflare names — the naming change is forward-only.

On mobile, signed-URL uploads go through a shared services/signedUrlUploadService.ts helper that services/videoUploadService.ts delegates to. The helper centralizes retry behavior and progress reporting for personalized video uploads and recorded patient videos alike.

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 and the legacy patient-workouts route. 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

Because the function runs on both upcoming and historical workout routes, personalized videos are visible in the therapist's WorkoutDetailsModalV2 when reviewing any completed session — not only during patient workout playback.

Doctor-facing callers (e.g., the patient detail video list) can pass includeProcessing: true to also receive videos in pending states (CREATED, UPLOAD_URL_CREATED, PROCESSING_QUEUED, FAILED). This lets the mobile app show upload progress and failed uploads alongside completed videos without requiring a separate endpoint. The response shape adds a status field when processing videos are included.

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. The structured media field returned by the API (see exercise-media-rendering.md) places the personalized Cloudflare source first in videoSources[] so clients consuming the new shape get the same priority for free.

Doctor Exercise Picker (Web + Mobile)

The exercise picker that therapists use when assigning exercises to a patient is patient-aware:

  • APIGET /v2/exercises?patientId=<id> (called from any patient-scoped therapist flow) verifies that the requesting doctor has an active connection to the patient (returns 403 with { error: "No active connection with this patient" } otherwise) and attaches personalizedVideo: { id, cloudflareStreamId, updatedAt } | null to each returned exercise. Verified by src/routes/__tests__/exercises-personalized.test.ts.
  • WebOktaPT-FE/components/ExerciseSelector/ExerciseLibraryDialog.tsx renders a pill-style scope selector (Personalized · All Exercises · My Exercises · Favorites). The "Personalized" pill is shown only when a patientId is provided and filters to exercises with personalizedVideo.cloudflareStreamId. Selecting it shows the personalized thumbnail in each card (via the standard priority chain).
  • MobileOkta-Mobile/components/doctor/hep/HepExercisePickerModal.tsx exposes the same scopes; see exercise-library.md for the design-level description shared across platforms.

Patient Video Library

When therapistPersonalizedExerciseVideo is enabled, patients can browse all personalized videos their therapists have recorded and review their full exercise history. This is exposed through a tabbed interface on the Past Workouts page.

Web App (OktaPT-FE)

File: pages/patient/past-workouts.tsx

The Past Workouts page gains a top-level tab bar with two tabs: Sessions (the existing completed workouts list) and Exercises. The Exercises tab is only visible when hasFeature("therapistPersonalizedExerciseVideo") returns true. Sessions remains the default tab and is always visible.

The Exercises tab contains two sub-tabs:

Personalized — Fetches the patient's personalized videos via GET /v2/personalized-exercise-videos/my-videos. Renders a scrollable list where each row shows a Cloudflare Stream thumbnail, exercise name, doctor name, and recording date. Tapping a row opens the PersonalizedVideoPlaybackModal with the Cloudflare Stream player.

All Exercises — Fetches the patient's exercise history via GET /v2/exercises/my-history. Renders each exercise with a thumbnail (using the standard video priority: personalized video > Cloudflare Stream > Kinescope), total completed sets, and last completed date. Tapping an exercise plays the best available video source in the playback modal. Exercises without any video source are non-interactive.

Mobile App (Okta-Mobile)

The mobile app uses the same two API endpoints (/my-videos and /v2/exercises/my-history) through personalizedVideoService.getMyVideos() and exerciseHistoryService.getMyHistory() to provide an equivalent patient-facing exercise and video browsing experience.

Patient Video Endpoints

Method Path Controller Description
GET /v2/personalized-exercise-videos/my-videos getMyVideos List all personalized videos for the current patient
GET /v2/personalized-exercise-videos/patient/:patientId getPatientVideos List all personalized videos for a specific patient (doctor only)

Both endpoints return PersonalizedVideoListItem[]:

interface PersonalizedVideoListItem {
  id: number;
  exerciseId: number;
  exerciseName: string;
  cloudflareStreamId: string;
  durationSec: number | null;
  createdAt: string;
  doctorFirstName: string;
  doctorLastName: string;
}

The /my-videos endpoint identifies the patient from the JWT. The /patient/:patientId endpoint requires doctor authentication and respects the same patient-access rules as other doctor endpoints (all active patients if allActivePatientsDashboard is enabled, otherwise only directly connected patients).

Exercise History API

Endpoint: GET /v2/exercises/my-history (patient-only)

Returns aggregated exercise completion statistics across all completed workouts for the current patient. Each item includes video source references so the client can render thumbnails and enable playback.

interface PatientExerciseHistoryItem {
  exerciseId: number;
  exerciseName: string;
  categoryId: string;
  totalCompletedSets: number;
  lastCompletedDate: string;
  timesAssigned: number;
  cloudflareStreamId: string | null;
  kinescopeVideoId: string | null;
  videoUrl: string | null;
  personalizedVideo: {
    id: number;
    cloudflareStreamId: string;
  } | null;
}

Used by both the web FE (exerciseHistoryApi.getMyHistory()) and mobile (exerciseHistoryService.getMyHistory()).

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)

Auto-Injection into HEP

When upload completion marks a personalized video as PROCESSING_QUEUED, the system immediately enqueues a second background job that adds the exercise to the patient's upcoming workouts with AI-generated prescription defaults. The video processing completion path still enqueues the same injection job as an idempotent fallback, so retries and older queued videos remain safe.

AI Prescription Generation

File: src/services/personalizedExercisePrescriptionService.ts

Uses GPT (gpt-4.1-mini) with structured JSON output to determine optimal prescription values through a three-tier fallback:

  1. AI analysis — sends exercise details (name, category, difficulty, equipment), patient attributes (UserAttributeForAiModel), and up to 10 most recent historical prescriptions to GPT-4.1-mini. The model returns a PersonalizedExercisePrescriptionSchema with sets, repsOrDuration, and trackingType (REPS_BASED or TIME_BASED).
  2. Historical fallback — if the AI call fails, uses the most recent prescription for this exercise from any prior workout.
  3. Conservative defaults — if no history exists: 3 sets, 10 reps, reps-based.

Each result is tagged with its source: AI, HISTORICAL, or DEFAULT. All AI calls are logged via aiResponseLogService.

Async Prescription Suggestions

File: src/services/personalizedExercisePrescriptionSuggestionService.ts

The mobile app prefetches prescription suggestions so they are ready before the doctor finishes recording. The flow is:

  1. Doctor selects a patient and exercise in the video recording flow
  2. Mobile calls POST /prescription-suggestions with { patientId, exerciseId } — the API enqueues a BullMQ job (or processes inline in dev) and returns a suggestionId with status PENDING
  3. Mobile polls GET /prescription-suggestions/:id until status is COMPLETED or FAILED
  4. On completion, the suggestion contains the resolved PersonalizedExercisePrescription (sets, reps/duration, tracking type, source)

Results are cached in Redis with a 10-minute TTL. Re-requesting the same (tenant, doctor, patient, exercise) combination returns the cached result rather than re-computing.

Mobile Coordinator: Okta-Mobile/services/personalizedExercisePrescriptionCoordinator.ts manages the prefetch lifecycle — deduplicates in-flight requests, tracks active suggestions in memory, polls at 500ms intervals, and cleans up stale entries after 10 minutes.

Injection Logic

After prescription values are determined:

  1. Reschedule overdue workouts — any past-dated incomplete workouts are rescheduled forward via rescheduleOverdueWorkouts
  2. Inject into existing sessions — the exercise is added to the next 5 editable upcoming incomplete workout sessions that don't already contain it, placed as a SUPPLEMENTAL_WORK block appended to the end
  3. Create new sessions if needed — if the patient has fewer than 5 editable upcoming sessions total, new sessions are created (one per day after the last existing session, at 13:00 UTC) each containing the exercise as MAIN_SESSION

All operations run in a single Prisma transaction with an idempotency check — a JSONB marker ({ source: "personalized-video-injection", videoId }) prevents duplicate processing if the job is retried.

BullMQ Queue

  • Queue name: personalized-exercise-injection
  • Job ID: exercise-injection-{videoId} (prevents duplicates)
  • Retry: 3 attempts with exponential backoff (10s base)
  • Retention: 100 completed, 200 failed jobs
  • In dev (no Redis): runs as fire-and-forget direct processing

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 (6 endpoints)
OktaPT-API/src/routes/personalized-exercise-videos/personalizedVideoProcessor.ts S3 -> Cloudflare Stream processing
OktaPT-API/src/routes/personalized-exercise-videos/storageNaming.ts Deterministic S3 key + Cloudflare name builder
Okta-Mobile/services/signedUrlUploadService.ts Shared signed-URL upload helper (used by videoUploadService.ts)
OktaPT-API/src/services/personalizedVideoService.ts Workout response integration (attachPersonalizedVideos)
OktaPT-API/src/queue/personalizedVideoQueue.ts BullMQ queue definition (video processing)
OktaPT-API/src/services/personalizedExercisePrescriptionService.ts Prescription resolution (AI / historical / default fallback)
OktaPT-API/src/services/personalizedExercisePrescriptionSuggestionService.ts Async suggestion flow (Redis-cached, BullMQ-backed)
OktaPT-API/src/services/personalizedExerciseInjectionService.ts HEP injection (uses prescription service for values)
OktaPT-API/src/queue/personalizedExerciseInjectionQueue.ts BullMQ queue definition (HEP injection)
Okta-Mobile/services/personalizedExercisePrescriptionCoordinator.ts Mobile prefetch + poll coordinator for prescription suggestions
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
Okta-Mobile/services/exerciseHistoryService.ts Mobile exercise history service
OktaPT-FE/pages/patient/past-workouts.tsx Past workouts page with Sessions/Exercises tabs
OktaPT-FE/lib/api/personalizedVideoApi.ts FE API client for personalized video endpoints
OktaPT-FE/lib/types/personalizedVideo.ts PersonalizedVideoListItem type
OktaPT-FE/lib/types/exerciseHistory.ts PatientExerciseHistoryItem type
OktaPT-FE/components/personalizedVideo/PersonalizedVideoPlaybackModal.tsx Video playback modal