The food library stores the foods a coach can put in a nutrition plan. Like exercises, one table (FoodLibraryItem) holds two kinds of rows:
  • System foods have studioId: null and are shared by every studio.
  • Studio foods have a studioId and belong to one studio.
A studio can edit a system food. The edit is stored in FoodLibraryOverride, scoped to the studio, and merged on read. System foods can be edited and restored, but never deleted. This page covers foodsRouter only. The same routes file also exports traineeFoodsRouter, mounted at /v1/trainee/foods behind trainee auth, which serves the same catalog to the trainee app. It is documented with the trainee lane. Files beyond the five standard ones:

The food object

List and detail routes return the full FoodLibraryItem row, with the studio’s override merged in for system foods, plus two flags.
Values are illustrative. Real names are in Hebrew.

Concepts

System foods and overrides

FoodLibraryOverride has one row per (studioId, foodId). Unlike the exercise override, it is a full copy of the editable fields, not a sparse patch. mergeFoodOverride replaces the system row’s content fields with the override’s values on read. Overridable: name, brand, category, categories, notes, the three flags, serving size and unit, calories and macros, macro type, tiers, English name, all unit and display fields, isPreferred, and the three exchange quantities. Import metadata (source, dataConfidence, sourceTables) is not overridable. Those body fields are dropped on a system food edit. Queries that filter on a food field must be override-aware. effectiveFoodWhere(studioId, match) builds that filter: match the base row when the studio has no override, or match the override when it has one. The list filters and the search use it. Other modules that read foods by field need the same helper, otherwise a retired or renamed food leaks back in.

Portions (MBP)

Studios can work in portion units in place of grams. A portion is a fixed number of calories per macro family, set per studio as three anchors in Studio.settings: readMbpAnchors(settings) reads the three anchors, falling back to the defaults for a missing or non-positive value. An anchor must be between 20 and 2000 kcal (MBP_ANCHOR_MIN_KCAL, MBP_ANCHOR_MAX_KCAL), so a typo is refused instead of re-pricing the library. Two pricing rules exist, and they are not interchangeable:
  • Single food (portionsForFood). Portions are the total calories of the priced quantity divided by the anchor of the food’s macro family. Exactly one component is non-zero. The counted macro is countsAs when given, otherwise a valid macroType, otherwise the dominant macro by calories (pickMacroType, using 4, 4 and 9 kcal per gram).
  • Aggregate (portionsFromMacroGrams). Each macro’s gram total converts through its own anchor. This is for gram targets and logged-meal sums, which have no single macro family.
All portion values are rounded to steps of 0.05 by roundMbp (MBP_STEP). A non-finite or non-positive input rounds to 0.

Serving tiers

A food can carry servingTiers: a list of priced servings, each with a size, unit, macros and three portion values.
On save, normalizeTiers fills any missing tier portion value with the single-food rule and sorts tiers by size. A food with no tiers gets one synthesized at read time from its base serving (synthesizeBaseTier, size 100 when the food has none). resolveMbp(tiers, quantity) picks the portions for a quantity:
  • Below the smallest tier or above the largest, the nearest end tier is scaled proportionally.
  • An exact size match returns that tier’s values.
  • Otherwise the tier with the closest size is used as it is, with no interpolation.
Piece foods (serving unit equal to the Hebrew word for “unit”, SERVING_UNIT_PIECE) treat the quantity as a piece count. They resolve against tiers with unit unit when there are any, otherwise macros are multiplied by the count and priced with the single-food rule.

Exchange quantities

Food DB v2 adds per-food exchange data: proteinExchangeQty, carbExchangeQty and fatExchangeQty are the quantity of the food (grams, millilitres, or pieces for unit foods) that equals one portion. When any of them is set, portions are quantity / exchangeQty, rounded to 0.05, and the anchors are not used. foodMbp(food, qty, anchors, countsAs) in exchange.ts is the single dispatch every call site goes through: exchange data when present (hasExchangeData), otherwise the anchors path. Only one macro counts per food. With more than one exchange quantity set (the “lentils rule”, for foods that are both a protein and a carb), the counted macro is countsAs when it qualifies, otherwise the declared macroType, otherwise the first of protein, carb, fat. A food with a single exchange quantity ignores countsAs. Plan items store countsAs for this, see Program templates. displayQty(food, qty) builds the string shown to trainees. With displayUnitPreference: "household" it shows the household amount rounded to quarters with fraction glyphs and the gram equivalent in brackets. Otherwise it shows grams or millilitres. It returns null for a legacy food with no unitType and no display preference. Numbers are wrapped in bidi isolate characters so the string is safe in right-to-left text.

Re-pricing

