Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesProgram TemplatesProgram templates — code map

Program templates — code map

API (NestJS)

Base path: /organizations/:orgId/program-templates

RouteMethodService method
POST /createValidates mode-specific shape, inserts header
GET /listFilters: deliveryMode, programId, q, tags[]
GET /:idgetByIdReturns header + cells
PATCH /:idupdateHeader-only; mode/duration changes
DELETE /:idremoveSoft delete
POST /:id/duplicateduplicateHeader + cells deep copy with ” (copy)” suffix
POST /:id/workoutsupsertCellsReplace full cell grid
POST /:id/previewpreviewDry-run apply, returns the would-be result
POST /:id/applyapplyMaterialize into target
POST /from-historyfromHistorySave-as-template from existing materialized rows

Controller: apps/api/src/program-templates/program-templates.controller.ts Service: apps/api/src/program-templates/program-templates.service.ts (~1119 lines) Module: apps/api/src/program-templates/program-templates.module.ts

DTOs (apps/api/src/program-templates/dto/)

  • create-program-template.dto.ts — CreateProgramTemplateDto
  • update-program-template.dto.ts — UpdateProgramTemplateDto
  • upsert-cells.dto.ts — UpsertProgramTemplateCellsDto
  • apply-template.dto.ts — ApplyProgramTemplateDto, PreviewProgramTemplateDto, ConflictMode
  • schedule-slot.dto.ts — ScheduleSlotDto, ScheduleOccurrenceDto
  • from-history.dto.ts — FromHistoryDto

Key service methods

  • requireCoach(orgId, clerkId) — role gate (owner / admin / coach).
  • loadTemplateWithCells(orgId, id) — single fetch with cells, used by most read paths.
  • assertModeMatches(template, dtoMode) — apply / preview safety check.
  • applyToCoaching(template, cells, dto, orgId, authorUserId) — bulk inserts workout_assignments. Conflict modes: skip (default), abort, overwrite.
  • applyToSchedule(template, slots, dto, orgId, clerkId) — expands the slots, drops or replaces anything clashing with the live schedule, and writes class_sessions in one transaction. Same conflict modes as coaching; overwrite skips sessions that have bookings.
  • buildPlannedSessions(template, slots, dto, orgId) — the expansion, shared by preview and apply so both see the same set.
  • loadExistingSessions(classTypeIds, planned) — live sessions in the applied window plus their booking counts (two grouped queries, no correlated subquery).
  • normalizeOccurrences(occurrences) — dedupes and (day, time)-sorts a slot on every write.
  • buildCoachingPreview, buildSchedulePreview — mode-specific dry-run.
  • readHistoryCells(orgId, dto) — normalize existing materialized rows back to cells (used by from-history’s coaching branch; the schedule branch derives slots inline in fromHistory, per class type, via getOrgTimezone + formatInTimeZone).
  • assertSlotsInProgram(slots, orgId, programId) — every slot’s class type must belong to programId (not just the org); falls back to an org-only check when programId is null (pre-backfill legacy row). Replaces the old org-only assertSlotsInOrg.
  • assertProgramInOrg, assertLocationInOrg, assertMembershipInOrg, assertLibraryWorkoutInOrg — referential integrity helpers.

Pure modules (no DB, unit-tested)

  • session-conflicts.ts — findSessionConflicts(planned, existing) and groupConflicts(...). The single definition of a schedule clash, shared by preview, apply and copy-week: same class type overlapping, and (flag spaces on) any overlap in the same exclusive space (kind: 'space'). Coach double-booking is not covered.
  • addDays(date, n), toUtc(date, time, tz) — date math helpers (uses date-fns-tz fromZonedTime).

Web (Next.js)

RouteFile
Templates list (per program)apps/web/src/app/[lang]/(protected)/dashboard/programs/[id]/templates/page.tsx
Template detailapps/web/src/app/[lang]/(protected)/dashboard/programs/[id]/templates/[templateId]/page.tsx
Cell editorapps/web/src/app/[lang]/(protected)/dashboard/programs/[id]/templates/[templateId]/cells/[week]/[day]/edit/page.tsx

Components

  • apps/web/src/components/shared/program-grid/ — generic week × day grid used by templates and by workout-assignments coaching view (cell-shell.tsx, cell-menu.tsx, empty-cell-menu.tsx, note-prompt-dialog.tsx).
  • apps/web/src/components/overview/schedule/save-week-as-template-dialog.tsx — “Save this week as a template” from the schedule’s setup menu (setup-timetable-menu.tsx). Posts from-history with source:'schedule' and the screen’s programId; no class-type picker — shows the program’s active class types as read-only chips instead. Driver: co-located .driver.tsx.
  • Apply / preview dialogs: search components/overview/programming/ (TODO: verify exact filenames — appears mixed across overview/programming and dashboard).
  • apps/web/src/components/templates/program-templates-tab.tsx — per-program Templates tab. Knows the page’s programId and threads it into new-template-form.tsx, edit-template-dialog.tsx, and (through those) schedule-slots-editor.tsx, whose class-type picker takes a required programId prop and only ever lists that program’s class types. Schedule rows are scoped by template.programId; the tab falls back to scanning slots[].classTypeId against the program’s own class types only for legacy rows with programId=null. apply-template-button.tsx (the schedule calendar’s “Apply template” entry point) uses the same programId-first, slot-scan-fallback rule.

DB

libs/db/src/lib/schema/program-templates.ts:

  • program_templates — header rows.
  • program_template_slots — schedule recurrence: one row per class type, occurrences[] as (day, time) pairs.
  • program_template_workouts — cell rows (week × day × slot).

See data-model.md.

Shared

libs/shared/src/lib/schemas/program-template.schema.ts — Zod response shapes (TODO: verify exact exports). Delivery mode enum lives in workout.schema.ts as deliveryModeValues.

Tests

  • program-templates.program-scope.unit.spec.ts — create/findAll program scoping: a foreign-program slot is rejected, a missing/foreign-org programId is rejected, findAll’s programId filter.
  • program-templates.from-history.unit.spec.ts — fromHistory’s schedule branch: programId scope yields one slot per class type with the occurrences that ran (and skips a class type with none), sourceId still scopes to one class type, occurrences are read in the org timezone (not UTC), and programId/sourceId are mutually required/exclusive.
  • program-templates.occurrences.unit.spec.ts, program-templates.date.unit.spec.ts — pure helpers (normalizeOccurrences, date math).
  • schedule-apply-conflicts.int.spec.ts — apply/preview against real Postgres (conflict detection, overwrite transaction).
  • The bulk apply logic is also covered indirectly via e2e flows in apps/web/e2e/specs/coaching-grid.spec.ts and schedule-setup.spec.ts.