The content library holds what a studio publishes to its trainees: recipes, videos, knowledge articles, PDFs, links and free-form items. Items are studio-wide by default. An item can also be restricted, which hides it from every trainee until it is unlocked for them one by one. This page covers contentRouter only. The same routes file exports traineeContentRouter, mounted at /v1/trainee/content behind trainee auth, with a list, a taxonomy and a detail route for the trainee app. It is documented with the trainee lane. The media upload routes here are shared infrastructure. The exercise library, the form builder and the file plan uploader all send their files through them and only ask for a different key prefix.

The content item

Coach routes return the ContentItem row with its category and tags resolved and an unlock count.

Concepts

Categories and tags

Both are per-studio rows (ContentCategory, ContentTag) with a unique name inside the studio. Every studio gets three default categories when it is first created by the web gateway (defaultContentCategories is called from middleware/web-context.ts). They carry a systemKey of recipe, video or knowledge, a name in the studio locale (Hebrew when no locale is known), and negative sort orders so they list first. They are ordinary rows: the routes below can rename or delete them like any other category. When an item is created without a categoryId key, it is filed under the default category for its type: recipe and video map to their own, and every other type maps to knowledge. Sending categoryId: null explicitly leaves the item uncategorized. Tags are studio-wide labels. An item can carry any number of them through the ContentItemTag join table.

Visibility

Two gates decide whether a trainee sees an item:
  1. isPublished is the studio-wide gate. An unpublished item is hidden from everyone.
  2. isRestricted narrows a published item to the trainees who have a ContentItemUnlock row for it.
One exception: when the studio’s published home banner points at an unpublished item, a trainee can still open that item from the banner. The unlock gate still applies to it. isFeatured is a flag on the item. This module stores it and has an index on it. How the trainee app presents featured items is decided on the trainee side.

Unlocks

A ContentItemUnlock row grants one trainee access to one item. The key (itemId, clientId) is unique. Each row has a source:
  • manual: granted by a coach through the unlocks routes on this page. The PUT route manages these as a replace-set.
  • automation: granted by an automation flow, or by the AutoFit import. The unlocks routes list these rows and never delete them.
unlockContentForClient(prisma, input) in content-unlocks.ts is the single write path other modules use. It checks the item belongs to the studio, inserts the row, and treats a unique-key collision as success, returning { unlocked: false, already: true }. In the code read for this page it is imported by modules/autofit-import/writers/contents.ts.

Media upload lanes

There are three ways to get a file into R2. All return a public URL that you then store on an item, an exercise, a form or a plan. Accepted content types on every lane: image/png, image/jpeg, image/webp, image/gif, application/pdf, video/mp4, video/quicktime, video/webm. Object keys have one shape across lanes:
  • scope is one of content, exercises, forms, plans (MEDIA_SCOPES). The default is content. The presigned lane has no scope field and always uses content.
  • The plans scope accepts application/pdf only, on both lanes that take a scope.
  • variant: "auto-thumbnail" adds the auto-thumb- prefix to the file name. The editor uses it to tell a thumbnail it captured from the video apart from one the coach uploaded by hand.
Size ceilings: 99,000,000 bytes on the inline lane (MAX_MEDIA_BYTES), and 30 MiB for a plan PDF (MAX_PLAN_PDF_BYTES). The inline lane enforces both on the decoded buffer. On the multipart lane, the code read here checks only the per-part size. It has no check on the total. On the multipart lane, the key is minted by start, travels through the browser, and comes back on every later call, so it is caller controlled. assertOwnMediaKey therefore rejects any key that does not start with one of this studio’s scope prefixes, or whose file name contains anything outside letters, digits, dot, underscore and hyphen, or starts with a dot. This stops one studio from pointing its parts at another studio’s folder or escaping its prefix with ...

Item endpoints

GET /v1/web/content

Lists the studio’s content items, published or not. Auth: web lane, any role.
string
One of recipe, video, knowledge, pdf, other, link.
Case-insensitive contains on title.
integer
default:"1"
Page number, positive.
integer
default:"100"
Positive, maximum 500.
Items are ordered by sortOrder ascending, then createdAt descending. There is no category or tag filter on the server. Response:
The example item is shortened. Each item is a full content item. Errors: VALIDATION (422), UNAUTHORIZED.

POST /v1/web/content

