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 theContentItem 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:isPublishedis the studio-wide gate. An unpublished item is hidden from everyone.isRestrictednarrows a published item to the trainees who have aContentItemUnlockrow for 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
AContentItemUnlock 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. ThePUTroute 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:
scopeis one ofcontent,exercises,forms,plans(MEDIA_SCOPES). The default iscontent. The presigned lane has noscopefield and always usescontent.- The
plansscope acceptsapplication/pdfonly, on both lanes that take a scope. variant: "auto-thumbnail"adds theauto-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.
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.string
Case-insensitive
contains on title.integer
default:"1"
Page number, positive.
integer
default:"100"
Positive, maximum 500.
sortOrder ascending, then createdAt descending. There is no category or tag filter on the server.
Response:
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.
boolean
default:"false"
Featured flag.
boolean
default:"false"
Hide from trainees until unlocked.
integer
default:"0"
Coerced to an integer.
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.
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.
createContentBody.partial(): every create field, optional.
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.
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.
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.
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:
UNAUTHORIZED.
POST /v1/web/content/categories
Creates a category. Returns 201.
Auth: web lane, any role.
string
required
Trimmed. Between 1 and 80 characters.
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.
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.
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.
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.
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.
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.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.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.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.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.
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.
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).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.FORBIDDEN (media key does not belong to this studio), VALIDATION.
Multipart flow
On any failure the client should callabort, otherwise the uploaded parts stay in the bucket.