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. - A schedule template belongs to exactly one program.
programIdis required oncreate()fordeliveryMode='schedule'(400 if missing) and is immutable afterward — there is noprogramIdonUpdateProgramTemplateDto. Every slot’s class type must belong to that same program; a slot from another program is rejected (400), even if the class type is in the same org. Coaching templates don’t carry a program. - 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.
- Coaching application declares assignment ownership.
previewandapplyacceptprogramId; program views always supply it. Every target athlete must be actively enrolled in that coaching program. Conflict detection and overwrite operate only within the sameworkout_assignments.program_id, and inserted rows retain it. WithoutprogramId, only ad-hoc NULL-owned rows participate. The reusable coaching template itself remains organization-wide.
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', programId, startDate, userIds, conflictMode }. UI displays “will create N assignments, skip M conflicts”; another program’s work is never a conflict. - 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.classTypeIds(optional, schedule only) narrows preview and apply to those class types’ slots; the template’s other slots are left out. No matching slot is a 400. The session sheet’s weekly repeat uses it so adding one class type never fires the rest of the template.
- 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 on class type alone. Overlap rather than equal start times, so an existing 07:30 catches a planned 07:00 for a 60-minute class.
Space conflicts (flag spaces on). A planned session also conflicts with any live session of any class type that overlaps it in the same exclusive space (ADR-0024 D2). These groups carry kind: 'space' and locationName. They are always skipped, even in overwrite mode, and count in skipped; abort aborts on them. Flag off: every group is kind: 'class_type', locationName: null, and room double-booking across class types is not checked. Coach double-booking is not covered either way.
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 (kind, classTypeId, day, time) — the coordinates of one time input in the template editor — with kind (class_type | space), locationName (set for space), 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[].
The planner and conflict detection are shared, not template-specific. buildPlannedSessions (expand weekly occurrences into draft class_sessions rows), loadExistingSessions and resolvePlannedInserts live in apps/api/src/program-templates/schedule-planner.ts and are pure/DB-generic — they take a flat list of (classTypeId, day, time) occurrences, not a template. applyToSchedule is one caller; ClassSessionsService.copyWeek (“copy last week”, see scheduling-bookings/behavior.md) is the other, building its occurrence list from a source week’s live sessions instead of a template’s slots. Both run through findSessionConflicts/groupConflicts from session-conflicts.ts, so a session can never stack on top of one that already exists regardless of which surface created it.
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:
coaching(sourceId= userId, required) orschedule, scoped by eitherprogramId(the whole program) or, for backward compatibility,sourceId(one class type) — exactly one of the two is required forschedule. POST /from-historywith{ source, sourceId?, programId?, startDate, endDate, name }.- For
coaching,sourceIdselects the athlete and optionalprogramIdselects the owning program. The server reads only that scope (NULL-owned ad-hoc work when omitted), normalizes rows into(weekNumber, dayOffset, sortOrder)cells, and computesdurationWeeks = max(weekNumber). Existing enrollment never broadens history capture. - For
schedule, the server resolves the target class types (every active class type inprogramId, or just the one named bysourceId), loads their live, non-cancelledclass_sessionsin range, and — per class type — reads each session’s weekday and time viaformatInTimeZone(startsAt, org.timezone, 'EEEE' | 'HH:mm')('UTC'fallback), the same timezoneapplyToScheduleuses to writestartsAtin the first place. Oneprogram_template_slotsrow is inserted per class type that had at least one session, ordered by class type name; a class type with none in range is simply omitted, not an error.durationWeeks = ceil((days in range) / 7). - Inserts header + slots (or + cells for coaching). Returns the new template id; schedule templates retain the resolved program while coaching templates remain reusable across programs.
Edge cases & error states
| Trigger | Handling |
|---|---|
Create schedule template with no slots | 400 “At least one class type slot is required for schedule templates”. |
Create schedule template with no programId | 400 “programId is required for schedule templates”. |
Create/update schedule template with a programId from another org | 400 “Program X not found in this organization”. |
A slot’s class type belongs to a different program than the template’s programId | 400 “Class type X belongs to a different program”. |
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' | Only conflicting rows in the requested program (or ad-hoc scope when omitted) are soft-deleted; results survive and other programs remain untouched. |
Apply schedule cell on a non-active day | Counted in skipped. |
from-history with endDate < startDate | 400 “endDate must be >= startDate”. |
from-history schedule source with neither programId nor sourceId | 400 “Provide either programId or sourceId for a schedule source”. |
from-history schedule source with both programId and sourceId | 400 “Provide only one of programId or sourceId for a schedule source”. |
from-history schedule source where no class type in scope has a session in range | 400 “Cannot derive recurrence — no class sessions in range…”. |
from-history coaching source with no sourceId | 400 “sourceId is required for a coaching source”. |
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.