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 snapshotstatus 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:
| State | snapshot_workout_id | Trigger |
|---|---|---|
| Pointer | = workout_id | Insert. No snapshot row exists. |
| Forked | != workout_id | First 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'⇒ bothworkout_idandsnapshot_workout_idnot null. DB CHECKworkout_assignments_kind_payload_chk.kind='rest'⇒ both pointers null,notenull.kind='note'⇒ both pointers null,notenot null.organizationIdis 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, legacycreate, templateapplyToCoaching) setsnapshot_workout_id = workout_idinitially. - Snapshot deep copy is idempotent. If
snapshot_workout_id !== workout_idalready,forkSnapshotIfNeededreturnsdidFork=falseand the existing snapshot id. - Soft delete only.
workout_resultsandpersonal_recordsmay reference an assignment indefinitely.DELETE /:idsetsdeleted_at; physical delete is never run. - Members can only read own assignments.
findMyOneputsuserIdin 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 whoseprogramIdmay be NULL or for another program (workout-assignments.service.ts:1165— FIT-201 fix). - Bulk publish forces
filter.published=falsein the service to prevent re-publishing already-published rows. findMyWeek/findMyTodayfilterpublished=true. Drafts are invisible to members.
Golden path — coach assigns to athletes
- Coach picks a workout + date + athletes in the dashboard.
- UI calls
POST /assignments/personal{ workoutId, athleteIds, date, drip:'now'|'morning_of' }. - Service validates: kind shape; if
kind='workout', workout belongs to org. - Insert N rows (one per athlete) with
published = drip !== 'morning_of',publish_atset accordingly. - Push notify each athlete whose row is
published=true(skip morning-of drip rows; they’ll be picked up by the publisher). - Track
workout_assignedevent per athlete.
Golden path — member completes a workout
- Member opens whiteboard.
GET /assignments/todayreturns all published rows for today with the full snapshot workout payload. - Member finishes, logs a result.
POST /workouts/:workoutId/resultswithassignmentId. See workout-results/behavior.md. - Result creation calls
forkSnapshotIfNeeded(assignmentId). If the assignment is still in pointer state, the snapshot is forked here. - 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
- Coach opens a member’s day in the program week view. The cell hosts a “open in builder” link with
?assignmentId=<X>. - Builder loads
workout_id’s payload (still equal to the snapshot pointer’s content). - Coach edits a movement, saves. UI calls
PATCH /workouts/:id/movements/:movementId/prescription?assignmentId=<X>. - 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
- Coach finishes drafting a week’s grid for program
P(3 athletes × 5 days). - UI calls
POST /assignments/bulk-preview{ filter: { programId: P, dateFrom, dateTo, published:false } }. Service returns{ matched: 15, samples: [{ id, date, kind, workoutName }, …] }. - Coach confirms. UI calls
POST /assignments/bulk-publishwith the same filter. - Service forces
filter.published=false, mutates matching rows topublished=true, writes oneaudit_logsrow keyed by a newbatch_id. Optional push fan-out (TODO: verify whether bulk-publish pushes).
Edge cases & error states
| Trigger | Handling |
|---|---|
Personal assign with kind='workout' and no workoutId | 400 “workoutId is required when kind=‘workout’”. |
Personal assign with kind='rest'/'note' AND workoutId set | 400 “workoutId must be omitted…”. |
Personal assign with kind='note' and empty note | 400 “note text is required when kind=‘note’”. |
| Feed assign on a non-feed program | 400 “Track must be a feed-mode program”. |
Schedule assign without classTypeId | 400 “classTypeId is required for schedule assignment”. |
forkSnapshotIfNeeded on a deleted assignment | 400 “Assignment has been deleted”. |
forkSnapshotIfNeeded on a non-workout kind | 400 “Cannot fork a non-workout assignment”. |
| Concurrent fork attempts | SELECT … 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.programId | X dropped from the intersection; X’s rows untouched. |
complete on an already-completed assignment | Idempotent — UPDATE matches status='assigned' only, otherwise no-op. TODO: verify response. |
| Push notification failure | Swallowed inside PushNotificationsService; the assign call still returns 200. |
Side effects
- Push notifications on
assignPersonal(one per published row, categoryworkoutAssigned), 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-deleteandbulk-publish(one row keyed bybatch_id). - Schedule assign writes
class_sessions+class_session_workoutsrows (statuspublished, capacity from slot).
Permissions
| Action | Required role |
|---|---|
| Create / update / delete / publish / bulk-* / assign-personal/feed/schedule | owner, admin, coach |
findMyWeek, findMyToday, complete, skip (own row), findMyOne (own row) | Any member of the org |
findByWeek (others), coach-overview | Any member of the org (read; member sees own; staff sees all per findMyOne semantics — TODO: verify if findByWeek also gates by self) |
forkSnapshotIfNeeded | Internal — not exposed as an HTTP route; called by the workouts/results services |