When a studio changes its anchors, stored portion values derived from the old anchors go stale. reprice.ts plans the fix. It is pure: the caller loads the rows and applies the plan in one transaction. Nothing records whether a stored value was typed or derived, so the planner classifies each food by signature:
  • A food is re-priced only when every portion-bearing field (the tiers and the exchange quantities) looks like a derivation from one of the from anchor sets, or, for a system food, still holds the dietitian table value.
  • Anything else is treated as the coach’s own value. The whole food is skipped. A food is never updated partially.
  • Only the macros whose anchor changed are touched.
Two flows use it:
  • Anchor change. modules/studios/studios.service.ts calls repriceStudioFoods inside its own settings transaction, with from set to the old anchors.
  • Alignment. The align route on this page moves values still priced by the default anchors to the studio’s current ones (from: [DEFAULT_MBP_ANCHORS, current], to: current). It is for studios whose anchors changed before re-pricing existed.
Both hold a row lock on the studio (SELECT ... FOR UPDATE) so anchor changes and re-prices run one at a time, and both write an AuditLog row with action mbp.anchors.reprice, resource type STUDIO_SETTINGS, and a diff holding the mode, the anchors and the report. A re-price can create, update or delete override rows for system foods, and update studio foods. It does not rewrite plans. Plans store food ids and quantities, and portions are computed on read. The report shape:

Plan-only rows

A food created by the plan import for one plan only has source: "ai-private". It never appears in the list or the catalog. Reads by id still resolve it.

Endpoints

GET /v1/web/foods

Lists the foods the studio sees. Auth: web lane, any role.
string
default:"all"
all, global (system foods only) or custom (the studio’s own only).
Case-insensitive contains on name or brand, override-aware.
string
Exact match on the base row’s source.
string
Exact match on the home category, override-aware.
string
Category slugs, comma-separated or repeated. Matches a food tagged with any of them. Each value must be one of the 20 slugs: meat, fish, dairy, fruit, vegetable, legumes, snacks, dessert, gluten_free, vegan, vegetarian, drinks, sugar_free, eggs, nuts_seeds, grains, meat_substitutes, lactose_free, pareve, no_cook.
string
protein, carb, fat, comma-separated or repeated. Matches a food whose macroType is any of them.
integer
default:"1"
Page number, positive.
integer
default:"20"
Positive, maximum 10000. The high ceiling lets the nutrition builder preload the library.
Rows are ordered by createdAt descending. Overrides for the system foods on the page are loaded in one query and merged. The flags are computed at the studio’s current anchors. Response:
The example item is shortened. Each item is a full food object. Errors: VALIDATION (422) for an unknown category or macro type. UNAUTHORIZED.

GET /v1/web/foods/catalog

A flat catalog of categories and food names, for the food preferences form field. Auth: web lane, any role. No parameters. The service reads id, name, category and categories for every listed food the studio sees, with the studio’s override applied, and passes them to buildFoodCatalog:
  • A food’s category is its home category, falling back to its first tag. A food with neither is left out.
  • Only categories that hold at least one food are listed, so a category emptied in the library disappears from every form that uses the field.
  • Known categories come in the canonical slug order. Unknown ones follow, sorted by name in Hebrew collation. Category names are the Hebrew labels from FOOD_CATEGORY_SLUG_TO_HE.
  • Foods are sorted by category rank, then by name.
Response:
Category name values are Hebrew strings, shown as ... here. Errors: UNAUTHORIZED.

POST /v1/web/foods

Creates a studio food. Returns 201. Auth: web lane, any role.
string
required
Minimum length 1.
number
required
Calories per serving.
number
required
Grams per serving.
number
required
Grams per serving.
number
required
Grams per serving.
string | null
Trimmed.
string
Brand name.
string
Home category. Any string.
string[]
Tags, each one of the 20 category slugs.
string
Free text.
boolean
Flag.
boolean
When omitted, falls back to betterAlternative, then false.
boolean
Column default is true.
number
Size of the serving the macros describe.
string
Any string.
string
protein, carb or fat. When omitted, the dominant macro by calories is stored.
string
Free text origin marker.
object[]
Priced servings, see below.
string | null
gram, ml or unit.
number | null
Positive.
number | null
Positive. Grams in one piece.
string | null
Must be one of the closed list HOUSEHOLD_MEASURES_HE (Hebrew values). Compared after normalizing Hebrew quote characters.
number | null
Positive.
string | null
gram_ml or household.
string | null
Must be one of the closed list DISPLAY_UNIT_LABELS_HE.
boolean
Flag.
string | null
high, medium or estimated.
string[]
Each one of regular, vegetarian, vegan, gluten_free, lactose_free.
number | null
Positive or null. Zero is not accepted. Express “none” as null.
number | null
Same rule.
number | null
Same rule.
Each entry of servingTiers:
number
required
Positive. Sizes must be unique within the food.
string
required
gram, ml or unit. All tiers of a food must share one unit.
string
cup, tsp or tbsp.
number
required
Non-negative.
number
default:"0"
Non-negative. Same for carbGrams and fatGrams.
number
Non-negative. Same for carbMbp and fatMbp. A missing value is filled with the single-food rule at the studio’s anchors.
Cross-field rules, each a VALIDATION error:
  • all serving tiers must share the same serving unit
  • duplicate serving size
  • household display preference requires householdMeasure and ... requires householdMeasureGrams, when displayUnitPreference is household
  • household-displayed unit foods require unitNetWeight > 0, when displayUnitPreference is household and unitType is unit
