A program is one trainee’s own copy of a plan. It is created by assigning a program template, by copying another program, or directly from the builder. The Prisma model is 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. pdfUrl must be an http or https URL (planFileUrl in the schema, checked by isPlanFileUrl).
  • Its content may only be {} or { "meta": { "workoutsPerWeek": 3 } }. readFilePlanContent returns null for anything else, and filePlanContentForStore turns that into BAD_REQUEST (a file plan carries no content beyond meta.workoutsPerWeek). workoutsPerWeek must be a finite number above zero and is rounded.
  • Readers must branch on isFilePlan before normalizing. The normalizers fabricate one empty day for empty content, which would make a file plan look like a regular one.
  • FILE_PLAN_NOTE is 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 NUTRITION it compares meal items across all days and meals. For TRAINING and COMBINED it 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 is removed.
  • A row whose exerciseId (or foodId) changed is swapped. It is counted once even when its numbers changed too.
  • Otherwise the row is edited when its fingerprint changed. The training fingerprint is sets, reps, repsHigh, rest, weight, rir, tempo, notes and each set’s type, reps, repType, repsHigh, weight, rest, note. Set ids are excluded because the normalizer mints new random ids when a row arrives without setRows. The nutrition fingerprint is foodId, qty, note, countsAs and each alternative’s foodId and qty.
  • It returns null when every count is zero.
The update route computes this summary on a content edit and passes it to repo.recordActivity as metadata.changes, intended for the trainee’s activity feed.
recordActivity in programs.repository.ts does not write metadata. Its input type lists only type, action, entityId and entityName, and the clientActivity.create call sets no metadata field. As the code stands, the change summary is computed and then dropped.

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 with holdForResponseId. 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.
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.
The repository loads every trainee in the studio that is not 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.
  • dayTags is filled for TRAINING only: one letter per day in content.days, starting at A and wrapping after 26.
  • nutritionGoal is filled for NUTRITION only: content.meta.goal when it is deficit, balance or surplus, otherwise null.
Response:
Errors: 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.
Only programs with 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:
Errors: 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.
Response: items are full program objects, including content.
The 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.
Side effects, in order:
  1. A ClientActivity row with actorType: TRAINER, entityType: PROGRAM, action: ASSIGNED, entityName set to the program name, and type set to TRAINING_PLAN, NUTRITION_PLAN or TRAINING_AND_NUTRITION_PLAN. actorName is the name of the Coach row whose externalUserId matches the caller.
  2. An assigned push, only when the program was created ACTIVE.
  3. The PlanActivatedHook, only when the program was created ACTIVE. It tells the subscription starter that the trainee received a first plan, which starts a subscription waiting on the FIRST_PLAN trigger.
Response: the created program object. Errors: 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.
The request is all or nothing on validation: every id must exist in the studio and every plan must still be held.
  • With releaseAt: null, plans are released one after another. Each release claims the hold, then runs the update path with status: 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.scheduleRelease sets releaseAt on the rows that are still held. The sweep worker sends them when the moment arrives.
Response: the current hold state of each requested plan.
Errors:

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.
Response: the program object plus 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.
Errors: 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.
What the service does:
  1. Content. The plan kind after the patch decides the storage path. For a file plan, the content must pass the file plan rule, and keepFilePlanKey carries the stored meta.autofitPdfId over. For a builder plan, keepAutofitMeta removes 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.
  2. Hold. When status is sent and is not PAUSED, releasePending and releaseAt are cleared. If the plan was held, the service takes the atomic claim first. ACTIVE is a send, and ARCHIVED or DRAFT is a cancel. If the write then fails, the hold is restored.
  3. Substitutions. After a content edit on a non-file TRAINING plan, trainee substitutions whose rowId is no longer in the plan are deleted (pruneSubstitutions). COMBINED plans are not pruned.
  4. Activity. A ClientActivity row is written with action set to REMOVED when the status moved to ARCHIVED, ASSIGNED when it moved to ACTIVE, and EDITED otherwise. See the warning under Plan diffing about the change counts.
  5. 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 is ACTIVE.
Response: the updated program object, without 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.
The copy keeps 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.
No body. The service checks that the plan exists and is still held, then sets releaseAt to null. Response:
Errors: NOT_FOUND (program not found). CONFLICT when the plan was already sent.

Who else uses this module

  • workers/plan-release-scheduler.ts builds its own createProgramsService and calls releaseDue.
  • modules/program-templates/ imports plan-kind.ts, plan-name.ts, the planFileUrl schema and the PlanActivatedHook type.
  • modules/products/ reuses the ClientActivePlans type for its own active-by-client route.
  • modules/checkins/checkins-insights.ts imports FILE_PLAN_NOTE and isFilePlan.