Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesProgram TemplatesProgram templates — behavior spec

Program templates — behavior spec

Delivery modes

ModeWhat the template representsApply producesRequired header fields
coachingPer-athlete programming grid (week × day × slot)workout_assignments rows, one per (cell × userId)name, durationWeeks
feedA multi-week feed sequence for a feed-mode programworkout_feed_posts rows, one per kind='workout' cellname, durationWeeks
scheduleA recurring class schedule + programming for a class typeclass_sessions + daily_programming rowstargetClassTypeId, 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 accept deliveryMode. Re-create to change mode.
  • Schedule-only fields rejected on non-schedule templates. update() filters touched schedule fields against template.deliveryMode and returns 400 (program-templates.service.ts:194).
  • Cell weekNumbertemplate.durationWeeks. upsertCells validates each cell (service.ts:282).
  • dayOffset BETWEEN 1 AND 7. Enforced by DB CHECK program_template_workouts_week_day_chk.
  • Cell payload shape by kind. DB CHECK program_template_workouts_kind_payload_chk:
    • workout ⇒ workoutId set, coachNote null
    • rest ⇒ workoutId null, coachNote null
    • note ⇒ workoutId null, coachNote not null
  • workoutId must be a library row in this org. assertLibraryWorkoutInOrg checks isSnapshot=false AND organizationId=orgId AND deletedAt 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. assertModeMatches rejects with 400 if dto.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

  1. Coach navigates to /dashboard/programs/[id]/templates and clicks “New template”.
  2. POST /organizations/:orgId/program-templates with { name, deliveryMode: 'coaching', durationWeeks: 8 }. Returns the empty header.
  3. UI opens the template builder at /dashboard/programs/[id]/templates/[templateId]/cells/[week]/[day]/edit.
  4. Coach drags workouts into cells, marks rest days, adds notes.
  5. UI saves the entire grid via POST /:id/workouts with cells[].
  6. Coach previews against a cohort: POST /:id/preview with { mode:'coaching', startDate, userIds, conflictMode }. UI displays “will create N assignments, skip M conflicts”.
  7. Coach applies: POST /:id/apply with the same body. Server inserts workout_assignments in a transaction.
  8. 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

  1. Template was created with targetClassTypeId, daysOfWeek=['monday','wednesday','friday'], startTimes=['07:00','18:00'], durationWeeks=8.
  2. Apply with { mode:'schedule', startDate:'2026-06-01' }.
  3. Server expands recurrence: for each week × active weekday × startTime, insert a class_sessions row with startsAt = toUtc(date, time, org.timezone), endsAt = startsAt + classType.defaultDurationMin, status='draft'.
  4. Server expands content: for each kind='workout' cell whose dayOffset lands on an active day, insert a daily_programming row.
  5. Sessions and programming live in draft until publish.

Golden path — from-history

  1. Coach picks a date range and a source (coaching userId, feed programId, or schedule classTypeId).
  2. POST /from-history with { source, sourceId, startDate, endDate, name }.
  3. Server reads the existing materialized rows, normalizes them into (weekNumber, dayOffset, sortOrder) cells, computes durationWeeks = max(weekNumber), and for schedule source derives daysOfWeek + startTimes from the unique weekdays/times of the source sessions.
  4. Inserts header + cells. Returns the new template id.

Edge cases & error states

TriggerHandling
Create schedule template with no targetClassTypeId / no daysOfWeek / no startTimes400 with field-specific message.
Create non-schedule template carrying schedule-only fields400 listing the forbidden fields.
Cell weekNumber > durationWeeks400 “weekNumber X exceeds durationWeeks Y”.
Cell with kind='workout' and no workoutId400 “workoutId required when kind=‘workout’”.
Cell with kind='rest' or 'note' and a workoutId400 “workoutId must be omitted…”.
Cell with kind='note' and no coachNote400 “coachNote required when kind=‘note’”.
Apply with mode !== template.deliveryMode400 from assertModeMatches.
Apply coaching with empty userIds400 “userIds required for coaching apply”.
Apply coaching with conflictMode='abort' and a conflict exists409 “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 mode400 “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 dayCounted in skipped.
from-history with endDate < startDate400 “endDate must be >= startDate”.
from-history with empty range400 “No data in the selected range”.

Side effects

  • Apply (coaching) inserts assignments without push notifications. Compare with assignPersonal, which fires firePushForAssignment per row. TODO: verify whether template apply should push.
  • Apply (schedule) writes both class_sessions and daily_programming in one transaction; failure rolls both back.
  • Apply (feed) uses fromZonedTime to convert (date, publishTimeOfDay) from the org’s timezone to UTC before storing in publishAt.
  • Soft delete cascades indirectly. Deleting a template does not touch already-applied rows; the materialization is its own data.

Permissions

ActionRequired role
All endpointsowner, admin, coach (enforced by requireCoach)

No role distinction within coach-or-above. Templates are not currently shared cross-org.