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
FlatListwith client-side filtering by first + last name - Each row shows initials avatar and full name
- On selection, navigates to exercise search with
patientIdandpatientNameparams
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-videoplayback 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:
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}.mp4paths 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:
- Generate signed S3 GET URL for the uploaded video
- Copy to Cloudflare Stream via
POST /stream/copyto the Cloudflare API - Poll readiness -- up to 30 attempts, 5 seconds apart (~2.5 minutes max), waiting for
readyToStream === true - Validate duration -- if over 120 seconds, delete the Cloudflare asset and mark as
FAILED - Activate in a Prisma transaction:
- Deactivate any existing active video for the same (patient, exercise) pair (
isActive: false,status: INACTIVE) - 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:
- API —
GET /v2/exercises?patientId=<id>(called from any patient-scoped therapist flow) verifies that the requesting doctor has an active connection to the patient (returns403with{ error: "No active connection with this patient" }otherwise) and attachespersonalizedVideo: { id, cloudflareStreamId, updatedAt } | nullto each returned exercise. Verified bysrc/routes/__tests__/exercises-personalized.test.ts. - Web —
OktaPT-FE/components/ExerciseSelector/ExerciseLibraryDialog.tsxrenders a pill-style scope selector (Personalized · All Exercises · My Exercises · Favorites). The "Personalized" pill is shown only when apatientIdis provided and filters to exercises withpersonalizedVideo.cloudflareStreamId. Selecting it shows the personalized thumbnail in each card (via the standard priority chain). - Mobile —
Okta-Mobile/components/doctor/hep/HepExercisePickerModal.tsxexposes 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:
- 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 aPersonalizedExercisePrescriptionSchemawithsets,repsOrDuration, andtrackingType(REPS_BASEDorTIME_BASED). - Historical fallback — if the AI call fails, uses the most recent prescription for this exercise from any prior workout.
- 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:
- Doctor selects a patient and exercise in the video recording flow
- Mobile calls
POST /prescription-suggestionswith{ patientId, exerciseId }— the API enqueues a BullMQ job (or processes inline in dev) and returns asuggestionIdwith statusPENDING - Mobile polls
GET /prescription-suggestions/:iduntil status isCOMPLETEDorFAILED - 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:
- Reschedule overdue workouts — any past-dated incomplete workouts are rescheduled forward via
rescheduleOverdueWorkouts - 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_WORKblock appended to the end - 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 |