/v1/trainee, router traineeRouter in src/modules/trainee/trainee.routes.ts.
Lane: trainee. Every endpoint on this page uses both guards: the trainee token and the app access check (403 APP_LOCKED). Preview tokens can only call the GET endpoints. See Authentication.
Concepts
Plans. A nutrition plan is aProgram of type NUTRITION with status ACTIVE. A trainee can have several. Content is normalized on read by normalizeNutritionContent into days, meals and items. A file plan (pdfUrl set) has no days and no targets.
Per-day targets. Targets live on each plan day (days[].targets): kcal, protein, carbs, fat, water in litres, and optional portion targets mbpProtein, mbpCarb, mbpFat.
Day rotation. A plan day is mapped to a calendar date by offset from today: nutritionDayIndexForOffset(dayCount, offset) is the offset modulo the number of days. Offset 0, today, is the first plan day unless the trainee’s saved snapshot or the app’s hint points at another day.
Portion system. Studios can switch on a portion unit system (mbpEnabled(studio.settings)). When it is on, macros carry an mbp object with protein, carb and fat portions and omit kcal. The studio’s anchors (readMbpAnchors) convert grams to portions for foods without their own exchange data.
Two stores for a day. What the trainee did with today’s plan is one JSON document per day, the day snapshot in NutritionDayLog. Free meals are rows in MealLog. A meal added through the AI or search flow is written to both, and the snapshot entry carries the log’s id as logId so readers count it once.
Food preferences. A trainee can be off certain foods or whole categories. The answer comes from the coach or the trainee (Client.foodPreferences), or from the latest submitted form with a food preferences field. Excluded foods are never offered as substitutes, and an excluded main food is replaced before the plan reaches the app.
Plans and substitutes
GET /v1/trainee/nutrition
Returns every active nutrition plan with days, meals, items, resolved foods, macros and a preview of substitutes.
Auth: trainee token and app access.
No parameters.
How items are built:
foodis the library row resolved with the studio’s overrides. Foods the plan references are loaded even when they have since left the listed library.macrosis priced from the food’s per-100 values, or per piece whenservingUnitis the piece unit.qtyis grams or millilitres, or a piece count for piece foods.- When the main food is excluded by the trainee’s preferences, the best permitted alternative is served in its place and the original id is returned as
replacedFoodId. If nothing permitted can stand in, the original food is kept. altslists substitutes. WithautoAltsoff these are the coach’s own alternatives, minus excluded foods and foods retired from substitution (showAsAlternative). WithautoAltson they are the first 4 automatic picks (AUTO_ALTS_PREVIEW_COUNT) from the same macro family, sized to match the item. The full list comes fromGET /v1/trainee/nutrition/alternatives.
food object has the same keys as the main one. It is shortened here. Values of unit and label fields are examples: the exact strings come from the food library rows.
string
regular or file. A file plan returns pdfUrl, autoAlts: false, targets: null and days: []. No targets are invented for it.object | null
The first day’s targets. Kept for app builds that predate per-day targets. New code should read
days[].targets.boolean
Whether inline
alts are a preview of an on-demand list (true) or the whole offer (false).string
The meal slot, for example
breakfast, snack_am, lunch, pre, post, dinner. label is the coach’s custom name and may be empty.object
Present only when the coach set a per-meal limit:
kcal, or mbpProtein, mbpCarb, mbpFat. A meal can carry a target and no items, for the trainee to compose.string
protein or carb. Present for dual-exchange foods to say which macro the item counts toward.string
Present when an excluded main food was replaced. The value is the coach’s original food id.
object
Portion system off:
kcal, protein, carbs, fat. Portion system on: protein, carbs, fat in grams and mbp with protein, carb, fat portions.object | null
A ready-to-render quantity (
displayQty), in household units or grams. null for foods without display unit data.{ "plans": [] }.
Errors: none beyond the lane errors.
GET /v1/trainee/nutrition/alternatives
Returns every substitute one plan item offers. The app calls it when the trainee opens the swap sheet.
Auth: trainee token and app access.
string
required
A plan id from
GET /v1/trainee/nutrition.string
required
A meal item id inside that plan. Any day of the plan is searched.
alternativesFor and food-alternatives.ts:
- With
autoAltsoff: the coach’s alternatives for the item, minus foods the trainee excluded and foods retired from substitution. - With
autoAltson: every food in the trainee’s visible library that is in the same macro family as the item’s food, is allowed as an alternative and is not excluded. Candidates are ranked by closeness to the reference food. A food with a declared macro type belongs to that family even without macro grams. - Quantities: when the item has a portion value, each substitute is sized to exactly that many portions. Otherwise it is sized to the same calories.
- Option ids for automatic picks are deterministic:
auto:<itemId>:<foodId>. The app stores the chosen id in the day snapshot. - The food currently served for the item (the main food, or its stand-in when the main is excluded) is not listed as its own substitute.
food, macros and display shapes as a plan item.
Errors:
GET /v1/trainee/nutrition/foods
Searches the food library for adding a food to a meal.
Auth: trainee token and app access.
string
required
Trimmed, 1 to 120 characters.
number
default:"20"
Positive integer, at most 50.
effectiveFoodWhere with foodSearchMatch). Results are ordered by name.
Response: 200. The shape depends on the portion system.
Portion system off, the raw library fields:
calories, servingTiers and the exchange quantities, and adds mbp for one serving (one piece for piece foods, else servingSize or 100 grams):
VALIDATION (422) when search is empty or limit is out of range.
The day
GET /v1/trainee/nutrition/menu
A compact summary of one day built from meal logs only: targets, totals consumed and the day’s logs.
Auth: trainee token and app access.
string
ISO date. Defaults to today in the trainee’s zone. The date part of the parsed value is used.
null for a file plan or no plan. consumed sums MealLog rows whose consumedAt falls in that local day. It does not read the day snapshot, so plan items the trainee ticked off are not included here. GET /v1/trainee/home is the endpoint that combines both.
Response: 200.
date here is a full timestamp at UTC midnight of the day key, not a YYYY-MM-DD string.
Errors: VALIDATION (422) when date is not a date.
GET /v1/trainee/nutrition/day
Returns the saved day snapshot for one date.
Auth: trainee token and app access.
string
required
YYYY-MM-DD.object | null
The stored JSON exactly as the app saved it.
null when nothing was saved for the date.object
Render data for every
auto: swap in the snapshot, keyed by item id. The plan payload previews only the first few automatic alternatives, so a swap onto a food further down the list needs its option here to draw the row correctly. A swap is included only while its plan still has autoAlts on and the food is still offered to this trainee.Snapshot keys
The server treats the snapshot as the app’s document. It reads these keys throughparseDaySnapshot when it computes home, the coach views and the partner API. Keys in the app are prefixed with the date and two underscores. The parser strips everything up to and including __.
Errors:
VALIDATION (422) when date is missing or not YYYY-MM-DD.
PUT /v1/trainee/nutrition/day
Saves the day snapshot. The document replaces the stored one for that date.
Auth: trainee token and app access.
string
required
YYYY-MM-DD. Must not be after today in the trainee’s zone.object
required
The whole snapshot. Any object is accepted. The server does not validate the keys listed above.
- The row is upserted on
(clientId, date)inNutritionDayLog. - One merge rule: when
datahas noportionskey at all, the storedportionsare kept. App builds that predate per-item quantities never send the key, and without this rule one save from such a build would reset every quantity the trainee set. A build that knows the key always sends it, as an empty object once cleared. - The server only refuses future days. Which past days the app lets the trainee edit is an app decision.
- Nothing else is written.
MealLogrows are created separately withPOST /v1/trainee/nutrition/log.
Meal logs
POST /v1/trainee/nutrition/log
Records a meal the trainee ate.
Auth: trainee token and app access.
string
Accepted by the schema. The service does not store it.
string
At most 120 characters.
string
At most 2000 characters.
string
default:"PLAN"
PLAN, PHOTO, FREE_TEXT or MANUAL.string
ISO date-time. Defaults to now. Not checked against the future.
string
A valid URL, at most 1000 characters. Upload first with Trainee uploads.
object[]
default:"[]"
The foods in the meal. Any objects. Stored as sent and shown to the coach.
number
Non-negative.
number
Non-negative grams.
number
Non-negative grams.
number
Non-negative grams.
items.
Response: 201. The created MealLog row.
VALIDATION (422) when the body fails the schema.
DELETE /v1/trainee/nutrition/log/:id
Deletes one of the trainee’s meal logs.
Auth: trainee token and app access.
string
required
The meal log id.
NOT_FOUND (404) meal log not found.
GET /v1/trainee/nutrition/history
Lists the trainee’s most recent meal logs.
Auth: trainee token and app access.
No parameters. Returns the latest 50 logs by consumedAt, newest first.
Response: 200 with { "items": [...] }. Each item has id, consumedAt, source, name, note, items, calories, protein, carbs, fat and photoUrl, as in the menu example.
Errors: none beyond the lane errors.
AI meal analysis
POST /v1/trainee/nutrition/analyze
Estimates the foods and macros of a meal from a photo, a text description, or both. It does not save anything. The app shows the result for confirmation and then calls POST /v1/trainee/nutrition/log.
Auth: trainee token and app access.
string
At most 2000 characters. Quantities stated here override the visual estimate.
string
The image as base64, without a data URL prefix.
string
default:"image/jpeg"
At most 60 characters.
text (non-blank) or image is required. The request travels as JSON, so it is bound by the global 24 MB JSON body limit. The schema sets no separate image size limit.
The flow, from analyzeMeal, ai/nutrition-analyzer.ts and analysis-library.ts:
- Catalog. The trainee’s visible foods are loaded and reduced to an analysis catalog: named foods, and for piece foods only those with a known weight per piece. The names are appended to the system prompt as a numbered list.
- Model call. One structured-output call to OpenAI through the Vercel AI SDK with the model in
OPENAI_NUTRITION_MODEL. The model returns a Hebrew dish name, a Hebrew note, arejectionvalue and one entry per food with grams, calories and macros for that portion, plus alibraryIndexwhen the food is the same as a catalog entry. - Rejection. A photo whose main subject is alcohol or cannabis is refused with 400 and
details.easterEgg. - Library first. An item matched to the catalog is repriced from the library food: its name, calories and macros come from the library row for the estimated weight, and it gets
source: "library"and afoodId. - Web lookup. Items still unmatched are looked up in one batch through OpenAI’s web search tool with the model in
OPENAI_NUTRITION_LOOKUP_MODELand a 15 second timeout. Values per 100 g outside sane bounds (more than 900 kcal, or macros summing above 101 g) are discarded. A found value givessource: "online". A failed lookup is logged and the model’s own estimate stays, withsource: "estimate". - Totals. The server sums the items. When the portion system is on, each item and the meal get an
mbpobject.
source is library, online or estimate. With the portion system on, data.mbp and each items[].mbp are added as { protein, carb, fat }.
Errors:
Barcode
GET /v1/trainee/nutrition/barcode/:code
Looks up a packaged product by barcode.
Auth: trainee token and app access.
string
required
The scanned code. Non-digits are stripped. It must then be 8 to 14 digits (EAN-8 to GTIN-14).
lookupBarcode and modules/foods/openfoodfacts.ts:
- A code that is not 8 to 14 digits returns
{ "found": false }without any request. - The product cache (
repo.barcodeProduct) is checked. An entry fetched less than 30 days ago is returned directly. The cache is shared by all studios. - Otherwise Open Food Facts is queried at
world.openfoodfacts.org/api/v2/product/<barcode>.jsonwith a 6 second timeout. The Hebrew product name is preferred. A product needs a name and calories per 100 g to count. - A fetched product is saved to the cache and returned. If the fetch fails and a stale cache entry exists, the stale entry is returned.
serving is null when the producer declared none. With the portion system on, product.mbpPer100 is added. A miss returns { "found": false } with status 200, never 404.
Errors: none beyond the lane errors.
Favorites
GET /v1/trainee/nutrition/favorites
Lists the trainee’s saved favorite meals, newest first.
Auth: trainee token and app access.
Response: 200.
POST /v1/trainee/nutrition/favorites
Saves a meal as a favorite for quick logging later.
Auth: trainee token and app access.
string
required
1 to 120 characters.
string
At most 2000 characters.
string
default:"MANUAL"
PLAN, PHOTO, FREE_TEXT or MANUAL.string
A valid URL, at most 1000 characters.
object[]
The foods, stored as sent.
number
Non-negative.
number
Non-negative.
number
Non-negative.
number
Non-negative.
FavoriteMeal row, same shape as a list item.
Errors: VALIDATION (422) when the body fails the schema.
DELETE /v1/trainee/nutrition/favorites/:id
Removes a favorite.
Auth: trainee token and app access.
string
required
The favorite id.
NOT_FOUND (404) favorite not found.
Food preferences
GET /v1/trainee/food-preferences
Returns the foods and categories the trainee is off, where the answer came from, and how many library foods it excludes.
Auth: trainee token and app access.
Resolution, from resolveFoodPreferences in modules/foods/food-preferences.ts:
- If
Client.foodPreferencesis set, that is the answer andsourceiscoach. This column is written by the coach and by the trainee’s own save below. - Otherwise the latest submitted response to a form that has a food preferences field is used, and
sourceisform. - Otherwise the answer is empty and
sourceisnone.
excludedFoodIds, or when its primary category is in excludedCategoryIds and its id is not in includedExceptionIds.
Response: 200.
object | null
The latest form response that carried a food preferences answer, when one exists. It can be present even when
source is coach.number
How many of the trainee’s visible library foods the answer excludes.
NOT_FOUND (404) trainee not found.
PUT /v1/trainee/food-preferences
Replaces the trainee’s food preferences.
Auth: trainee token and app access. Preview tokens are refused at the middleware, so a coach looking at the app preview cannot save as the trainee.
string[]
default:"[]"
Up to 5000 ids, each 1 to 128 characters.
string[]
default:"[]"
Same limits.
string[]
default:"[]"
Same limits. Foods allowed even though their category is excluded.
foodPreferencesBody in clients.schema.ts) and is normalized with normalizeFoodPreferenceAnswer, so every writer stores one canonical shape. The value is saved to Client.foodPreferences with foodPreferencesUpdatedAt. From then on it takes precedence over form answers, exactly like a coach’s edit.
Side effect: a ClientActivity row of type FOOD_PREFERENCES_UPDATED with actor type TRAINEE and metadata.excludedCount is added to the trainee’s timeline. The coach is not pushed a notification. A failure to write the timeline entry does not undo the save.
Response: 200.
source is coach in this response because the value now lives in the client column, regardless of who saved it.
Errors: VALIDATION (422) when a list is too long or an id is empty or too long.