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 |
schedule | A recurring weekly board — one slot per class type, each with independent (day, time) occurrences | class_sessions rows (pure recurrence) | name, durationWeeks, slots[≥1] |
deliveryMode='course' is not allowed on templates (CHECK program_templates_delivery_mode_chk).
Schedule recurrence lives in program_template_slots: one row per class type, carrying occurrences[] as independent (day, time) pairs. That replaced the old daysOfWeek × startTimes cross-product (FIT-250), which couldn’t express “Wed 08:00 and Tue 09:00” without also implying Wed 09:00 and Tue 08:00. At least one slot is required for schedule mode, enforced at the application layer.
Legacy columns.
program_templatesstill carriestarget_class_type_id,days_of_week,start_times,default_location_id,default_coach_membership_idfrom the pre-FIT-250 shape. Nothing writes them any more andtoResponsedoes not return them; the CHECKprogram_templates_schedule_fields_chknow only asserts they stay NULL on non-schedule templates. The web’sTemplateBuildercarried a matching stranded branch (anisRecurrenceOnly“Open Gym” hint keyed ontargetClassTypeId); it was deleted, along with therecurrenceOnlyHint/recurrenceOnlyBadgedictionary keys. The builder is a coaching-only surface —program-templates-tab.tsxroutes schedule templates straight to the Apply dialog and never opens it for them.
Lifecycle
draft header ── upsertCells ──> design state (cells filled in)
│
├─ preview ──> dry-run result (no writes)
├─ duplicate ──> new template (copy)
└─ apply ──> materialized target rows
(assignments | class sessions)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. - Slots rejected on non-schedule templates.
create()andupdate()both checkslotsagainsttemplate.deliveryModeand return 400. - 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.
- Schedule apply never stacks sessions. Every planned session is measured against the live schedule first — see Schedule apply — conflicts.
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
durationWeeks: 8andslots: [{ classTypeId: <WOD>, occurrences: [{day:'monday', time:'07:00'}, {day:'wednesday', time:'18:00'}] }]. - Preview with
{ mode:'schedule', startDate:'2026-06-01' }returns what apply will do, including any conflicts (below). - Apply with the same body plus an optional
conflictMode. - Server expands each occurrence onto the ACTUAL calendar date of its weekday — the first such weekday on/after
startDate, then weekly fordurationWeeks— inserting aclass_sessionsrow withstartsAt = toUtc(date, time, org.timezone),endsAt = startsAt + (classType.defaultDurationMin ?? 60),status='draft'. One session per occurrence per week; no cross-product. - Apply is pure recurrence — it materializes no
daily_programming. Workouts attach per class-type-per-day through the daily board. - Sessions live in draft until publish.
Schedule apply — conflicts
Apply used to insert its sessions blind, so applying a template twice doubled the calendar. It now measures the expansion against what’s already scheduled.
What counts as a conflict: the same class type, same calendar date, whose [startsAt, endsAt) intersects a live session (deletedAt IS NULL, status not cancelled/archived). Different class types never conflict — WOD and Yoga at 07:00 are two rooms. Overlap rather than equal start times, so an existing 07:30 catches a planned 07:00 for a 60-minute class. Coach/room double-booking across different class types is not covered yet.
Detection is pure (session-conflicts.ts) and shared by preview and apply, so the preview’s counts are exactly what apply will do. Two indexed queries load the candidate sessions and their live booking counts (status <> 'cancelled', not soft-deleted); the intervals are compared in memory.
The booking count is a grouped query, not a correlated subquery. A raw
sql`(SELECT COUNT(*) FROM bookings WHERE class_session_id = id)`fragment renders its column refs unqualified, soidbound tobookings.idinside the subquery and every count came back 0 — silently lettingoverwritedelete booked sessions. Caught byschedule-apply-conflicts.int.spec.ts; see the Database Policy note in CLAUDE.md.
conflictMode (schedule):
| Mode | Behavior |
|---|---|
skip (default) | Creates the non-conflicting sessions, reports skipped. Re-applying a template is idempotent. |
overwrite | Soft-deletes the clashing session (audit-logged as class_session.delete with reason: 'program_template_apply_overwrite') and inserts the planned one — only when nobody has booked it. A session with any non-cancelled booking degrades to a skip and is counted in blocked. |
abort | Writes nothing, 409 with the grouped conflicts. |
Response shape. Conflicts are grouped by (classTypeId, day, time) — the coordinates of one time input in the template editor — with count, blockedCount and up to 10 sample dates. A 52-week template with one bad occurrence is one row, not 52. Preview returns plannedCount (what the template describes), sessionCount (what will be created under the chosen mode), conflictCount, blockedCount and conflicts[]; apply returns created, skipped, overwritten, blocked and the same conflicts[].
Slot invariants (editor)
- Occurrences are stored deduped and (day, time) ordered by
normalizeOccurrenceson every write, so no client can persist the same(day, time)twice. - The editor rejects two times of the same class type overlapping (interval math off
defaultDurationMin, fallback 60) and start times outside 06:00–21:00. Both block the form’s submit. - There is no cap on times per day or per week.
ScheduleSlotDto’s@ArrayMaxSize(168)is a payload ceiling only.
Golden path — from-history
- Coach picks a date range and a source (
coachinguserId 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 for aschedulesource captures one slot whoseoccurrences[]are the unique(weekday, time)pairs of the source sessions. - Inserts header + cells. Returns the new template id.
Edge cases & error states
| Trigger | Handling |
|---|---|
Create schedule template with no slots | 400 “At least one class type slot is required for schedule templates”. |
Send slots on a non-schedule template | 400 “slots are only valid for schedule templates”. |
A slot with an empty occurrences[] | 400 (@ArrayMinSize(1)), and DB CHECK program_template_slots_recurrence_chk. |
| 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 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
class_sessionsonly — plus, underoverwrite, the soft-delete of what it replaced and the matchingaudit_logsrows. All in one transaction; failure rolls the lot back. - 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.