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)¶
- 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 hashepSmartSearch, 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+ theexactMatchesbucket ofbuildExerciseDisplayList) - 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-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.
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, fromGET /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 thepostUpload*params, exactly asrecord.tsxdoes), 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,
HepExercisePickerModallooks 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=) carriesusedWithOtherPatientson 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 isfalse, 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 withonApply(exercises, keepWithPatientIds);manage.tsxcalls 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:
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:
- Settle missed sessions — past-dated incomplete workouts move to the plan's next session days via
rescheduleOverdueWorkouts - 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 - 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: itsautoContinueis off, or the clinic switched continuation off (autoWorkoutGenerationandplanContinuationbothfalse, see Feature Flags); the exercise then goes only into the sessions that are left - 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 |