Creates a content item. Returns 201. Auth: web lane, any role.
string
required
recipe, video, knowledge, pdf, other or link.
string
required
Minimum length 1.
string
Required, and not blank, when type is other.
string
Free text.
string
image, video, youtube, pdf, link or none.
string
Required, and not blank, when type is link.
string
Poster image.
string
Recipe ingredients.
object[]
Each entry is type (step or tip) and text (minimum length 1).
number | null
Between 0 and 1,000,000. The same rule applies to totalKcal, totalProtein, totalCarbs, totalFat, servingGrams, servingKcal, servingProtein, servingCarbs and servingFat.
string | null
A category in the same studio. Omit to file under the type’s default category. Send null for no category.
string[]
Tags in the same studio.
boolean
default:"true"
Studio-wide visibility.
Featured flag.
boolean
default:"false"
Hide from trainees until unlocked.
integer
default:"0"
Coerced to an integer.
Creating an item sends no notification and writes no activity record. Response: the created content item. Errors: VALIDATION for a schema failure and for the service checks: otherLabel is required when type is other, mediaUrl is required when type is link, category not found, tag not found. Note that a missing category or tag is VALIDATION (422), not NOT_FOUND.

GET /v1/web/content/:id

Returns one item, published or not. Auth: web lane, any role.
string
required
The content item id.
Response: one content item. Errors: NOT_FOUND (content item not found).

PATCH /v1/web/content/:id

Edits an item. Auth: web lane, any role.
string
required
The content item id.
The body is createContentBody.partial(): every create field, optional.
isPublished, isFeatured, isRestricted and sortOrder keep their create defaults through .partial(). With the installed Zod 4, a patch that leaves them out parses them as true, false, false and 0, and those values are written. A patch that only changes the title therefore republishes a draft, removes the featured flag, lifts the restriction and resets the sort position. Always send all four fields with their current values.
The service validates the record as it will be after the patch, not only the fields sent. Switching an item to other or link when its otherLabel or mediaUrl is blank is rejected. categoryId and tagIds are checked against the studio. When tagIds is sent, the item’s tags are replaced with that list in one transaction. Omit tagIds to leave the tags alone. Response: the updated content item. Errors: NOT_FOUND (content item not found). VALIDATION for the same checks as create.

DELETE /v1/web/content/:id

Deletes an item. Returns 204 with no body. Auth: web lane, any role.
string
required
The content item id.
The row is hard deleted. Its unlock rows cascade. A home banner that pointed at it keeps existing with contentItemId set to null (onDelete: SetNull). The media object in R2 is not deleted. Errors: NOT_FOUND (content item not found).

Unlock endpoints

GET /v1/web/content/:id/unlocks

Lists the trainees an item is unlocked for. Auth: web lane, any role.
string
required
The content item id.
Returns both manual and automation rows, newest first. Response:
Errors: NOT_FOUND (content item not found).

PUT /v1/web/content/:id/unlocks

Replaces the set of manual unlocks for an item. Auth: web lane, any role.
string
required
The content item id.
string[]
required
The full list of trainees who should hold a manual unlock. At most 5000 entries. Duplicates are removed. An empty array removes every manual unlock.
In one transaction the service deletes the manual rows whose trainee is not in the list, then inserts a manual row for every listed trainee with skipDuplicates. Automation rows are never deleted. A trainee already unlocked by an automation keeps that row, and no second row is added. The route does not set isRestricted. Unlock rows only matter on an item that is restricted. No notification is sent. Response: the full unlock list after the change, same shape as the GET response. Errors: NOT_FOUND (content item not found). VALIDATION (client not found) when any id is not a trainee of this studio.

Taxonomy endpoints

GET /v1/web/content/taxonomy

Returns every category and tag of the studio. Auth: web lane, any role. Both lists are ordered by sortOrder ascending, then createdAt ascending. Categories with no items are included. Response:
Errors: UNAUTHORIZED.

POST /v1/web/content/categories

Creates a category. Returns 201. Auth: web lane, any role.
string
required
Trimmed. Between 1 and 80 characters.
A name that already exists in the studio, compared case-insensitively, is not an error. The existing category is returned and no duplicate is created. The status is still 201. Response: the ContentCategory row, same shape as in the taxonomy response. Errors: VALIDATION.

PATCH /v1/web/content/categories/:id

Renames a category, default or custom. Auth: web lane, any role.
string
required
The category id.
string
required
Trimmed. Between 1 and 80 characters.
If another category already has that name, the rename is silently skipped and the category is returned unchanged. Compare the returned name with what you sent to detect this. Response: the ContentCategory row. Errors: NOT_FOUND (category not found), VALIDATION.

DELETE /v1/web/content/categories/:id

Deletes a category. Returns 204 with no body. Auth: web lane, any role.
string
required
The category id.
Items in the category are kept and become uncategorized (ContentItem.categoryId is onDelete: SetNull). A default category can be deleted too. After that, new items of its type are created with no category, because the lookup by systemKey finds nothing. Errors: NOT_FOUND (category not found).

POST /v1/web/content/tags

