A program template is a reusable plan in the studio’s library. Assigning a template copies it into a program for one trainee. Later edits to the template do not change programs that were already assigned. The Prisma model is ProgramTemplate. Besides the five standard files the module holds the plan content contracts, which the rest of the backend imports: The router wires the service with createProgramGenerator (ai/program-generator.ts, configured from OPENAI_API_KEY and OPENAI_NUTRITION_MODEL), a trainee notifier, and a plan-activated hook that calls the subscription starter with the FIRST_PLAN trigger.

The template object

Every route that returns a template passes the Prisma row through enrich() and adds assignedCount. The extra fields depend on the kind of template. Base fields from the ProgramTemplate row: Added by the service:
The content value in the example is shortened. A full example is under Training content.

Endpoints

GET /v1/web/program-templates

Lists the studio’s templates, most recently updated first. Auth: web lane, any role.
string
TRAINING, NUTRITION or COMBINED.
string
Limit to one folder.
Case-insensitive contains on name.
integer
default:"1"
Page number, positive.
integer
default:"20"
Positive, maximum 500.
The assigned counts for the whole page are loaded with one groupBy on Program.sourceTemplateId. Response:
Errors: VALIDATION, UNAUTHORIZED.

POST /v1/web/program-templates

Creates a template. Returns 201. Auth: web lane, any role.
string
required
TRAINING, NUTRITION or COMBINED.
string
required
Minimum length 1.
string | null
Name shown to the trainee.
string
Free text.
string
Free text.
string | null
Folder to file the template in. The service does not check that the folder exists or matches the type. A bad id fails at the database with CONFLICT.
object
required
Plan JSON. Normalized before it is stored.
string | null
An http(s) URL. Setting it makes a file template.
Content handling depends on the kind:
  • File template (pdfUrl set): content must be {} or { "meta": { "workoutsPerWeek": n } }. Anything else is BAD_REQUEST. The normalizers are skipped, because they would fabricate a day.
  • TRAINING: stored as normalizeTrainingContent(content).
  • NUTRITION: stored as normalizeNutritionContent(content).
  • COMBINED: stored as sent.
Response: the created template object with assignedCount: 0. Errors: BAD_REQUEST (a file plan carries no content beyond meta.workoutsPerWeek). VALIDATION for a bad body. UNAUTHORIZED.

POST /v1/web/program-templates/generate-ai

Generates a template from a free-text description with OpenAI and saves it. Returns 201. Auth: web lane, any role.
string
required
TRAINING, NUTRITION or COMBINED. The generator only has two branches: TRAINING produces a training plan, and every other value goes down the nutrition branch. Sending COMBINED therefore stores nutrition-shaped content on a COMBINED template, without normalizing it. Send TRAINING or NUTRITION.
string
required
Between 1 and 2000 characters. A prompt that is blank after trimming is refused.
string | null
Folder for the new template.
The generator calls generateObject from the Vercel AI SDK with a fixed system prompt and a Zod output schema, then maps the result into builder content. The service normalizes that content and creates the template with the generated name and level. What the generated content looks like:
  • Training. Days with Hebrew labels, each with strength rows (muscle, sets, reps, rest, weight, rir, tempo, notes) and aerobic rows. Every row has exerciseId: null, so the coach still has to link rows to library exercises. The generator emits the v1 shape with an aerobic array per day. The normalizer converts those minutes into the cardio requirement.
  • Nutrition. One targets block applied to every day, meals typed as breakfast, snack_am, lunch, pre, post or dinner, and items with foodId: null. The food name the model produced is kept only in the item note (item.note || item.name), because the stored item shape has no name field.
Response: the created template object with assignedCount: 0. Errors:

GET /v1/web/program-templates/:id

Returns one template. Auth: web lane, any role.
string
required
The template id.
Response: one template object. Errors: NOT_FOUND (program template not found).

PATCH /v1/web/program-templates/:id

Edits a template. Only the fields sent are written. type cannot be changed. Auth: web lane, any role.
string
required
The template id.
string
Minimum length 1.
string | null
Send null to clear.
string | null
Send null to clear.
string | null
Send null to clear.
string | null
Send null to unfile.
object
New plan JSON.
string | null
An http(s) URL, or null to remove the file.
The kind after the patch decides how content is stored. If the template is, or becomes, a file template, the content must pass the file plan rule. Otherwise the content is normalized for the template’s existing type and passed through keepAutofitMeta, which restores the AutoFit keys of the stored row. A save from a builder that does not echo those keys keeps the template findable by a later AutoFit sync. Programs already assigned from the template are not touched. There is no notification or activity record. Response: the updated template object. Errors: NOT_FOUND, BAD_REQUEST (file plan content), VALIDATION.

DELETE /v1/web/program-templates/:id

