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.findMyWeekandfindMyTodayconstrain bothuserIdandorganizationId, so a member’s assignments from another organization cannot enter the active org conversation.- 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. - 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 constrainworkout_assignments.program_id. - Bulk publish forces
filter.published=falsein the service to prevent re-publishing already-published rows. findMyWeek/findMyTodayfilterpublished=true. Drafts are invisible to members.- Schedule class types are org-scoped before insert.
assignScheduleverifiesclassTypeId → program.organizationIdmatches 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=PandPATCH /assignments/publishwithprogramIdin the body affect only rows owned by P; a foreign-program row returns 404 (or, for publish, simply does not match).programIdis 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
- 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. Morning-of rows stay silent untilProgramSessionsService.publishDuereleases them atpublish_at(checked every minute); the release clearspublish_at, publishes the session and its unsettled rows, and sends the push then. A row or session the coach hid in the meantime has nopublish_atand is never released. Each row flips in one conditionalUPDATE, so an overlapping tick cannot push twice. - Track
workout_assignedevent 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.
- Coach clicks an empty cell’s “Workout” action.
WorkoutPickerSheetopens increatemode, scoped to that(membershipId, dateIso). - Coach pastes workout text into the sheet’s primary
PasteInputsection and submits.POST /organizations/:orgId/workouts/parsereturns aParseDraft; the sheet opensStructureReviewDialogon it (see workout-parse/behavior.md). - 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. - The sheet’s
onSavefiresPOST /assignments/personalexactly as in the golden path above, withathleteIds: [userId], the single-daydate, and the currentprogramId. The assignment write path never forks on howworkoutIdwas produced. - “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
- 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>&programId=<P>(or the builder’s workout/sections endpoints with the same scope). - 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 only assignment rows whose ownprogram_idisP:{ 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).
Golden path — replace or clear one coaching program day
- Coach opens a program grid day header. Replace workout chooses a library workout; Remove day opens a destructive confirmation for that exact program/date.
- Replace calls
POST /assignments/program-day/replacewith{ programId, workoutId, date }; removal callsPOST /assignments/program-day/removewith{ programId, date }. - Both paths verify coach-or-above permission and coaching-program ownership. Replacement also resolves the current active roster before opening its transaction.
- Replacement soft-deletes that program/date’s rows except those already
completedorskipped, 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. - 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. - Both write one
audit_logsrow naming the program, date and affected assignment ids. - 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
| 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’”. |
Schedule assign without classTypeId | 400 “classTypeId is required for schedule assignment”. |
| Schedule assign with a class type from another organization | 400 “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 caller | 404 from requireAssignmentForWorkout, which every caller runs before forkSnapshotIfNeeded. |
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. |
| Program-day replacement with no active enrollee | 400; existing assignments are untouched. |
Program-day call with a foreign or schedule-mode programId | 404 / 400 from the program check, which runs before the roster lookup. |
| Program-day replacement where every enrollee already completed the day | Nothing is cleared, nothing is inserted, no push; { replacedAssignments: 0, preservedAssignments: N }. |
| Program-day replacement for a past date | Rows are replaced, but no “new workout” push is sent. |
| Publish with ids outside the asserted program | Those ids match nothing; the response count reflects only the rows written. |
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). - Program-day replacement soft-deletes then creates rows in one transaction under an advisory lock, and writes one
audit_logsrow. 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_logsrow. - Schedule assign writes
class_sessions+class_session_workoutsrows (statuspublished, capacity from slot).
Permissions
| Action | Required role |
|---|---|
| Create / update / delete / publish / bulk-* / assign-personal / program-day replacement/removal / 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 |