Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkoutsWorkouts — behavior spec

Workouts — behavior spec

Workout modes

ModeTier neededStorageUse
structuredworkout_builderworkouts row + workout_sections[] + workout_movements[] with prescription JSONBThe default builder output. Members see formatted sections; results may capture per-set data.
freeformbasic_workoutsworkouts row only; body in descriptionQuick 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:

Stateis_snapshotforked_from_idVisibility
Library templatefalseNULLAppears in coach library lists; edited by coaches.
Snapshottruesource library idHidden 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 filters is_snapshot = false AND deleted_at IS NULL (apps/api/src/workouts/workouts.service.ts:236).
  • Snapshot rows cannot be soft-deleted. DB CHECK workouts_snapshot_immutable_chk enforces it; the service surfaces a clean 400 (workouts.service.ts:382).
  • Snapshot rows must carry forked_from_id. DB CHECK workouts_snapshot_provenance_chk.
  • Movement exerciseId must resolve to a canonical row (organization_id IS NULL) OR an org-owned row. Validated before insert in both create and setSections.
  • 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).
  • workoutId referenced by an assignment / result lives forever. Soft-delete on the workout is allowed only when is_snapshot=false. Assignments/results still load through snapshotWorkoutId/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:789 mapMovementToSnapshot).
  • Structured mode requires workout_builder tier at create + flip-from-freeform + setSections with non-empty sections (requireWorkoutBuilder).

Golden path — coach builds and publishes a workout

  1. Coach navigates to /dashboard/workouts/new/builder.
  2. UI fetches exercise candidates via GET /organizations/:orgId/exercises/library (paginated, merged canonical+custom).
  3. Coach adds a title, picks scoring (time, rounds_reps, …), drags in sections (warmup / strength / metcon / cooldown), and adds movements with letter labels / supersets / prescriptions.
  4. UI calls POST /organizations/:orgId/workouts with the full sections[] payload in one request.
  5. Server validates exercise ids, opens a transaction, inserts the workout + sections + movements, returns the detail response.
  6. Background BullMQ job enqueues to workouts-enrichment for embedding. The write does not wait.
  7. Coach (or program template) later issues a POST /assignments/personal / POST /assignments/feed / POST /assignments/schedule referencing the workout.

Golden path — member runs and edits a snapshot

  1. Member opens /[lang]/(member)/whiteboard for today.
  2. Web fetches GET /organizations/:orgId/assignments/today; each row includes the full snapshot workout (sections + movements + exercise metadata).
  3. Member completes the workout, logs a result via POST /workouts/:workoutId/results with assignmentId. The result anchors to the snapshot id; the assignment auto-transitions assigned → 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

TriggerHandling
Create a structured workout on a Lite-tier org403 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 org400 “Program not found in this organization.”
Delete a snapshot row directly400 “Cannot delete a snapshot workout — it is referenced by historical results.”
Edit-from-cell with ?assignmentId of a rest/note assignment400 “Cannot fork a non-workout assignment” (from forkSnapshotIfNeeded).
Edit-from-cell with ?assignmentId of a soft-deleted assignment400 “Assignment has been deleted.”
Concurrent edits to the same not-yet-forked assignmentSELECT … 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 attachments400 “Comment must have body or attachments.”
Embedding enrichment enqueue failsLogged as a warning; write completes normally.

Side effects

  • workouts-enrichment BullMQ job on every create / update / setSections (best-effort enqueue, never blocks).
  • Deep-copy on first lazy-fork writes workouts + workout_sections + workout_movements + exercise_comments rows 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

ActionRequired role
Create / update / delete workoutowner, admin, coach
Set sectionsowner, admin, coach
Update movement prescriptionWorkout-access gate (membership in org) + ownership of the workout’s org
List / read workoutsAny member of the org
List movement commentsAny member of the org
Create movement commentAny 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).