Product. Assigning a product to a trainee creates a ClientSubscription row, which is owned by Client subscriptions.
products.routes.ts builds a ClientSubscriptionsService and passes it into createProductsService, together with a trainee notifier (lib/trainee-push.ts) and subscriptionAssignedHook(ctx) from modules/tasks/. That service is what the bulk assign route calls.
The product object
Every route that returns a product uses the same repositoryinclude and shape() function. _count.clients is flattened into clientCount.
Linked form rules
create and update both run assertForms. For each form id present in the body:
- The form must exist in the caller’s studio, otherwise
NOT_FOUND(onboarding form not foundorupdate form not found). assertAutomaticFormTypefrommodules/forms/form-type.tschecks the form type. The onboarding slot accepts onlyINTAKE. The update slot accepts onlyCHECK_IN. Any other type throwsBAD_REQUEST(onboarding form must be intakeorupdate form must be check-in).- A
PDF_SIGNATUREform is refused in either slot withBAD_REQUESTanddetails.reasonset toFORM_IS_DOCUMENT_TO_SIGN. A document to sign reaches the trainee only through its signing link.
null for a form id skips the check and clears the link.
Endpoints
GET /v1/web/products
Lists the studio’s products, newest first.
Auth: web lane, any role.
boolean
Filter on the
active flag. Parsed with z.coerce.boolean(), which treats any non-empty string as true. active=false in a query string therefore filters for active products. Omit the parameter to get both.string
Case-insensitive
contains match on name or tag.integer
default:"1"
Page number, positive.
integer
default:"20"
Rows per page. Positive, maximum 500.
UNAUTHORIZED with no studio context. VALIDATION (422) for a bad query.
GET /v1/web/products/active-by-client
Returns the live subscriptions grouped by trainee. The trainee list uses it to show each trainee’s current plan names.
Auth: web lane, any role.
string or string[]
One id or a repeated parameter. A single string is wrapped into an array. Omit it to get every trainee in the studio that has a live subscription.
ClientSubscription with status SCHEDULED, ACTIVE or FROZEN. Rows are read ordered by startedOn ascending, so each trainee’s plans array is in start order. A subscription with an empty or null planName is dropped from plans. A trainee whose subscriptions all lack a name still appears with an empty plans array. Trainees with no live subscription are not in the result.
Response: data.items is an array of ClientActivePlans (the type is shared with modules/programs/programs.service.ts).
id here is the subscription id, not the product id. endsOn can be null.
Errors: UNAUTHORIZED, VALIDATION.
POST /v1/web/products
Creates a product. Returns 201.
Auth: web lane, OWNER or HEAD_COACH.
string
required
Minimum length 1.
string | null
Name shown to the trainee.
string | null
Maximum 40 characters.
integer | null
default:"null"
Non-negative integer in agorot.
null leaves the price open for the coach to set per trainee.integer | null
default:"null"
Positive integer.
null leaves the duration open.string
default:"MONTHS"
DAYS or MONTHS.string | null
An
INTAKE form in the same studio. See Linked form rules.string | null
A
CHECK_IN form in the same studio.boolean
default:"true"
Whether the plan can be offered.
FORBIDDEN (insufficient role) for a sub coach. NOT_FOUND for a form id outside the studio. BAD_REQUEST for a form of the wrong type. VALIDATION for a bad body.
GET /v1/web/products/:id
Returns one product.
Auth: web lane, any role.
string
required
The product id.
NOT_FOUND (product not found).
GET /v1/web/products/:id/assignments
Returns the ids of the trainees that currently hold a live subscription to this product.
Auth: web lane, any role.
string
required
The product id.
SCHEDULED, ACTIVE, FROZEN), keeps those whose productId matches, and de-duplicates the trainee ids.
Response:
NOT_FOUND (product not found).
POST /v1/web/products/:id/assign-bulk
Assigns the product to many trainees in one call. Returns 201 with a per-trainee result.
Auth: web lane, OWNER or HEAD_COACH.
string
required
The product id.
string[]
required
Between 1 and 500 trainee ids. Duplicates are removed before processing.
date
Start date for every new subscription. Parsed with
z.coerce.date(). When omitted, no date is passed on and the subscriptions service applies the studio’s start mode per trainee (start today, queue after the current plan, or wait for a trigger).subscriptions.addSubscription(studioId, clientId, body) for each. Bulk assign only adds subscriptions. It never removes or replaces one. All side effects of adding a subscription happen inside that service, which the router wires with a trainee notifier and subscriptionAssignedHook. See Client subscriptions.
An AppError thrown for one trainee is caught and recorded as that trainee’s reason, and the loop continues. Any other error aborts the whole request.
Response:
reason is the ErrorCode string of the failure, not the message.
Errors: FORBIDDEN for a sub coach. NOT_FOUND (product not found). VALIDATION for a bad body. INTERNAL (subscriptions are not available) only if the service was built without a subscriptions service, which the router never does.
PATCH /v1/web/products/:id
Updates a product. Only the fields sent are written.
Auth: web lane, OWNER or HEAD_COACH.
string
required
The product id.
name, displayName, tag, priceAgorot, durationValue, durationUnit, onboardingFormId, updateFormId, active. Send null for priceAgorot or durationValue to make the plan open again. Send null for a form id to unlink the form.
This route writes only the Product row. It does not touch existing ClientSubscription rows, which carry their own planName, priceAgorot, durationValue and durationUnit columns.
Response: the updated product object.
Errors: FORBIDDEN, NOT_FOUND (product or form), BAD_REQUEST (form type), VALIDATION.
DELETE /v1/web/products/:id
Deletes a product. Returns 204 with no body.
Auth: web lane, OWNER or HEAD_COACH.
string
required
The product id.
ClientSubscription.productId is declared with onDelete: SetNull, so existing subscriptions survive with productId set to null and keep their own planName column.
Errors: FORBIDDEN, NOT_FOUND (product not found). A foreign key failure from another relation would surface as CONFLICT (related resource constraint failed) through the shared Prisma error mapping.