Creates a tag. Returns 201. Auth: web lane, any role.
string
required
Trimmed. Between 1 and 80 characters.
Same collision rule as categories: an existing name returns the existing tag. Response: the ContentTag row. Errors: VALIDATION.

PATCH /v1/web/content/tags/:id

Renames a tag. Auth: web lane, any role.
string
required
The tag id.
string
required
Trimmed. Between 1 and 80 characters.
A clash with another tag’s name skips the rename and returns the tag unchanged. Response: the ContentTag row. Errors: NOT_FOUND (tag not found), VALIDATION.

DELETE /v1/web/content/tags/:id

Deletes a tag. Returns 204 with no body. Auth: web lane, any role.
string
required
The tag id.
Errors: NOT_FOUND (tag not found).

Recipe AI

POST /v1/web/content/generate-recipe

Extracts a structured recipe from pasted text, a link, or both. It returns the draft and saves nothing. Auth: web lane, any role.
string
Pasted recipe text.
string
A recipe page. https:// is added when the value has no scheme. Only http and https are accepted.
At least one of the two is required after trimming. The generator (ai/recipe-generator.ts) uses OpenAI with the model in OPENAI_NUTRITION_MODEL. For a link it first fetches the page text through the shared SSRF-safe fetch. If the site blocks that fetch, it falls back to a second reader (createRecipeLinkReader) and logs a warning. If neither works and pasted text was also sent, it continues with the text alone. The model is told to write the title, description, ingredients and steps in Hebrew. Response: returns 200, not 201.
ingredients is one string with a line per ingredient. Blank ingredient lines and blank steps are dropped. The shape matches the ingredients and steps fields of the create body, so the editor can pass the draft straight into a create call. Errors: A link that cannot be read and has no pasted text to fall back on raises the fetch error from the shared safe-fetch helper. Its messages are could not fetch recipe link and recipe link had no readable text. The error code it uses was not confirmed for this page.

Media endpoints

POST /v1/web/content/media

Uploads a file inline as base64. Returns 201. Auth: web lane, any role.
string
required
Base64, raw or as a data: URL.
string
required
One of the eight accepted types.
string
Accepted, not used.
string
auto-thumbnail.
string
content, exercises, forms or plans. Default content. plans requires application/pdf.
Response:
Errors: VALIDATION for media is empty, media is too large, plan PDF is too large, the plans scope accepts application/pdf only, or an unsupported type. INTERNAL (r2 storage is not configured). BAD_REQUEST with status 413 (payload too large) when the JSON body exceeds 24mb.

POST /v1/web/content/media-url

Returns a presigned URL the browser can PUT the file to directly. Returns 201. Auth: web lane, any role.
string
required
One of the eight accepted types. The PUT must send the same Content-Type.
string
Accepted, not used.
string
auto-thumbnail.
There is no scope field. The key is always under content/. Response:
uploadUrl expires after 600 seconds. url is the public address to store once the upload succeeds. Errors: VALIDATION, INTERNAL (r2 storage is not configured).

POST /v1/web/content/media-upload/start

Opens a multipart upload. Returns 201. Auth: web lane, any role.
string
required
One of the eight accepted types.
string
Accepted, not used.
string
auto-thumbnail.
string
content, exercises, forms or plans. plans requires application/pdf.
Response:
Keep both values. Every later call needs them. Errors: VALIDATION, INTERNAL.

POST /v1/web/content/media-upload/part

Uploads one part. The body is the raw bytes, so the other inputs travel in the query string. Auth: web lane, any role.
string
required
The key from start. 1 to 512 characters.
string
required
The upload id from start. 1 to 1024 characters.
integer
required
Between 1 and 10000.
The request must be Content-Type: application/octet-stream. The route mounts its own express.raw parser with a 12mb limit. S3 semantics apply: every part except the last must be at least 5 MiB.
Response:
Collect the partNumber and etag of every part for the complete call. Errors:

POST /v1/web/content/media-upload/complete

Finishes a multipart upload. Returns 201. Auth: web lane, any role.
string
required
The key from start.
string
required
The upload id from start.
object[]
required
Between 1 and 256 entries, each with partNumber (1 to 10000) and etag (1 to 256 characters).
Response:
Errors: FORBIDDEN (media key does not belong to this studio), VALIDATION, INTERNAL.

POST /v1/web/content/media-upload/abort

Cancels a multipart upload and discards its parts. Returns 204 with no body. Auth: web lane, any role.
string
required
The key from start.
string
required
The upload id from start.
Errors: FORBIDDEN (media key does not belong to this studio), VALIDATION.

Multipart flow

On any failure the client should call abort, otherwise the uploaded parts stay in the bucket.