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)

  • The library is loaded once (useExercises, with the patient's personalized flags) and searched on the phone as the therapist types (lib/exerciseSearch.ts: words in any order, plurals and one-letter slips forgiven); where the clinic has hepSmartSearch, matches by meaning are added after a pause in typing
  • Rows are name and caption only (category, "Personalized video", "AI match", "Yours") with a chevron: no thumbnail or play button, since the therapist is about to film their own clip. components/doctor/personalizedVideo/RecordExerciseRow.tsx
  • An exercise named exactly what was typed is always the first row, above the matches by meaning (findExactNameMatches + the exactMatches bucket of buildExerciseDisplayList)
  • The green Create exercise "…" bar sits at the top of the list as soon as something is typed, unless an exercise with exactly that name already exists; tapping it creates the exercise (category "OTHER") and goes straight to recording
  • With Mine or Personalized selected, an exactly named exercise that only exists under All is not lost: in place of the create bar a row says "“Squat” is under All · Show" and switches to All
  • 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

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.

Videos that start in the library (2026-10-21)

Therapists also film patients from the Exercises tab: a new exercise, the patient in its video, kept private and assigned to that one patient. The clip then sits in the library instead of on the patient's record (it cannot be found by patient, a re-record does not replace it, and it is one "Public" switch away from the whole clinic). Three places catch this, all behind the same therapistPersonalizedExerciseVideo flag:

  • The create form asks who the video is for. As soon as a video is attached in components/exercises/ExerciseForm.tsx (recorded or picked from the camera roll), a row under it asks "Who is this video for?" with two tiles: Everyone and One patient (components/exercises/VideoAudienceRow.tsx). "One patient" opens the therapist's patients in a sheet (components/exercises/VideoPatientSheet.tsx, from GET /v2/personalized-exercise-videos/patients); clips over 120 s are refused there with the same limit as the recording flow. With a patient chosen the Public switch disappears and Save reads "Save and add to {Name}'s plan". Saving then creates the exercise private with no library video and sends the clip down the personalized path (startPersonalizedVideoBackgroundUpload, then Manage HEP with the postUpload* params, exactly as record.tsx does), so the exercise joins the plan and the dosage editor opens. The clip is handed to the patient's record right after the exercise is created, before its photo and tags: if the upload cannot start the form stays with the clip and offers Try again (or leave without it), and a photo or tags that fail afterwards are reported before the plan opens. Changing the video re-checks the 2-minute limit. Save refuses until the question is answered; the hint under the tiles turns red (and is announced to screen readers). Everyone uploads the video onto the exercise as before and is remembered on the device (services/demoVideoMemory.ts), so the picker below never asks about that exercise. The row only appears for a new video in the create screen; edit keeps the Video field as it was.
  • The HEP picker asks when such an exercise goes into a plan. On Add, HepExercisePickerModal looks at the picked exercises for one that is the signed-in therapist's, private, with a video they uploaded that is already on Cloudflare (cloudflareStreamId), that nothing beyond this patient uses, with no personalized video for this patient yet (lib/patientVideo.ts, shouldOfferKeepingVideoWithPatient), and that the therapist has not called a demo on this device. "Beyond this patient" comes from the API: the exercise list read for a patient (GET /v2/exercises?patientId=) carries usedWithOtherPatients on the therapist's own private exercises with an uploaded video, true when any other patient's session (completed or not) or a session with no patient, a template, or a chat card in a conversation without this patient uses the exercise, or another exercise row plays the same upload. The app asks only when the field is false, so an API without it asks nothing. It replaces the Add button with "Is this video for {Name} only?" and two buttons: Keep with {Name} or It's a demo for everyone. "It's a demo" is remembered on the device, so a therapist who films their own demos is asked once per exercise; deselecting an exercise withdraws the question. The answer travels with onApply(exercises, keepWithPatientIds); manage.tsx calls the conversion endpoint in the background while the dosage editor opens and re-reads the exercise list so the row shows the patient's video; once the picker and editor are closed (on iOS, once the modal has finished sliding away) it shows "Moved to {Name}'s videos", or an alert saying why the video stayed in the library.
  • The exercise detail screen offers it for old ones. Under the video of the therapist's own private exercise with an uploaded clip on Cloudflare, Keep with one patient opens the same patient sheet (loaded in advance); picking a patient asks "Keep this video with {Full name}?" before anything moves, and the screen re-reads the exercise in place afterwards; a refusal is shown over the still-open sheet.

The conversion is POST /v2/personalized-exercise-videos/from-exercise { patientId, exerciseId } (doctor only, flag-gated, patient access checked with verifyDoctorPatientAccess, the rule of the exercise list where the app asks). The exercise must be the caller's own, private and active in their tenant (one the clinic's auto-merge folded into another still counts: it stays live in the library), and carry a cloudflareStreamId (library uploads are copied to Cloudflare Stream on upload-complete, so nothing is re-uploaded). It answers 409 with a code: VIDEO_NOT_READY while only the S3 upload exists, NO_OWN_VIDEO with no upload at all, USED_WITH_OTHER_PATIENTS when other patients' sessions, a patientless session, a template or a chat card elsewhere use the exercise (a demo; moving it would leave them without one), SHARED_VIDEO when another exercise row plays the same stream or S3 key. A repeat of a move that already happened (the active row for the pair, from this doctor, migrationSource: "exercise-library") answers 200 with that row. In one transaction it clears cloudflareStreamId and uploadedVideoKey on the exercise only if the clip is still there (a second tap for another patient gets 409 NO_OWN_VIDEO), deactivates any active personalized video for the pair, and creates a COMPLETED, active PersonalizedExerciseVideo with the exercise's stream id and uploadedVideoKey as storageKey (migrationSource: "exercise-library"). No plan injection: the therapist is adding the exercise by hand. Verified by src/routes/personalized-exercise-videos/__tests__/fromExercise.test.ts. The helpers for "a video the therapist uploaded" live in src/services/ownExerciseVideo.ts.

