A product is a subscription plan a studio sells to trainees: a name, an optional price, an optional duration, and optional links to an intake form and a check-in form. The Prisma model is 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 repository include 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:
  1. The form must exist in the caller’s studio, otherwise NOT_FOUND (onboarding form not found or update form not found).
  2. assertAutomaticFormType from modules/forms/form-type.ts checks the form type. The onboarding slot accepts only INTAKE. The update slot accepts only CHECK_IN. Any other type throws BAD_REQUEST (onboarding form must be intake or update form must be check-in).
  3. A PDF_SIGNATURE form is refused in either slot with BAD_REQUEST and details.reason set to FORM_IS_DOCUMENT_TO_SIGN. A document to sign reaches the trainee only through its signing link.
Sending 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.
Case-insensitive contains match on name or tag.
integer
default:"1"
Page number, positive.
integer
default:"20"
Rows per page. Positive, maximum 500.
Response:
Errors: 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.
“Live” means a 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.
Response: the created product object. Errors: 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.
Response: one product object. Errors: 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.
The service loads every live subscription in the studio (SCHEDULED, ACTIVE, FROZEN), keeps those whose productId matches, and de-duplicates the trainee ids. Response:
Errors: 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).
The service loops over the trainee ids one at a time and calls 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.
The body accepts the same fields as create, all optional and with no defaults: 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.
The row is hard deleted. 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.