Program. Its content column is a JSON document whose shape depends on type. The contracts are documented on the Program templates page.
Files: the five standard files plus three helpers.
programs.routes.ts wires the service with a trainee notifier (lib/trainee-push.ts) and a PlanActivatedHook that calls starter.onEvent(clientId, 'FIRST_PLAN') on the subscription starter from Client subscriptions.
The program object
Routes return the full Prisma row.Concepts
Plan kind: builder plan or file plan
isFilePlan(plan) in plan-kind.ts returns true when pdfUrl is a non-empty string. Nothing else decides the kind. The type stays TRAINING or NUTRITION, so every type-based query keeps working.
- A file plan’s whole content is the file or link in
pdfUrl.pdfUrlmust be anhttporhttpsURL (planFileUrlin the schema, checked byisPlanFileUrl). - Its
contentmay only be{}or{ "meta": { "workoutsPerWeek": 3 } }.readFilePlanContentreturnsnullfor anything else, andfilePlanContentForStoreturns that intoBAD_REQUEST(a file plan carries no content beyond meta.workoutsPerWeek).workoutsPerWeekmust be a finite number above zero and is rounded. - Readers must branch on
isFilePlanbefore normalizing. The normalizers fabricate one empty day for empty content, which would make a file plan look like a regular one. FILE_PLAN_NOTEis the fixed Hebrew sentence shown wherever a metric would have been derived from a plan the trainee only has as a file. The check-in insights in Check-ins use it.
Plan diffing
summarizePlanEdit(type, before, after) in plan-diff.ts compares two versions of a plan and returns a PlanChangeSummary:
- For
NUTRITIONit compares meal items across all days and meals. ForTRAININGandCOMBINEDit compares strength rows across all days. - Both sides are normalized first, then matched by row id.
- A row present only in the new version is
added. A row present only in the old one isremoved. - A row whose
exerciseId(orfoodId) changed isswapped. It is counted once even when its numbers changed too. - Otherwise the row is
editedwhen its fingerprint changed. The training fingerprint issets,reps,repsHigh,rest,weight,rir,tempo,notesand each set’stype,reps,repType,repsHigh,weight,rest,note. Set ids are excluded because the normalizer mints new random ids when a row arrives withoutsetRows. The nutrition fingerprint isfoodId,qty,note,countsAsand each alternative’sfoodIdandqty. - It returns
nullwhen every count is zero.
repo.recordActivity as metadata.changes, intended for the trainee’s activity feed.
Plan notifications
notifyPlan sends a push through the trainee notifier. It only runs when the program is ACTIVE.
The template variable is
plan, set to traineePlanName(program). The push data carries programId. Update pushes are debounced: none is sent within six hours (PLAN_NOTIFY_DEBOUNCE_MS) of lastPlanNotifiedAt. The stamp is written only when the notifier reports at least one accepted message, so a suppressed or failed push does not start the debounce.
Intake-review hold
A plan assigned from an intake review can be held back from the trainee until the coach sends it. The hold is created by the template assign route withholdForResponseId.
Three paths release a held plan: the release route, a status change through the update route, and the sweep worker. All three go through
repo.claimRelease, a single conditional updateMany on releasePending: true. Of two callers racing on the same row exactly one sees count === 1, so the plan is activated and pushed once.
The sweep is workers/plan-release-scheduler.ts. It runs on the BullMQ queue PerformQueue.PLAN_RELEASE_SWEEP every five minutes and once at boot, and calls programs.releaseDue(). That loads up to 100 held plans with status: PAUSED, releaseAt in the past and a studio that is not deleted, oldest first, and releases each one. A failed activation restores the hold (repo.restoreRelease) so the next tick retries it.
Endpoints
GET /v1/web/programs/copy-sources
Lists other trainees whose plan can be copied onto a trainee. Powers the “copy from another trainee” picker.
Auth: web lane, any role.
string
required
TRAINING or NUTRITION. COMBINED programs are included for either value.string
required
The trainee the plan will be copied to. Left out of the results.
string
Trimmed. Case-insensitive
contains on the trainee’s name, firstName, lastName and email, and a plain contains on phone.integer
default:"1"
Page number, positive.
integer
default:"50"
Positive, maximum 200.
ARCHIVED, is not the excluded trainee, and has at least one non-archived program of a matching type. For each trainee the service picks one program: the ACTIVE one if there is one, otherwise the most recently updated. Trainees with no usable name are skipped. The result is sorted by clientName with the Hebrew collation (localeCompare(..., 'he')) and paged in memory, so total is the number of trainees, not programs.
dayTagsis filled forTRAININGonly: one letter per day incontent.days, starting atAand wrapping after 26.nutritionGoalis filled forNUTRITIONonly:content.meta.goalwhen it isdeficit,balanceorsurplus, otherwisenull.
VALIDATION when type or excludeClientId is missing. UNAUTHORIZED with no studio context.
GET /v1/web/programs/active-by-client
Returns the active plans of one type grouped by trainee. The trainee list uses it for plan tags.
Auth: web lane, any role.
string
required
TRAINING or NUTRITION. COMBINED programs are included for either value.string or string[]
One id or a repeated parameter. Omit to cover the whole studio.
status: ACTIVE are returned, ordered by createdAt ascending. name is the internal name, not the display name. Trainees with no active plan of that type are not in the result.
Response:
VALIDATION, UNAUTHORIZED.
GET /v1/web/programs
Lists programs in the studio, most recently updated first.
Auth: web lane, any role.
string
Limit to one trainee.
string
TRAINING, NUTRITION or COMBINED. An exact match. COMBINED is not folded in here.string
DRAFT, ACTIVE, PAUSED or ARCHIVED.integer
default:"1"
Page number, positive.
integer
default:"20"
Positive, maximum 500.
items are full program objects, including content.
content in the example is shortened. meta carries more keys in a real row.
Errors: VALIDATION, UNAUTHORIZED.
POST /v1/web/programs
Creates a program for a trainee. Returns 201.
Auth: web lane, any role.
string
required
The trainee. Must be in the caller’s studio.
string
required
TRAINING, NUTRITION or COMBINED.string
required
Minimum length 1.
string | null
Name shown to the trainee.
object
required
Plan JSON. Any object is accepted (
z.record(z.string(), z.unknown())). Unlike templates, a builder plan’s content is stored as sent and is not normalized on create.string
DRAFT, ACTIVE, PAUSED or ARCHIVED. Falls back to the column default DRAFT.date
Parsed with
z.coerce.date().date
Parsed with
z.coerce.date().string | null
An
http(s) URL. Setting it makes this a file plan, and content must then pass the file plan rule.- A
ClientActivityrow withactorType: TRAINER,entityType: PROGRAM,action: ASSIGNED,entityNameset to the program name, andtypeset toTRAINING_PLAN,NUTRITION_PLANorTRAINING_AND_NUTRITION_PLAN.actorNameis the name of theCoachrow whoseexternalUserIdmatches the caller. - An assigned push, only when the program was created
ACTIVE. - The
PlanActivatedHook, only when the program was createdACTIVE. It tells the subscription starter that the trainee received a first plan, which starts a subscription waiting on theFIRST_PLANtrigger.
NOT_FOUND (client not found). BAD_REQUEST when a file plan carries builder content. VALIDATION for a bad body, including a pdfUrl that is not an http(s) URL. UNAUTHORIZED with no studio or user context.
POST /v1/web/programs/release
Sends held intake-review plans now, or schedules them for later.
Auth: web lane, any role.
string[]
required
Between 1 and 20 program ids. Duplicates are removed.
string | null
required
null sends every plan now. An ISO 8601 datetime with an offset (z.iso.datetime({ offset: true })) schedules them and they stay held. The key must be present.- With
releaseAt: null, plans are released one after another. Each release claims the hold, then runs the update path withstatus: ACTIVE, which sends the assigned push and fires the plan-activated hook. A lost claim is not an error, because the plan was already sent by someone else. - With a moment,
repo.scheduleReleasesetsreleaseAton the rows that are still held. The sweep worker sends them when the moment arrives.
GET /v1/web/programs/:id
Returns one program with the trainee’s saved exercise substitutions.
Auth: web lane, any role.
string
required
The program id.
traineeSubstitutions, read from TraineeExerciseSubstitution. It is a sibling key and never nested in content, because the builder rebuilds every row from a whitelist and would strip it on the next save.
NOT_FOUND (program not found).
PATCH /v1/web/programs/:id
Edits a program: name, dates, status, file, or content. Only the fields sent are written.
Auth: web lane, any role.
string
required
The program id.
string
Minimum length 1.
string | null
Send
null to clear it.object
New plan JSON. Increments
version by one.string
DRAFT, ACTIVE, PAUSED or ARCHIVED.date
Parsed with
z.coerce.date(). Cannot be cleared through this route, because null is not accepted.date
Parsed with
z.coerce.date().string | null
An
http(s) URL, or null to remove the file.- Content. The plan kind after the patch decides the storage path. For a file plan, the content must pass the file plan rule, and
keepFilePlanKeycarries the storedmeta.autofitPdfIdover. For a builder plan,keepAutofitMetaremoves any AutoFit keys the request brought and puts back the ones on the stored row, so a save from a builder that does not echo them keeps the row findable by the AutoFit import. Builder content is not normalized here. - Hold. When
statusis sent and is notPAUSED,releasePendingandreleaseAtare cleared. If the plan was held, the service takes the atomic claim first.ACTIVEis a send, andARCHIVEDorDRAFTis a cancel. If the write then fails, the hold is restored. - Substitutions. After a content edit on a non-file
TRAININGplan, trainee substitutions whoserowIdis no longer in the plan are deleted (pruneSubstitutions).COMBINEDplans are not pruned. - Activity. A
ClientActivityrow is written withactionset toREMOVEDwhen the status moved toARCHIVED,ASSIGNEDwhen it moved toACTIVE, andEDITEDotherwise. See the warning under Plan diffing about the change counts. - Push and hook. When the plan became
ACTIVE, the assigned push and the plan-activated hook fire, unless the plan was held and this call lost the claim. Otherwise, a content edit sends the updated push, subject to the six hour debounce and only while the plan isACTIVE.
traineeSubstitutions.
Errors: NOT_FOUND (program not found). BAD_REQUEST when a file plan is sent builder content. VALIDATION for a bad body. UNAUTHORIZED.
POST /v1/web/programs/:id/duplicate
Copies a program, for the same trainee or onto another one. Returns 201.
Auth: web lane, any role.
string
required
The source program id.
string
Target trainee. Omit, or send no body, to copy for the same trainee.
type, name, displayName, content and sourceTemplateId.
pdfUrl, startsOn and endsOn are not copied. A duplicated file plan therefore becomes a regular plan with file plan content.
A ClientActivity row with action: ASSIGNED is written for the target trainee. No push is sent and the plan-activated hook does not fire, even when the copy is created ACTIVE for another trainee.
Response: the created program object.
Errors: NOT_FOUND for the source program (program not found) or the target trainee (client not found). VALIDATION, UNAUTHORIZED.
POST /v1/web/programs/:id/unschedule
Drops the scheduled send moment of a held plan. The plan stays held.
Auth: web lane, any role.
string
required
The program id.
releaseAt to null.
Response:
NOT_FOUND (program not found). CONFLICT when the plan was already sent.
Who else uses this module
workers/plan-release-scheduler.tsbuilds its owncreateProgramsServiceand callsreleaseDue.modules/program-templates/importsplan-kind.ts,plan-name.ts, theplanFileUrlschema and thePlanActivatedHooktype.modules/products/reuses theClientActivePlanstype for its ownactive-by-clientroute.modules/checkins/checkins-insights.tsimportsFILE_PLAN_NOTEandisFilePlan.