Two guardrails hold regardless of the prompts: the auto-canonicalization job leaves a private exercise with its own uploaded video alone, and a merge never copies a private source's uploads onto a public target (see Canonicalization); the admin's promotion search never offers a private exercise with an uploaded video, and POST /v2/admin/platform-exercises/promote refuses one with 409. Turning Public on for an exercise with an uploaded video asks first, in the app and on the web ("Every therapist in your clinic will see this video, and whoever is in it"); in the app, Public turned on before a new video is attached asks at Save instead (unless the question was answered: Everyone is a demo, One patient keeps it private).

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=
keepExerciseVideoWithPatient(patientId, exerciseId) POST /v2/personalized-exercise-videos/from-exercise
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/from-exercise createFromExercise Keep a private library exercise's own video with one patient (doctor only): the clip becomes the patient's active personalized video and leaves the exercise; no plan injection; 409 when other patients' sessions use the exercise
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:

  • 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 (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.
  • Web — OktaPT-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).
  • Mobile — Okta-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. Settle missed sessions — past-dated incomplete workouts move to the plan's next session days via rescheduleOverdueWorkouts
  2. Inject into existing sessions — the exercise is appended (MAIN_SESSION) to every editable upcoming session that doesn't already contain it, whatever week it falls in: it is part of the plan from now on
  3. Top the plan up if needed — the same rule as everywhere else: when fewer than three session days are upcoming, the next four are added on the patient's schedule (see Plan Scheduling), each a copy of the session(s) the plan already has for that slot plus the new exercise; with no plan at all, lone-exercise sessions are created from today. They carry source: VIDEO_CONTINUATION. Nothing is added when the plan does not continue: its autoContinue is off, or the clinic switched continuation off (autoWorkoutGeneration and planContinuation both false, see Feature Flags); the exercise then goes only into the sessions that are left
  4. Move the plan's start — continuation now only repeats sessions from the first upcoming one on, so a copy never lacks the new exercise

All operations run in a single Prisma transaction under the patient's plan lock, 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/lib/patientVideo.ts Which library exercises carry the therapist's own clip (and may be one patient's)
Okta-Mobile/components/exercises/VideoAudienceRow.tsx, VideoPatientSheet.tsx "Who is this video for?" in the exercise form; the patient sheet it and the detail screen use
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