Workouts — behavior spec
Workout modes
| Mode | Tier needed | Storage | Use |
|---|---|---|---|
structured | workout_builder | workouts row + workout_sections[] + workout_movements[] with prescription JSONB | The default builder output. Members see formatted sections; results may capture per-set data. |
freeform | basic_workouts | workouts row only; body in description | Quick text post (legacy / Lite tier / spotter agent fallback). No sections. |
Flipping freeform → structured on an existing workout via PATCH /:id re-checks the tier gate. Flipping structured → freeform is allowed without tier check; sections persist but are ignored.
Lazy-fork lifecycle (FIT-152)
Two states for any non-snapshot workout row:
| State | is_snapshot | forked_from_id | Visibility |
|---|---|---|---|
| Library template | false | NULL | Appears in coach library lists; edited by coaches. |
| Snapshot | true | source library id | Hidden from library lists; one per assignment-that-was-edited; immutable for deletion. |
State machine for (assignment, workout) pair after assignment is created:
assignment.workoutId = LibA
assignment.snapshotWorkoutId = LibA (pointer state — no snapshot row exists)
│
│ first edit-from-cell OR first result completion via assignmentId
▼
deepCopyWorkout(LibA) → SnapA
- SnapA.isSnapshot = true
- SnapA.forkedFromId = LibA
- movement comments copied LibA → SnapA
assignment.snapshotWorkoutId = SnapA (forked state)All subsequent edits with ?assignmentId=X land on SnapA. The library row LibA is never mutated by per-athlete edits.
Invariants
- Library list (
GET /organizations/:orgId/workouts) always filtersis_snapshot = falseANDdeleted_at IS NULL(apps/api/src/workouts/workouts.service.ts:236). - Snapshot rows cannot be soft-deleted. DB CHECK
workouts_snapshot_immutable_chkenforces it; the service surfaces a clean 400 (workouts.service.ts:382). - Snapshot rows must carry
forked_from_id. DB CHECKworkouts_snapshot_provenance_chk. - Movement
exerciseIdmust resolve to a canonical row (organization_id IS NULL) OR an org-owned row. Validated before insert in bothcreateandsetSections. - Cross-org movement IDs cannot leak through
updateMovementPrescription. Service joins through section → workout → org and re-checks the WHERE clause on UPDATE (defense in depth). workoutIdreferenced by an assignment / result lives forever. Soft-delete on the workout is allowed only whenis_snapshot=false. Assignments/results still load throughsnapshotWorkoutId/libraryWorkoutId.- Comments survive forking. On
deepCopyWorkout, every per-movement comment on the source movements is duplicated onto the snapshot’s sibling movements with reply threading preserved (workout-assignments.service.ts:938). - Snapshot id resolution by
(section.sortOrder, movement.sortOrder)— frontend may still hold library-row movement ids after a fork; service maps through (workouts.service.ts:789mapMovementToSnapshot). - Structured mode requires
workout_buildertier at create + flip-from-freeform +setSectionswith non-empty sections (requireWorkoutBuilder).
Golden path — coach builds and publishes a workout
- Coach navigates to
/dashboard/workouts/new/builder. - UI fetches exercise candidates via
GET /organizations/:orgId/exercises/library(paginated, merged canonical+custom). - Coach adds a title, picks scoring (
time,rounds_reps, …), drags in sections (warmup / strength / metcon / cooldown), and adds movements with letter labels / supersets / prescriptions. - UI calls
POST /organizations/:orgId/workoutswith the fullsections[]payload in one request. - Server validates exercise ids, opens a transaction, inserts the workout + sections + movements, returns the detail response.
- Background BullMQ job enqueues to
workouts-enrichmentfor embedding. The write does not wait. - Coach (or program template) later issues a
POST /assignments/personal/POST /assignments/feed/POST /assignments/schedulereferencing the workout.
Golden path — member runs and edits a snapshot
- Member opens
/[lang]/(member)/whiteboardfor today. - Web fetches
GET /organizations/:orgId/assignments/today; each row includes the full snapshot workout (sections + movements + exercise metadata). - Member completes the workout, logs a result via
POST /workouts/:workoutId/resultswithassignmentId. The result anchors to the snapshot id; the assignment auto-transitionsassigned → completed.
If, between assignment and completion, the coach changed the snapshot prescription via PATCH .../movements/:movementId/prescription?assignmentId=..., the member sees the per-athlete edit; other athletes assigned the same library row do not.
Edge cases & error states
| Trigger | Handling |
|---|---|
| Create a structured workout on a Lite-tier org | 403 with message pointing at mode='freeform' or upgrade. |
Create with cross-org exerciseId in sections[].movements[] | 400 “One or more exercises not found in this organization or the canonical library.” |
Update with a programId not in this org | 400 “Program not found in this organization.” |
| Delete a snapshot row directly | 400 “Cannot delete a snapshot workout — it is referenced by historical results.” |
Edit-from-cell with ?assignmentId of a rest/note assignment | 400 “Cannot fork a non-workout assignment” (from forkSnapshotIfNeeded). |
Edit-from-cell with ?assignmentId of a soft-deleted assignment | 400 “Assignment has been deleted.” |
| Concurrent edits to the same not-yet-forked assignment | SELECT … FOR UPDATE on the assignment row serializes; only one snapshot is created. |
Update movement prescription with a movement id that lives in a different workout (and no ?assignmentId) | 404 “Movement not found.” |
| Comment with neither body nor attachments | 400 “Comment must have body or attachments.” |
| Embedding enrichment enqueue fails | Logged as a warning; write completes normally. |
Side effects
workouts-enrichmentBullMQ job on every create / update / setSections (best-effort enqueue, never blocks).- Deep-copy on first lazy-fork writes
workouts+workout_sections+workout_movements+exercise_commentsrows in a single transaction. - Push notification on per-movement comment reply (
category: 'newComment', route/(tabs)/workouts/{workoutId}/exercise/{movementId}) to the parent comment author when the author is not the replier. - R2 thumbnail generation for image attachments on comments (sharp resize to 400x400 JPEG; failures are non-fatal).
- R2 presigned URLs (1h TTL, cached) on every comment list response so attachments render.
Permissions
| Action | Required role |
|---|---|
| Create / update / delete workout | owner, admin, coach |
| Set sections | owner, admin, coach |
| Update movement prescription | Workout-access gate (membership in org) + ownership of the workout’s org |
| List / read workouts | Any member of the org |
| List movement comments | Any member of the org |
| Create movement comment | Any member of the org |
All routes are protected by the global AuthGuard. Tier-gated routes carry @RequiresFeature('basic_workouts') and structured-mode writes call requireWorkoutBuilder defensively inside the service.
Audit-relevant actions
The workouts module itself does not write to audit_logs. Assignment-level bulk operations (which can mutate a coach’s assignment grid) are audit-logged inside workout-assignments. Workout-level deletes / forks are recoverable via the snapshot chain (forkedFromId).