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. findMyWeek and findMyToday constrain both userId and organizationId, so a member’s assignments from another organization cannot enter the active org conversation.
  • 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.
  • Program-bound assignments stay program-bound. Personal and template writes accept programId; legacy creates require it. When supplied, the program must be coaching-mode in the active organization and every recipient must be actively enrolled. The whole request is rejected before insertion if any recipient is outside that program. Program grids, member compliance, program stats, comments, template conflicts/history and bulk operations constrain workout_assignments.program_id.
  • 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.
  • Schedule class types are org-scoped before insert. assignSchedule verifies classTypeId → program.organizationId matches the requested organization before opening the write transaction.
  • Enrollment carries no work. Adding a member to another program only inserts or reactivates an enrollment. It does not copy, retag, or reveal assignments from other programs. NULL-owned historical assignments stay ad-hoc and outside every program grid, statistic and bulk action; ownership is never inferred from membership history.
  • Management mutations may assert scope. PATCH/DELETE /assignments/:id?programId=P and PATCH /assignments/publish with programId in the body affect only rows owned by P; a foreign-program row returns 404 (or, for publish, simply does not match). programId is an assertion, not a filter — omitting it resolves the id in whichever program owns it, so agent tools and clients that cannot know the program still reach the row. Publish returns { count, assignmentIds } for the rows actually written, never the ids requested.
  • Member self-service stays aggregate. Members can read their own published work and complete/skip/log results across their programs. Organization-wide member calendars remain aggregate views; enrollment does not change row ownership.

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. Morning-of rows stay silent until ProgramSessionsService.publishDue releases them at publish_at (checked every minute); the release clears publish_at, publishes the session and its unsettled rows, and sends the push then. A row or session the coach hid in the meantime has no publish_at and is never released. Each row flips in one conditional UPDATE, so an overlapping tick cannot push twice.
  6. Track workout_assigned event per athlete.

Golden path — coach assigns a NEW workout to one member’s day (paste-first)

The coaching plan’s member × day grid (member-week-grid.tsx) is paste-first for a brand-new workout — the structured builder is only reached as an explicit “Adjust” step, never the default.

  1. Coach clicks an empty cell’s “Workout” action. WorkoutPickerSheet opens in create mode, scoped to that (membershipId, dateIso).
  2. Coach pastes workout text into the sheet’s primary PasteInput section and submits. POST /organizations/:orgId/workouts/parse returns a ParseDraft; the sheet opens StructureReviewDialog on it (see workout-parse/behavior.md).
  3. Coach confirms. Commit creates the structured workout (non-library unless the coach ticks “Also save to Workouts for reuse”) and resolves the SAME onSave(workoutId, date) callback picking a saved workout from the list below would have.
  4. The sheet’s onSave fires POST /assignments/personal exactly as in the golden path above, with athleteIds: [userId], the single-day date, and the current programId. The assignment write path never forks on how workoutId was produced.
  5. “Pick a saved one” (the library list, now secondary) and the quiet “Build from scratch” link (the old default, now the fallback) remain available in the same sheet; both still resolve through step 4’s mutation, or hand off to the per-member/day builder route.

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>&programId=<P> (or the builder’s workout/sections endpoints with the same scope).
  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 only assignment rows whose own program_id is P: { 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).

Golden path — replace or clear one coaching program day

  1. Coach opens a program grid day header. Replace workout chooses a library workout; Remove day opens a destructive confirmation for that exact program/date.
  2. Replace calls POST /assignments/program-day/replace with { programId, workoutId, date }; removal calls POST /assignments/program-day/remove with { programId, date }.
  3. Both paths verify coach-or-above permission and coaching-program ownership. Replacement also resolves the current active roster before opening its transaction.
  4. Replacement soft-deletes that program/date’s rows except those already completed or skipped, deep-copies the selected workout into one immutable release, and inserts one published assignment for each active enrollee who does not still hold a preserved row. Removal soft-deletes every matching row, completed ones included. A replacement failure rolls back both the deletion and the inserts.
  5. Both take a transaction-scoped Postgres advisory lock on (org, program, date) and resolve rows by predicate (program_id, date, deleted_at IS NULL) inside the transaction. Predicate scoping alone is not enough: on an empty day two concurrent replaces would each clear nothing, see no survivor, and insert a full roster. Other programs and ad-hoc (program_id IS NULL) work remain untouched.
  6. Both write one audit_logs row naming the program, date and affected assignment ids.
  7. The confirmation preview counts the rows each write will touch. Removal is shown every row on the day; replacement is shown the rows it clears plus the rows it will issue, so a replace on an empty day reads as the whole roster rather than zero. The dashboard’s “Remove day” dialog reads the same preview rather than counting the members drawn in the grid, which omits former enrollees who still own rows.

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’”.
Schedule assign without classTypeId400 “classTypeId is required for schedule assignment”.
Schedule assign with a class type from another organization400 “Class type not found in this organization”; no transaction or assignment insert occurs.
Client-supplied assignmentId outside the org, not referencing the target workout, or (for results) not assigned to the caller404 from requireAssignmentForWorkout, which every caller runs before forkSnapshotIfNeeded.
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.
Program-day replacement with no active enrollee400; existing assignments are untouched.
Program-day call with a foreign or schedule-mode programId404 / 400 from the program check, which runs before the roster lookup.
Program-day replacement where every enrollee already completed the dayNothing is cleared, nothing is inserted, no push; { replacedAssignments: 0, preservedAssignments: N }.
Program-day replacement for a past dateRows are replaced, but no “new workout” push is sent.
Publish with ids outside the asserted programThose ids match nothing; the response count reflects only the rows written.
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).
  • Program-day replacement soft-deletes then creates rows in one transaction under an advisory lock, and writes one audit_logs row. Newly published assignments push only when the date has not already passed in the org timezone. Preserved rows are matched by status (completed, skipped), never inferred from “whatever survived the clear” — a row a concurrent request inserted is live but not preserved.
  • Program-day removal soft-deletes the day in one transaction and writes one audit_logs row.
  • Schedule assign writes class_sessions + class_session_workouts rows (status published, capacity from slot).

Permissions

ActionRequired role
Create / update / delete / publish / bulk-* / assign-personal / program-day replacement/removal / 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