Response: the created food row with systemEdited: false and a computed portionsManual. Errors: VALIDATION, UNAUTHORIZED.

POST /v1/web/foods/mbp/calculate

Previews the portions for a quantity of a food that is still being edited and may not be saved yet. Auth: web lane, any role.
number
required
Positive. Grams, millilitres, or pieces for a unit food.
object[]
Same shape as on create.
number
Base serving size.
string
Base serving unit.
number
Defaults to 0. Same for protein, carbs, fat.
string
Unit type.
string
protein, carb or fat.
number | null
Non-negative. Same for carbExchangeQty and fatExchangeQty.
string
protein or carb.
If any exchange quantity is above zero, the result is foodExchange(source, quantity, countsAs) and the anchors are not read. Otherwise the tiers sent, or a tier synthesized from the base serving, are resolved with resolveMbp at the studio’s anchors. Response:
Errors: VALIDATION, UNAUTHORIZED.

POST /v1/web/foods/reprice/preview

A dry run of a re-price. Writes nothing. Auth: web lane, OWNER or HEAD_COACH.
string
default:"change"
change previews saving the anchors in this body. align previews the alignment action and ignores the anchors.
number
Between 20 and 2000. A missing anchor keeps the stored value.
number
Between 20 and 2000.
number
Between 20 and 2000.
An empty body is accepted and previews a change to the current anchors, which reports no changed macros. Response: a re-price report, see Re-pricing. Errors: FORBIDDEN for a sub coach. VALIDATION for an anchor out of range.

POST /v1/web/foods/reprice/align

Moves foods still priced by the default anchors, or by the system table, to the studio’s current anchors. Auth: web lane, OWNER or HEAD_COACH. No body. Runs in one transaction with a 30 second timeout: lock the studio row, read the anchors under the lock, plan, apply, and write the audit row with the caller as actorId. Response: the re-price report of what was applied. Errors: FORBIDDEN. NOT_FOUND (studio not found). UNAUTHORIZED.

GET /v1/web/foods/:id

Returns one food the studio can see, with its override merged. Auth: web lane, any role.
string
required
The food id.
Response: one food object. Errors: NOT_FOUND (food not found).

GET /v1/web/foods/:id/mbp

Portions for a quantity of a saved food. Auth: web lane, any role.
string
required
The food id.
number
required
Positive. Coerced from the query string.
Loads the food with the studio’s override and returns foodMbp(food, quantity, anchors). No countsAs is passed, so a dual-exchange food counts toward its declared macro type. Response:
Errors: NOT_FOUND (food not found), VALIDATION.

PATCH /v1/web/foods/:id

Edits a food. The behaviour depends on who owns the row. Auth: web lane, any role.
string
required
The food id.
The body takes the create fields, all optional, with the same cross-field rules. Studio food. The row is updated with the fields sent. Tiers, when sent, are normalized at the studio’s anchors. System food. The shared row is never changed. The service builds a full override from the request on top of the current override, or the base row when there is none, then decides where the save lands (systemPortionsSave): This keeps a studio from collecting override rows that only restate system values, and keeps such foods following later anchor changes. Response: the food object after the change. Errors: NOT_FOUND (food not found), VALIDATION.

POST /v1/web/foods/:id/restore

Restores a system food to its system values for this studio. Auth: web lane, any role.
string
required
The food id. Must be a system food.
No body. “System values” means the values at this studio’s anchors. If those differ from the shared row’s defaults, a portions-only override is stored. Otherwise the override is deleted. Response: the food object after the restore. Errors: NOT_FOUND (food not found). BAD_REQUEST (only system foods can be restored).

DELETE /v1/web/foods/:id

Deletes a studio food. Returns 204 with no body. Auth: web lane, any role.
string
required
The food id.
The row is hard deleted. Plans that reference the id are not rewritten by this route. To hide a system food, set showAsAlternative to false through the update route. Errors:
  • Food preferences (food-preferences.ts). resolveFoodPreferences works out a trainee’s excluded foods from the coach’s saved choice or from the latest form answer, and reports the source (coach, form or none). isFoodExcludedFor and countExcludedFoods apply an answer to foods. The clients, trainee and partner modules use it. The coach routes for it are on the Clients page.
  • Open Food Facts (openfoodfacts.ts). A product lookup by barcode for the trainee app’s scanner, with a six second timeout. It is called from the trainee module, not from any route on this page.