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 throughenrich() and adds assignedCount. The extra fields depend on the kind of template.
Base fields from the ProgramTemplate row:
Added by the service:
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.
string
Case-insensitive
contains on name.integer
default:"1"
Page number, positive.
integer
default:"20"
Positive, maximum 500.
groupBy on Program.sourceTemplateId.
Response:
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.- File template (
pdfUrlset): content must be{}or{ "meta": { "workoutsPerWeek": n } }. Anything else isBAD_REQUEST. The normalizers are skipped, because they would fabricate a day. TRAINING: stored asnormalizeTrainingContent(content).NUTRITION: stored asnormalizeNutritionContent(content).COMBINED: stored as sent.
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.
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 hasexerciseId: null, so the coach still has to link rows to library exercises. The generator emits the v1 shape with anaerobicarray per day. The normalizer converts those minutes into the cardio requirement. - Nutrition. One
targetsblock applied to every day, meals typed asbreakfast,snack_am,lunch,pre,postordinner, and items withfoodId: null. The food name the model produced is kept only in the itemnote(item.note || item.name), because the stored item shape has no name field.
assignedCount: 0.
Errors:
GET /v1/web/program-templates/:id
Returns one template.
Auth: web lane, any role.
string
required
The template id.
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.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.
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.
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.
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:
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.type, name, displayName, content and pdfUrl from the template, sets sourceTemplateId, and is created with status: ACTIVE.
Normal assign side effects:
- A
ClientActivityrow (actorType: TRAINER,action: ASSIGNED,entityType: PROGRAM,entityIdthe program id,entityNamethe template name,typeone ofTRAINING_PLAN,NUTRITION_PLAN,TRAINING_AND_NUTRITION_PLAN). - A push of type
TRAINING_PLAN_ASSIGNED,NUTRITION_PLAN_ASSIGNED, or both for a combined template. Theplanvariable is the display name, falling back to the name. - The plan-activated hook, which starts a subscription that waits on the
FIRST_PLANtrigger.
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.
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.
- All ids are checked first. If any id is not in the studio, the whole request fails with
NOT_FOUNDand nothing is created. - Existing assignments are skipped. A trainee who already has an
ACTIVEorPAUSEDprogram from this template gets no second copy. Their existing program id is returned instead.
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.
NOT_FOUND (program template not found or client not found). VALIDATION for a bad body.
Content contracts
The request schemas accept any object forcontent. 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
TypeTrainingContent 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 notstepsOnly.requirementHasSteps: on and mode is notcardioOnly.requirementCompensates: on and mode isbothCompensated. 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 forweeklyscope, or minutes times workouts per week forperWorkout.dayCardioMinutes(day, requirement, workoutsPerWeek): the day’s owncardioMinuteswhen set. Otherwise the plan target, divided by workouts per week and rounded when the scope isweekly.dayStepsGoal(day, requirement): the day’s ownstepsDailywhen set, otherwise the plan’s daily steps.0when the requirement has no steps.
Days and strength rows
A day hasid, label, strength, cardioMinutes and stepsDaily.
iddefaults tod1,d2and so on. Duplicate ids are replaced with the next freedN.labeldefaults toDay A,Day Band so on.cardioMinutesandstepsDailyare 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 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
everyNis a positive integer string or"".- When
modeis missing or unknown, it is inferred:coachDemandiffirstTimeis true oreveryNis set, otherwisenone. techniqueVideoRequired(rule)is true whenmodeiscoachDemandand either trigger is set.requestedAtis 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
TypeNutritionContent 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(defaultnd1,nd2),label(default is the Hebrew word for “day” plus the day number),targetsandmeals. Content with no days gets one empty day. targetsholdskcal,protein,carbs,fat(grams) andwater(litres). Defaults are 2000, 150, 200, 60 and 3. OptionalmbpProtein,mbpCarbandmbpFatportion targets are kept only when finite and above zero.kcalOverride: trueis set once the coach types a calorie target over the portion-derived one.- A top-level
targetsobject 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, defaultbreakfast),label(custom name, empty falls back to the type’s label in the UI),time,prep,items, and an optionaltarget. The defaulttimecomes fromMEAL_TYPE_DEFAULT_TIME: 08:00, 10:30, 13:00, 16:00, 18:00 and 20:00 in the order above. - A meal
targetholds any ofkcal(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, optionalcountsAs(proteinorcarb, for foods that can count toward either macro) andalts. An alternative hasid,foodId,qtyand optionalcountsAs.
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)andtargetsWaterGoalMl(targets): the water target in millilitres (watertimes 1000, rounded), or 0 when there is none.
Imported content
Whenmeta.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
setsstays""instead of"3". - Nutrition: a missing
qtystays""instead of"100", a missing mealtimestays"", 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 andautofitWeekstays 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 formeta.autofitPdfIdon a trainee file plan, whose content skips the normalizers.
Who else uses this module
modules/programs/imports both normalizers,keepAutofitMeta,keepFilePlanKeyandfilePlanContentForStore.modules/programs/plan-diff.tsnormalizes both sides before diffing.modules/plan-import/andmodules/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.