Deletes a template. Returns 204 with no body. Auth: web lane, any role.
string
required
The template id.
The row is hard deleted. Program.sourceTemplateId is a plain column with no relation, so assigned programs keep the stale id and are otherwise unaffected. Errors: NOT_FOUND (program template not found).

POST /v1/web/program-templates/:id/duplicate

Copies a template inside the library. Returns 201. Auth: web lane, any role.
string
required
The template id.
No body. The copy keeps type, content, displayName, description, level, folderId and pdfUrl. Its name is the original name followed by a Hebrew suffix meaning “(copy)”. The content is copied as stored, AutoFit keys included. Response: the new template object with assignedCount: 0. Errors: NOT_FOUND.

GET /v1/web/program-templates/:id/assignments

Lists the trainees that currently hold a plan made from this template. Auth: web lane, any role.
string
required
The template id.
Only programs with status ACTIVE or PAUSED are listed, oldest first. A trainee with two such programs appears twice. Note the difference from assignedCount, which counts programs in every status. Response:
Errors: NOT_FOUND.

POST /v1/web/program-templates/:id/assign

Assigns the template to one trainee by creating a program. Returns 201. Auth: web lane, any role.
string
required
The template id.
string
required
The trainee. Must be in the caller’s studio.
date
Stored on the program. Parsed with z.coerce.date().
string
Hold mode. The id of an intake FormResponse for this same trainee. The plan is created hidden from the trainee until the coach sends it.
The program is always a fresh copy. An earlier or parallel plan from the same template does not block the assignment. The copy takes type, name, displayName, content and pdfUrl from the template, sets sourceTemplateId, and is created with status: ACTIVE. Normal assign side effects:
  1. A ClientActivity row (actorType: TRAINER, action: ASSIGNED, entityType: PROGRAM, entityId the program id, entityName the template name, type one of TRAINING_PLAN, NUTRITION_PLAN, TRAINING_AND_NUTRITION_PLAN).
  2. A push of type TRAINING_PLAN_ASSIGNED, NUTRITION_PLAN_ASSIGNED, or both for a combined template. The plan variable is the display name, falling back to the name.
  3. The plan-activated hook, which starts a subscription that waits on the FIRST_PLAN trigger.
Hold mode (holdForResponseId set): the response must exist in the studio, belong to this trainee, and be of a form whose type is INTAKE. The program is created with status: PAUSED, releasePending: true and releaseResponseId set. The activity row is still written. The push and the hook are skipped. They fire when the plan is released, see Programs. Response: the created Program row.
Errors:

POST /v1/web/program-templates/:id/assign-bulk

Assigns the template to many trainees. Returns 201. Auth: web lane, any role.
string
required
The template id.
string[]
required
Between 1 and 500 trainee ids. Duplicates are removed.
date
Stored on every new program.
Bulk assign only adds. It behaves differently from the single assign in two ways:
  • All ids are checked first. If any id is not in the studio, the whole request fails with NOT_FOUND and nothing is created.
  • Existing assignments are skipped. A trainee who already has an ACTIVE or PAUSED program from this template gets no second copy. Their existing program id is returned instead.
New programs are inserted with one createManyAndReturn, all ACTIVE. Then one batch of ClientActivity rows is written, the assigned push goes to all new recipients, and the plan-activated hook runs once per new program. Hold mode is not available in bulk. Response: one entry per trainee that now holds a plan from this template. This includes trainees who already had one and were not in the request.
Errors: NOT_FOUND (program template not found or client not found). VALIDATION for a bad body.

Content contracts

The request schemas accept any object for content. The shape is enforced by normalizers, not by Zod. A normalizer never throws. It reads what it recognizes, fills defaults, and drops everything else. Unknown keys do not survive a template save. Two rules follow from that:
  • Add a field to the normalizer, or it will be stripped the next time a coach saves.
  • Both normalizers mirror the frontend builder shape, so a change has to land in both places.

Training content

Type TrainingContent in training-schema.ts. Current schemaVersion is 2.

meta

Movement requirement

meta.requirement describes cardio and daily steps for the whole plan. Helpers exported for other modules:
  • requirementHasCardio: on and mode is not stepsOnly.
  • requirementHasSteps: on and mode is not cardioOnly.
  • requirementCompensates: on and mode is bothCompensated. One fully met goal stands in for the other, so a day counts as done when either target is reached.
  • weeklyCardioMinutes(requirement, workoutsPerWeek): the minutes as they are for weekly scope, or minutes times workouts per week for perWorkout.
  • dayCardioMinutes(day, requirement, workoutsPerWeek): the day’s own cardioMinutes when set. Otherwise the plan target, divided by workouts per week and rounded when the scope is weekly.
  • dayStepsGoal(day, requirement): the day’s own stepsDaily when set, otherwise the plan’s daily steps. 0 when the requirement has no steps.

