FoodLibraryItem) holds two kinds of rows:
- System foods have
studioId: nulland are shared by every studio. - Studio foods have a
studioIdand belong to one studio.
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 fullFoodLibraryItem row, with the studio’s override merged in for system foods, plus two flags.
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 inStudio.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 iscountsAswhen given, otherwise a validmacroType, 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.
roundMbp (MBP_STEP). A non-finite or non-positive input rounds to 0.
Serving tiers
A food can carryservingTiers: a list of priced servings, each with a size, unit, macros and three portion values.
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.
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
fromanchor 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.
- Anchor change.
modules/studios/studios.service.tscallsrepriceStudioFoodsinside its own settings transaction, withfromset 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.
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 hassource: "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).string
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.
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:
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.
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.
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.VALIDATION error:
all serving tiers must share the same serving unitduplicate serving sizehousehold display preference requires householdMeasureand... requires householdMeasureGrams, whendisplayUnitPreferenceishouseholdhousehold-displayed unit foods require unitNetWeight > 0, whendisplayUnitPreferenceishouseholdandunitTypeisunit
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.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:
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.
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.
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.
foodMbp(food, quantity, anchors). No countsAs is passed, so a dual-exchange food counts toward its declared macro type.
Response:
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.
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.
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.
showAsAlternative to false through the update route.
Errors:
Related helpers without a web route
- Food preferences (
food-preferences.ts).resolveFoodPreferencesworks out a trainee’s excluded foods from the coach’s saved choice or from the latest form answer, and reports thesource(coach,formornone).isFoodExcludedForandcountExcludedFoodsapply 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.