Program templates — behavior spec
Delivery modes
| Mode | What the template represents | Apply produces | Required header fields |
|---|---|---|---|
coaching | Per-athlete programming grid (week × day × slot) | workout_assignments rows, one per (cell × userId) | name, durationWeeks |
feed | A multi-week feed sequence for a feed-mode program | workout_feed_posts rows, one per kind='workout' cell | name, durationWeeks |
schedule | A recurring class schedule + programming for a class type | class_sessions + daily_programming rows | targetClassTypeId, daysOfWeek[≥1], startTimes[≥1] |
deliveryMode='course' is not allowed on templates (CHECK program_templates_delivery_mode_chk).
Schedule mode merges what used to be two artifacts: recurrence (daysOfWeek, startTimes) and content (the cell grid). Both must be present at create time for schedule templates; the CHECK constraint program_templates_schedule_fields_chk rejects mismatched shapes.
Lifecycle
draft header ── upsertCells ──> design state (cells filled in)
│
├─ preview ──> dry-run result (no writes)
├─ duplicate ──> new template (copy)
└─ apply ──> materialized target rows
(assignments | feed posts | sessions+programming)A template is never “completed” — it stays usable until deletedAt is set or isActive=false removes it from active list filters. Templates can be applied many times.
Invariants
- Mode immutable after create.
update()does not acceptdeliveryMode. Re-create to change mode. - Schedule-only fields rejected on non-schedule templates.
update()filters touched schedule fields againsttemplate.deliveryModeand returns 400 (program-templates.service.ts:194). - Cell
weekNumber≤template.durationWeeks.upsertCellsvalidates each cell (service.ts:282). dayOffset BETWEEN 1 AND 7. Enforced by DB CHECKprogram_template_workouts_week_day_chk.- Cell payload shape by
kind. DB CHECKprogram_template_workouts_kind_payload_chk:workout⇒ workoutId set, coachNote nullrest⇒ workoutId null, coachNote nullnote⇒ workoutId null, coachNote not null
workoutIdmust be a library row in this org.assertLibraryWorkoutInOrgchecksisSnapshot=falseANDorganizationId=orgIdANDdeletedAt IS NULL.- Whole-template replace on
upsertCells. The endpoint deletes all cells then inserts the new set in one transaction. - Apply mode must match template mode.
assertModeMatchesrejects with 400 ifdto.mode !== template.deliveryMode. - All apply work runs in a transaction so a failure halfway through leaves no orphans.
Golden path — coach builds and applies a coaching template
- Coach navigates to
/dashboard/programs/[id]/templatesand clicks “New template”. POST /organizations/:orgId/program-templateswith{ name, deliveryMode: 'coaching', durationWeeks: 8 }. Returns the empty header.- UI opens the template builder at
/dashboard/programs/[id]/templates/[templateId]/cells/[week]/[day]/edit. - Coach drags workouts into cells, marks rest days, adds notes.
- UI saves the entire grid via
POST /:id/workoutswithcells[]. - Coach previews against a cohort:
POST /:id/previewwith{ mode:'coaching', startDate, userIds, conflictMode }. UI displays “will create N assignments, skip M conflicts”. - Coach applies:
POST /:id/applywith the same body. Server insertsworkout_assignmentsin a transaction. - Each new assignment is in pointer state (
snapshotWorkoutId = workoutId); the lazy-fork lifecycle kicks in on per-cell edits — see workouts.
Golden path — coach applies a schedule template
- Template was created with
targetClassTypeId,daysOfWeek=['monday','wednesday','friday'],startTimes=['07:00','18:00'],durationWeeks=8. - Apply with
{ mode:'schedule', startDate:'2026-06-01' }. - Server expands recurrence: for each week × active weekday × startTime, insert a
class_sessionsrow withstartsAt = toUtc(date, time, org.timezone),endsAt = startsAt + classType.defaultDurationMin,status='draft'. - Server expands content: for each
kind='workout'cell whosedayOffsetlands on an active day, insert adaily_programmingrow. - Sessions and programming live in draft until publish.
Golden path — from-history
- Coach picks a date range and a source (
coachinguserId,feedprogramId, orscheduleclassTypeId). POST /from-historywith{ source, sourceId, startDate, endDate, name }.- Server reads the existing materialized rows, normalizes them into
(weekNumber, dayOffset, sortOrder)cells, computesdurationWeeks = max(weekNumber), and forschedulesource derivesdaysOfWeek+startTimesfrom the unique weekdays/times of the source sessions. - Inserts header + cells. Returns the new template id.
Edge cases & error states
| Trigger | Handling |
|---|---|
Create schedule template with no targetClassTypeId / no daysOfWeek / no startTimes | 400 with field-specific message. |
| Create non-schedule template carrying schedule-only fields | 400 listing the forbidden fields. |
Cell weekNumber > durationWeeks | 400 “weekNumber X exceeds durationWeeks Y”. |
Cell with kind='workout' and no workoutId | 400 “workoutId required when kind=‘workout’”. |
Cell with kind='rest' or 'note' and a workoutId | 400 “workoutId must be omitted…”. |
Cell with kind='note' and no coachNote | 400 “coachNote required when kind=‘note’”. |
Apply with mode !== template.deliveryMode | 400 from assertModeMatches. |
Apply coaching with empty userIds | 400 “userIds required for coaching apply”. |
Apply coaching with conflictMode='abort' and a conflict exists | 409 “Assignment already exists for user X on date Y (slot S)”. |
Apply coaching with conflictMode='overwrite' | Existing rows soft-deleted (their results survive FK); new rows inserted. |
Apply feed with programId not in feed mode | 400 “Target program is not in feed mode”. |
Apply feed cell with kind='rest' or 'note' | Counted in skipped, not inserted. |
Apply schedule cell on a non-active day | Counted in skipped. |
from-history with endDate < startDate | 400 “endDate must be >= startDate”. |
from-history with empty range | 400 “No data in the selected range”. |
Side effects
- Apply (coaching) inserts assignments without push notifications. Compare with
assignPersonal, which firesfirePushForAssignmentper row. TODO: verify whether template apply should push. - Apply (schedule) writes both
class_sessionsanddaily_programmingin one transaction; failure rolls both back. - Apply (feed) uses
fromZonedTimeto convert(date, publishTimeOfDay)from the org’stimezoneto UTC before storing inpublishAt. - Soft delete cascades indirectly. Deleting a template does not touch already-applied rows; the materialization is its own data.
Permissions
| Action | Required role |
|---|---|
| All endpoints | owner, admin, coach (enforced by requireCoach) |
No role distinction within coach-or-above. Templates are not currently shared cross-org.