Days and strength rows

A day has id, label, strength, cardioMinutes and stepsDaily.
  • id defaults to d1, d2 and so on. Duplicate ids are replaced with the next free dN.
  • label defaults to Day A, Day B and so on.
  • cardioMinutes and stepsDaily are per-day overrides, stored as positive integer strings or "". After normalizing, a day value equal to the plan value is blanked, so only real overrides are stored.
  • Content with no days gets one empty day: { "id": "d1", "label": "Day A", "strength": [], "cardioMinutes": "", "stepsDaily": "" }.
A strength row: A set row has id (random set_ id when missing), type (warmup, working or drop, default working), reps, repType (null inherits the row’s rep type), repsHigh, weight, rest and note.

Technique video rule

  • everyN is a positive integer string or "".
  • When mode is missing or unknown, it is inferred: coachDemand if firstTime is true or everyN is set, otherwise none.
  • techniqueVideoRequired(rule) is true when mode is coachDemand and either trigger is set.
  • requestedAt is a coach’s one-off “film this in the next workout” request. It is kept only when it is a valid ISO instant, and the key is absent otherwise. stampTechniqueRequest(content, exerciseId, requestedAt) writes it onto every strength row that prescribes the exercise and returns the number of rows touched. It rewrites the stored shape and does not normalize the plan.

Migration from version 1

normalizeTrainingContent upgrades older content on read. There is no stored migration.

Nutrition content

Type NutritionContent in nutrition-schema.ts. schemaVersion is 1. Macros are not stored on items. They are computed from the referenced FoodLibraryItem, see Foods.

meta

Days, targets, meals and items

  • A day has id (default nd1, nd2), label (default is the Hebrew word for “day” plus the day number), targets and meals. Content with no days gets one empty day.
  • targets holds kcal, protein, carbs, fat (grams) and water (litres). Defaults are 2000, 150, 200, 60 and 3. Optional mbpProtein, mbpCarb and mbpFat portion targets are kept only when finite and above zero. kcalOverride: true is set once the coach types a calorie target over the portion-derived one.
  • A top-level targets object is the legacy plan-level shape. It is used as the fallback for every day that lacks its own values and is not written back.
  • A meal has id, type (breakfast, snack_am, lunch, pre, post, dinner, default breakfast), label (custom name, empty falls back to the type’s label in the UI), time, prep, items, and an optional target. The default time comes from MEAL_TYPE_DEFAULT_TIME: 08:00, 10:30, 13:00, 16:00, 18:00 and 20:00 in the order above.
  • A meal target holds any of kcal (rounded to whole calories), mbpProtein, mbpCarb, mbpFat. Each key survives only when finite and above zero, and a target with no keys left is removed entirely.
  • An item has id, foodId, qty (string, default "100"), note, optional countsAs (protein or carb, for foods that can count toward either macro) and alts. An alternative has id, foodId, qty and optional countsAs.

Helpers used by the trainee side

  • nutritionDayIndexForOffset(dayCount, offset): plan days rotate around the trainee’s current day. Offset 0 is the first day, 1 the second, and the sequence wraps, including for negative offsets.
  • nutritionTargetsForOffset(content, offset): the targets of that day.
  • planWaterGoalMl(content, offset) and targetsWaterGoalMl(targets): the water target in millilitres (water times 1000, rounded), or 0 when there is none.

Imported content

When meta.imported is true, both normalizers stop filling builder defaults, because an empty value means “missing in the source document” and drives the review markers in the UI:
  • Training: a missing sets stays "" instead of "3".
  • Nutrition: a missing qty stays "" instead of "100", a missing meal time stays "", and missing targets fall back to 0 instead of the defaults.
imported, source, draft and goalSet ride through every normalize untouched and stay absent on content that never carried them.

AutoFit keys

autofit-meta.ts lists the keys the AutoFit import stamps on content.meta so a re-sync finds the row it wrote instead of creating a second one:
  • pickAutofitMeta(meta) returns the keys present, verbatim. Ids stay strings and autofitWeek stays a number, because the import’s JSON path lookup compares types.
  • keepAutofitMeta(content, stored) removes whatever AutoFit keys the new content brought and puts back the stored row’s keys. Template update and program update both use it.
  • keepFilePlanKey(content, stored) does the same for meta.autofitPdfId on a trainee file plan, whose content skips the normalizers.

Who else uses this module

  • modules/programs/ imports both normalizers, keepAutofitMeta, keepFilePlanKey and filePlanContentForStore.
  • modules/programs/plan-diff.ts normalizes both sides before diffing.
  • modules/plan-import/ and modules/autofit-import/ produce content in these shapes.
  • The trainee, check-in and client tracking modules read plans through the same normalizers and helpers. Their endpoints are documented on their own pages.