Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout AssignmentsWorkout assignments — behavior spec

Workout assignments — behavior spec

State machine

┌────────── publish=false (draft) ─────────┐ │ │ created (assigned) ── publish_at? ── morning-of-job ──> publish=true ┌──────────────────────────────────┐ │ member sees in my-week / today │ └──────────────────────────────────┘ ┌─────────────────────────────┼─────────────────────────────┐ ▼ ▼ ▼ POST :id/complete POST :id/skip result logged → auto-completed status='completed' status='skipped' status='completed' + completed_at + completed_at=now + completed_at=now + result row anchored to snapshot

status enum (assignment_status): assigned / completed / skipped. kind enum (assignment_kind, FIT-159): workout / rest / note.

Lazy-fork lifecycle (FIT-152)

Two states for the (workout_id, snapshot_workout_id) pair on a kind='workout' assignment:

Statesnapshot_workout_idTrigger
Pointer= workout_idInsert. No snapshot row exists.
Forked!= workout_idFirst edit-from-cell or first result completion. Deep-copy lives at this id.

Transition is performed by WorkoutAssignmentsService.forkSnapshotIfNeeded. It runs inside a transaction with SELECT … FOR UPDATE on the assignment row to serialize concurrent forks. The deep copy carries movements, sections, prescriptions, AND per-movement comments (with reply threading preserved via a per-fork commentMap).

Non-workout kinds (rest / note) skip the lazy-fork lifecycle entirely. forkSnapshotIfNeeded rejects these with 400.

Invariants

  • kind='workout' ⇒ both workout_id and snapshot_workout_id not null. DB CHECK workout_assignments_kind_payload_chk.
  • kind='rest' ⇒ both pointers null, note null.
  • kind='note' ⇒ both pointers null, note not null.
  • organizationId is denormalized on the assignment row for org-scoped reads / future RLS — saves a join through the workout on every coach-overview / week-grid query.
  • Pointer state on insert. All three assign endpoints (assignPersonal, legacy create, template applyToCoaching) set snapshot_workout_id = workout_id initially.
  • Snapshot deep copy is idempotent. If snapshot_workout_id !== workout_id already, forkSnapshotIfNeeded returns didFork=false and the existing snapshot id.
  • Soft delete only. workout_results and personal_records may reference an assignment indefinitely. DELETE /:id sets deleted_at; physical delete is never run.
  • Members can only read own assignments. findMyOne puts userId in the WHERE clause (not a post-hoc check) so unauthorized reads return 404. Staff (owner/admin/coach) can read any.
  • Bulk scoping intersects with program_enrollments. Bulk operations target every assignment of an enrolled member in the date window — including ad-hoc personal assignments whose programId may be NULL or for another program (workout-assignments.service.ts:1165 — FIT-201 fix).
  • Bulk publish forces filter.published=false in the service to prevent re-publishing already-published rows.
  • findMyWeek/findMyToday filter published=true. Drafts are invisible to members.

Golden path — coach assigns to athletes

  1. Coach picks a workout + date + athletes in the dashboard.
  2. UI calls POST /assignments/personal { workoutId, athleteIds, date, drip:'now'|'morning_of' }.
  3. Service validates: kind shape; if kind='workout', workout belongs to org.
  4. Insert N rows (one per athlete) with published = drip !== 'morning_of', publish_at set accordingly.
  5. Push notify each athlete whose row is published=true (skip morning-of drip rows; they’ll be picked up by the publisher).
  6. Track workout_assigned event per athlete.

Golden path — member completes a workout

  1. Member opens whiteboard. GET /assignments/today returns all published rows for today with the full snapshot workout payload.
  2. Member finishes, logs a result. POST /workouts/:workoutId/results with assignmentId. See workout-results/behavior.md.
  3. Result creation calls forkSnapshotIfNeeded(assignmentId). If the assignment is still in pointer state, the snapshot is forked here.
  4. Result row anchors to the snapshot id. Service runs an UPDATE on the assignment: status → completed, completed_at = now().

If the member instead taps “Mark complete” without logging a result, POST /:id/complete does the same status transition with no result.

Golden path — coach edits an athlete’s per-cell prescription

  1. Coach opens a member’s day in the program week view. The cell hosts a “open in builder” link with ?assignmentId=<X>.
  2. Builder loads workout_id’s payload (still equal to the snapshot pointer’s content).
  3. Coach edits a movement, saves. UI calls PATCH /workouts/:id/movements/:movementId/prescription?assignmentId=<X>.
  4. Workouts service calls forkSnapshotIfNeeded(X). Deep copy created on first edit. Subsequent edits land on the same snapshot.

Golden path — bulk publish a coaching program’s week

  1. Coach finishes drafting a week’s grid for program P (3 athletes × 5 days).
  2. UI calls POST /assignments/bulk-preview { filter: { programId: P, dateFrom, dateTo, published:false } }. Service returns { matched: 15, samples: [{ id, date, kind, workoutName }, …] }.
  3. Coach confirms. UI calls POST /assignments/bulk-publish with the same filter.
  4. Service forces filter.published=false, mutates matching rows to published=true, writes one audit_logs row keyed by a new batch_id. Optional push fan-out (TODO: verify whether bulk-publish pushes).

Edge cases & error states

TriggerHandling
Personal assign with kind='workout' and no workoutId400 “workoutId is required when kind=‘workout’”.
Personal assign with kind='rest'/'note' AND workoutId set400 “workoutId must be omitted…”.
Personal assign with kind='note' and empty note400 “note text is required when kind=‘note’”.
Feed assign on a non-feed program400 “Track must be a feed-mode program”.
Schedule assign without classTypeId400 “classTypeId is required for schedule assignment”.
forkSnapshotIfNeeded on a deleted assignment400 “Assignment has been deleted”.
forkSnapshotIfNeeded on a non-workout kind400 “Cannot fork a non-workout assignment”.
Concurrent fork attemptsSELECT … FOR UPDATE serializes; only one snapshot row created.
findMyOne for an assignment that doesn’t belong to the user (member role)404 (no existence leak).
Bulk preview with programId of an empty program (no enrolled members)Returns { matched: 0, samples: [] }.
Bulk delete with filter.userIds=[X] where X is not enrolled in filter.programIdX dropped from the intersection; X’s rows untouched.
complete on an already-completed assignmentIdempotent — UPDATE matches status='assigned' only, otherwise no-op. TODO: verify response.
Push notification failureSwallowed inside PushNotificationsService; the assign call still returns 200.

Side effects

  • Push notifications on assignPersonal (one per published row, category workoutAssigned), and indirectly on per-movement comment replies.
  • Event tracking (workout_assigned) per row.
  • Auto-completion of the assignment when a result is created against it.
  • Deep-copy on first fork writes workouts + sections + movements + comments rows in one transaction.
  • Audit log batch on bulk-delete and bulk-publish (one row keyed by batch_id).
  • Schedule assign writes class_sessions + class_session_workouts rows (status published, capacity from slot).

Permissions

ActionRequired role
Create / update / delete / publish / bulk-* / assign-personal/feed/scheduleowner, admin, coach
findMyWeek, findMyToday, complete, skip (own row), findMyOne (own row)Any member of the org
findByWeek (others), coach-overviewAny member of the org (read; member sees own; staff sees all per findMyOne semantics — TODO: verify if findByWeek also gates by self)
forkSnapshotIfNeededInternal — not exposed as an HTTP route; called by the workouts/results services