The nutrition tab of the trainee app reads the coach’s plan, records what the trainee ate, and lets the trainee add meals by search, photo, text or barcode. Sixteen endpoints on the main trainee router serve it. Mount: /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 a Program 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:
  • food is the library row resolved with the studio’s overrides. Foods the plan references are loaded even when they have since left the listed library.
  • macros is priced from the food’s per-100 values, or per piece when servingUnit is the piece unit. qty is 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.
  • alts lists substitutes. With autoAlts off these are the coach’s own alternatives, minus excluded foods and foods retired from substitution (showAsAlternative). With autoAlts on 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 from GET /v1/trainee/nutrition/alternatives.
Response: 200.
The alternative’s 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.
With no active plan, or no studio settings row, the response is { "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.
Rules, from alternativesFor and food-alternatives.ts:
  • With autoAlts off: the coach’s alternatives for the item, minus foods the trainee excluded and foods retired from substitution.
  • With autoAlts on: 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.
Response: 200.
Each option has the same 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.
Trimmed, 1 to 120 characters.
number
default:"20"
Positive integer, at most 50.
Searches system foods and the studio’s own foods that are listed in the library, matching against the effective values after the studio’s overrides (effectiveFoodWhere with foodSearchMatch). Results are ordered by name. Response: 200. The shape depends on the portion system. Portion system off, the raw library fields:
Portion system on, each item omits calories, servingTiers and the exchange quantities, and adds mbp for one serving (one piece for piece foods, else servingSize or 100 grams):
The whole catalog as a flat list of ids and names is on Trainee food catalog. Errors: 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.
Targets come from the newest active nutrition plan for the day that maps to the date, or 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.
Response: 200.
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 through parseDaySnapshot 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.
Behaviour:
  • The row is upserted on (clientId, date) in NutritionDayLog.
  • One merge rule: when data has no portions key at all, the stored portions are 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. MealLog rows are created separately with POST /v1/trainee/nutrition/log.
Response: 200.
Errors:

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.
Totals are stored as sent. The server does not recompute them from items. Response: 201. The created MealLog row.
Errors: 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.
If the log is linked from a custom meal in a day snapshot, the app must also save the snapshot without that meal. The server does not edit snapshots. Response: 200.
Errors: 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.
At least one of 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:
  1. 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.
  2. 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, a rejection value and one entry per food with grams, calories and macros for that portion, plus a libraryIndex when the food is the same as a catalog entry.
  3. Rejection. A photo whose main subject is alcohol or cannabis is refused with 400 and details.easterEgg.
  4. 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 a foodId.
  5. Web lookup. Items still unmatched are looked up in one batch through OpenAI’s web search tool with the model in OPENAI_NUTRITION_LOOKUP_MODEL and 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 gives source: "online". A failed lookup is logged and the model’s own estimate stays, with source: "estimate".
  6. Totals. The server sums the items. When the portion system is on, each item and the meal get an mbp object.
Response: 200.
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).
The lookup, from lookupBarcode and modules/foods/openfoodfacts.ts:
  1. A code that is not 8 to 14 digits returns { "found": false } without any request.
  2. 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.
  3. Otherwise Open Food Facts is queried at world.openfoodfacts.org/api/v2/product/<barcode>.json with a 6 second timeout. The Hebrew product name is preferred. A product needs a name and calories per 100 g to count.
  4. 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.
The studio’s own food library is not searched by barcode. Response: 200.
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.
Errors: none beyond the lane errors.

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.
Response: 201. The created 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.
Response: 200.
Errors: 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:
  1. If Client.foodPreferences is set, that is the answer and source is coach. This column is written by the coach and by the trainee’s own save below.
  2. Otherwise the latest submitted response to a form that has a food preferences field is used, and source is form.
  3. Otherwise the answer is empty and source is none.
A food is excluded when its id is in 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.
Category ids are the food category slugs. The list of categories and foods to render the picker is on Trainee food catalog. Errors: 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.
The body is the same schema the coach lane uses (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.