batchCooking/apps/api/src/db/reference-seed-data.ts
Nicolas de500e1a8a feat(recipes): catalogue de référence pour les unités d'ingrédients
Remplace l'unité texte libre de RecipeIngredient (max 20 caractères,
"g"/"grammes"/"G"... jamais fiable à additionner) par une référence
vers un nouveau catalogue Unit (id/key/type/toBaseFactor), même
traitement que Diet/Allergy/Ingredient : GET /reference/units, seedé
par reference-seed-data.ts (14 unités : gram/kilogram/milliliter/
centiliter/liter/tablespoon/teaspoon/piece/pinch/slice/clove/bunch/
sachet/sprig), sélectionnable uniquement via un <select> dans le
formulaire recette (plus de saisie libre).

`toBaseFactor` (combien d'unités de base — gramme pour MASS,
millilitre pour VOLUME — vaut une unité) pose les bases d'une future
fonctionnalité de conversion (ex. liste de courses additionnant
"500g" + "0.5kg") sans construire cette fonctionnalité elle-même —
les unités COUNT restent à toBaseFactor=1, non convertibles entre
elles (une "pincée" n'est pas une fraction fixe d'une "gousse").

Migration : recipe_ingredient.unit → unit_id (FK), breaking change
sans backfill assumé (pas de recette réelle en prod actuellement,
voir commentaire de migration) — mêmes garde-fous service-side que
ingredientId (404 UNIT_NOT_FOUND) et mêmes tests de couverture.
2026-08-19 22:34:57 +02:00

1244 lines
51 KiB
TypeScript

import type {
AllergenKind,
IngredientCategory,
IngredientIcon,
IngredientSubcategory,
PrismaClient,
UnitType,
} from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive. Authored directly as
// English camelCase `uid`s (the exact value stored as `Diet.key`) — no
// French label, no separate lookup table (see the module doc comment on
// `seedReferenceData` below). The French display text for each lives
// solely in `apps/web`'s `locales/fr/translation.json` (`catalog.diets`),
// maintained independently, tied together only by this same uid string.
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
// Recipe ingredient units — a closed, normalized set replacing what used to
// be free text (see `Unit`/`RecipeIngredient.unitId` in schema.prisma for
// why). `toBaseFactor` is how many of the type's base unit (gram for MASS,
// milliliter for VOLUME, itself for COUNT) one of this unit equals — MASS
// and VOLUME units convert against each other within their own type, COUNT
// units don't convert to one another at all (a "pincée" isn't a fixed
// fraction of a "gousse"), so each just gets `1`. Same "English camelCase
// uid, no French label" authoring as `DIETS`/`ALLERGENS` — the display
// label lives in `apps/web`'s `locales/fr/translation.json` under
// `catalog.units.<key>`.
export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> = [
{ uid: "gram", type: "MASS", toBaseFactor: 1 },
{ uid: "kilogram", type: "MASS", toBaseFactor: 1000 },
{ uid: "milliliter", type: "VOLUME", toBaseFactor: 1 },
{ uid: "centiliter", type: "VOLUME", toBaseFactor: 10 },
{ uid: "liter", type: "VOLUME", toBaseFactor: 1000 },
{ uid: "tablespoon", type: "VOLUME", toBaseFactor: 15 },
{ uid: "teaspoon", type: "VOLUME", toBaseFactor: 5 },
{ uid: "piece", type: "COUNT", toBaseFactor: 1 },
{ uid: "pinch", type: "COUNT", toBaseFactor: 1 },
{ uid: "slice", type: "COUNT", toBaseFactor: 1 },
{ uid: "clove", type: "COUNT", toBaseFactor: 1 },
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune
// reaction) vs INTOLERANCE (non-immune — gluten sensitivity, sulfite
// sensitivity) per the product decision discussed in chat: only Gluten and
// Sulfites are commonly-recognized intolerances among the 14; the rest are
// true allergens.
export const ALLERGENS: Array<{ uid: string; kind: AllergenKind }> = [
{ uid: "gluten", kind: "INTOLERANCE" },
{ uid: "crustaceans", kind: "ALLERGY" },
{ uid: "eggs", kind: "ALLERGY" },
{ uid: "fish", kind: "ALLERGY" },
{ uid: "peanuts", kind: "ALLERGY" },
{ uid: "soy", kind: "ALLERGY" },
{ uid: "milk", kind: "ALLERGY" },
{ uid: "treeNuts", kind: "ALLERGY" },
{ uid: "celery", kind: "ALLERGY" },
{ uid: "mustard", kind: "ALLERGY" },
{ uid: "sesameSeeds", kind: "ALLERGY" },
{ uid: "sulfites", kind: "INTOLERANCE" },
{ uid: "lupin", kind: "ALLERGY" },
{ uid: "molluscs", kind: "ALLERGY" },
];
interface IngredientSeed {
/** English camelCase identifier, authored directly — also the DB `key` value (no French label, no separate lookup table). */
uid: string;
/**
* Generic pictogram type, overriding its group's `defaultIcon` below —
* only needed for the exceptions within a subcategory (a wedge of cheese
* inside an otherwise-milk "produits laitiers" group, a stockpot inside
* an otherwise-flour "bases" group…). See `IngredientIcon` in
* schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx`
* for the actual pictograms — this was a free-text emoji field until the
* product decision recorded in chat replaced it with this small, shared
* vocabulary.
*/
icon?: IngredientIcon;
allergenUids: string[];
/**
* Diet regimes this ingredient is compatible with, overriding its group's
* `defaultDiets` below — only needed for the exceptions within a
* subcategory (a fish-based stock inside an otherwise-vegan "bases"
* group, a butter-based dough inside an otherwise-vegan "pâtes à
* cuire"…). References `DIETS` by uid, same as `allergenUids`
* references `ALLERGENS`. Deliberately never includes `"omnivore"`
* (trivial, every ingredient qualifies) or `"glutenFree"` (derived from
* `allergenUids` instead — see `IngredientDiet` in schema.prisma for
* why).
*/
dietUids?: string[];
/**
* Whether this ingredient is reasonably makeable at home (a burger bun, a
* béchamel) rather than something you'd only ever buy (a raw vegetable, a
* specific cut of meat) — see `Ingredient.reproducible` in schema.prisma.
* Omitted (falsy) by default; only set `true` on the curated subset this
* is actually true for. Never group-level (unlike `defaultDiets`/
* `defaultIcon`) — even a homogeneous-looking group like "Pains" mixes
* genuinely home-bakeable items (`Pain`, `Naan`) with ones nobody
* realistically bakes from scratch (`Pain de seigle`, `Biscotte`), so this
* needs a per-item judgment call, not a group default.
*/
reproducible?: boolean;
}
// A broad pantry list — the goal is to cover the large majority of what a
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
// condiments, épices...), not just enough to exercise the recipe catalog in
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
// `key` is `@unique`, there's no create/update/delete endpoint), so this is
// meant to already be comprehensive at first deploy rather than grown
// piecemeal as recipes need more of it. `allergenUids` reference
// `ALLERGENS` above by uid — every one of the 14 EU-regulated allergens is
// covered by at least one ingredient here.
//
// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/
// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy
// ("rayons") reworked from an earlier, less intuitive scheme that mixed
// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones
// ("légumes") — an ingredient's category used to depend on which angle you
// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses
// this to offer category browsing, not just free-text search — with 400+
// ingredients, search alone doesn't scale to actually *finding* something,
// and a single flat list of 7 aisles alone wouldn't either (some aisles
// would be 100+ items deep). Each group's key is the single source of
// truth for that mapping; `INGREDIENTS` below just flattens it back to one
// array for the seeding loop.
//
// `defaultDiets`/`defaultIcon` are what every item in the group shares
// unless it sets its own `dietUids`/`icon` — most groups are homogeneous
// on both counts (a vegetable is always vegan and always looks like a
// vegetable; a cut of meat never is and never does), so this avoids
// repeating the same values on hundreds of items; only a group's
// exceptions (a fish-based stock inside "bases", a cheese inside "produits
// laitiers"…) need a per-item override.
export const INGREDIENT_GROUPS: Array<{
category: IngredientCategory;
subcategory: IngredientSubcategory;
defaultDiets: string[];
defaultIcon: IngredientIcon;
items: IngredientSeed[];
}> = [
// =========================================================================
// Produits frais
// =========================================================================
{
category: "freshProduce",
subcategory: "vegetables",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "VEGETABLE",
items: [
{ uid: "tomato", allergenUids: [] },
{ uid: "onion", allergenUids: [] },
{ uid: "shallot", allergenUids: [] },
{ uid: "garlic", allergenUids: [] },
{ uid: "carrot", allergenUids: [] },
{ uid: "zucchini", allergenUids: [] },
{ uid: "cucumber", allergenUids: [] },
{ uid: "gherkins", allergenUids: [] },
{ uid: "bellPepper", allergenUids: [] },
{ uid: "mushroom", allergenUids: [] },
{ uid: "porcini", allergenUids: [] },
{ uid: "eggplant", allergenUids: [] },
{ uid: "broccoli", allergenUids: [] },
{ uid: "cauliflower", allergenUids: [] },
{ uid: "whiteCabbage", allergenUids: [] },
{ uid: "redCabbage", allergenUids: [] },
{ uid: "brusselsSprouts", allergenUids: [] },
{ uid: "spinach", allergenUids: [] },
{ uid: "swissChard", allergenUids: [] },
{ uid: "lettuce", allergenUids: [] },
{ uid: "arugula", allergenUids: [] },
{ uid: "watercress", allergenUids: [] },
{ uid: "leek", allergenUids: [] },
{ uid: "celery", allergenUids: ["celery"] },
{ uid: "radish", allergenUids: [] },
{ uid: "beetroot", allergenUids: [] },
{ uid: "turnip", allergenUids: [] },
{ uid: "parsnip", allergenUids: [] },
{ uid: "greenBean", allergenUids: [] },
{ uid: "pea", allergenUids: [] },
{ uid: "corn", allergenUids: [] },
{ uid: "artichoke", allergenUids: [] },
{ uid: "fennel", allergenUids: [] },
{ uid: "endive", allergenUids: [] },
{ uid: "pumpkin", allergenUids: [] },
{ uid: "butternutSquash", allergenUids: [] },
{ uid: "asparagus", allergenUids: [] },
{ uid: "avocado", allergenUids: [] },
{ uid: "potato", allergenUids: [] },
{ uid: "sweetPotato", allergenUids: [] },
{ uid: "cherryTomato", allergenUids: [] },
{ uid: "bokChoy", allergenUids: [] },
{ uid: "soybeanSprouts", allergenUids: ["soy"] },
{ uid: "shiitake", allergenUids: [] },
{ uid: "daikon", allergenUids: [] },
{ uid: "freshGreenChili", allergenUids: [] },
{ uid: "cardoon", allergenUids: [] },
// Second Ciqual pass — see the PR description.
{ uid: "radicchio", allergenUids: [] },
{ uid: "romanesco", allergenUids: [] },
{ uid: "kohlrabi", allergenUids: [] },
{ uid: "napaCabbage", allergenUids: [] },
{ uid: "celeriac", allergenUids: ["celery"] },
{ uid: "okra", allergenUids: [] },
{ uid: "springOnion", allergenUids: [] },
{ uid: "redKuriSquash", allergenUids: [] },
{ uid: "rutabaga", allergenUids: [] },
{ uid: "samphire", allergenUids: [] },
{ uid: "salsify", allergenUids: [] },
{ uid: "lambsLettuce", allergenUids: [] },
{ uid: "escarole", allergenUids: [] },
],
},
{
category: "freshProduce",
subcategory: "fruits",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "FRUIT",
items: [
{ uid: "lemon", allergenUids: [] },
{ uid: "lime", allergenUids: [] },
{ uid: "apple", allergenUids: [] },
{ uid: "pear", allergenUids: [] },
{ uid: "banana", allergenUids: [] },
{ uid: "orange", allergenUids: [] },
{ uid: "clementine", allergenUids: [] },
{ uid: "grapefruit", allergenUids: [] },
{ uid: "strawberry", allergenUids: [] },
{ uid: "raspberry", allergenUids: [] },
{ uid: "blueberry", allergenUids: [] },
{ uid: "blackberry", allergenUids: [] },
{ uid: "cherry", allergenUids: [] },
{ uid: "apricot", allergenUids: [] },
{ uid: "peach", allergenUids: [] },
{ uid: "plum", allergenUids: [] },
{ uid: "grape", allergenUids: [] },
{ uid: "melon", allergenUids: [] },
{ uid: "watermelon", allergenUids: [] },
{ uid: "pineapple", allergenUids: [] },
{ uid: "mango", allergenUids: [] },
{ uid: "kiwi", allergenUids: [] },
{ uid: "fig", allergenUids: [] },
{ uid: "date", allergenUids: [] },
{ uid: "lychee", allergenUids: [] },
{ uid: "pomegranate", allergenUids: [] },
{ uid: "rhubarb", allergenUids: [] },
{ uid: "quince", allergenUids: [] },
{ uid: "blackcurrant", allergenUids: [] },
{ uid: "cranberry", allergenUids: [] },
// Second Ciqual pass.
{ uid: "redcurrant", allergenUids: [] },
{ uid: "persimmon", allergenUids: [] },
{ uid: "nectarine", allergenUids: [] },
{ uid: "tamarind", allergenUids: [] },
],
},
{
category: "freshProduce",
subcategory: "freshHerbs",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "HERB",
items: [
{ uid: "basil", allergenUids: [] },
{ uid: "parsley", allergenUids: [] },
{ uid: "thyme", allergenUids: [] },
{ uid: "rosemary", allergenUids: [] },
{ uid: "bayLeaf", allergenUids: [] },
{ uid: "chives", allergenUids: [] },
{ uid: "freshCilantro", allergenUids: [] },
{ uid: "mint", allergenUids: [] },
{ uid: "oregano", allergenUids: [] },
{ uid: "dill", allergenUids: [] },
{ uid: "tarragon", allergenUids: [] },
{ uid: "savory", allergenUids: [] },
{ uid: "marjoram", allergenUids: [] },
{ uid: "sage", allergenUids: [] },
{ uid: "chervil", allergenUids: [] },
{ uid: "ginger", allergenUids: [] },
{ uid: "lemongrass", allergenUids: [] },
{ uid: "kaffirLime", allergenUids: [] },
],
},
// =========================================================================
// Boucherie & poissonnerie
// =========================================================================
{
category: "meatAndSeafood",
subcategory: "meats",
defaultDiets: [],
defaultIcon: "MEAT",
items: [
{ uid: "rabbit", allergenUids: [] },
{ uid: "groundBeef", allergenUids: [] },
{ uid: "beefSteak", allergenUids: [] },
{ uid: "beefRoast", allergenUids: [] },
{ uid: "vealCutlet", allergenUids: [] },
{ uid: "porkTenderloin", allergenUids: [] },
{ uid: "porkChop", allergenUids: [] },
{ uid: "lamb", allergenUids: [] },
{ uid: "legOfLamb", allergenUids: [] },
{ uid: "baconLardons", allergenUids: [] },
{ uid: "bacon", allergenUids: [] },
{ uid: "ham", allergenUids: [] },
{ uid: "curedHam", allergenUids: [] },
{ uid: "sausage", allergenUids: [] },
{ uid: "chorizo", allergenUids: [] },
{ uid: "merguez", allergenUids: [] },
{ uid: "prosciutto", allergenUids: [] },
{ uid: "pancetta", allergenUids: [] },
{ uid: "mortadella", allergenUids: [] },
{ uid: "salami", allergenUids: [] },
// Added from the Ciqual 2025 "aliments moyens" table — French
// charcuterie/offal/game staples the pantry list was missing (see
// the PR description for how this batch was sourced/curated).
{ uid: "andouille", allergenUids: [] },
{ uid: "andouillette", allergenUids: [] },
{ uid: "whitePudding", allergenUids: [] },
{ uid: "blackPudding", allergenUids: [] },
{ uid: "cervelat", allergenUids: [] },
{ uid: "rillettes", allergenUids: [] },
{ uid: "dryCuredSausage", allergenUids: [] },
{ uid: "bayonneHam", allergenUids: [] },
{ uid: "coppa", allergenUids: [] },
{ uid: "rosetteSausage", allergenUids: [] },
{ uid: "vealLiver", allergenUids: [] },
{ uid: "vealKidneys", allergenUids: [] },
{ uid: "vealBrain", allergenUids: [] },
{ uid: "vealSweetbread", allergenUids: [] },
{ uid: "beefTongue", allergenUids: [] },
{ uid: "tripe", allergenUids: [] },
{ uid: "venison", allergenUids: [] },
{ uid: "roeDeer", allergenUids: [] },
{ uid: "wildBoar", allergenUids: [] },
// Second Ciqual pass.
{ uid: "horseMeat", allergenUids: [] },
{ uid: "beefHeart", allergenUids: [] },
{ uid: "foieGras", allergenUids: [] },
{ uid: "beefMuzzle", allergenUids: [] },
{ uid: "grisonsDriedBeef", allergenUids: [] },
],
},
{
category: "meatAndSeafood",
subcategory: "poultry",
defaultDiets: [],
defaultIcon: "POULTRY",
items: [
{ uid: "chicken", allergenUids: [] },
{ uid: "turkey", allergenUids: [] },
{ uid: "duck", allergenUids: [] },
{ uid: "duckBreast", allergenUids: [] },
// Ciqual 2025 additions — see the VIANDES group above.
{ uid: "quail", allergenUids: [] },
{ uid: "guineaFowl", allergenUids: [] },
{ uid: "goose", allergenUids: [] },
{ uid: "poultryLiver", allergenUids: [] },
// Second Ciqual pass.
{ uid: "capon", allergenUids: [] },
{ uid: "pigeon", allergenUids: [] },
{ uid: "pheasant", allergenUids: [] },
],
},
{
category: "meatAndSeafood",
subcategory: "fish",
defaultDiets: ["pescatarian"],
defaultIcon: "FISH",
items: [
{ uid: "salmon", allergenUids: ["fish"] },
{ uid: "tuna", allergenUids: ["fish"] },
{ uid: "cod", allergenUids: ["fish"] },
{ uid: "trout", allergenUids: ["fish"] },
{ uid: "sardine", allergenUids: ["fish"] },
{ uid: "anchovy", allergenUids: ["fish"] },
{ uid: "whiting", allergenUids: ["fish"] },
{ uid: "surimi", allergenUids: ["fish"] },
{ uid: "seaBass", allergenUids: ["fish"] },
{ uid: "seaBream", allergenUids: ["fish"] },
{ uid: "sole", allergenUids: ["fish"] },
{ uid: "turbot", allergenUids: ["fish"] },
{ uid: "hake", allergenUids: ["fish"] },
{ uid: "pollock", allergenUids: ["fish"] },
{ uid: "saithe", allergenUids: ["fish"] },
{ uid: "haddock", allergenUids: ["fish"] },
{ uid: "mackerel", allergenUids: ["fish"] },
{ uid: "herring", allergenUids: ["fish"] },
{ uid: "redMullet", allergenUids: ["fish"] },
{ uid: "skate", allergenUids: ["fish"] },
{ uid: "monkfish", allergenUids: ["fish"] },
{ uid: "halibut", allergenUids: ["fish"] },
{ uid: "swordfish", allergenUids: ["fish"] },
{ uid: "carp", allergenUids: ["fish"] },
{ uid: "pike", allergenUids: ["fish"] },
{ uid: "perch", allergenUids: ["fish"] },
{ uid: "tilapia", allergenUids: ["fish"] },
{ uid: "pangasius", allergenUids: ["fish"] },
{ uid: "smokedSalmon", allergenUids: ["fish"] },
{ uid: "driedFish", allergenUids: ["fish"] },
// Ciqual 2025 additions.
{ uid: "eel", allergenUids: ["fish"] },
{ uid: "plaice", allergenUids: ["fish"] },
// Second Ciqual pass.
{ uid: "saltCod", allergenUids: ["fish"] },
{ uid: "lemonSole", allergenUids: ["fish"] },
{ uid: "scorpionfish", allergenUids: ["fish"] },
],
},
{
category: "meatAndSeafood",
subcategory: "shellfish",
defaultDiets: ["pescatarian"],
defaultIcon: "SHELLFISH",
items: [
{ uid: "shrimp", allergenUids: ["crustaceans"] },
{ uid: "langoustine", allergenUids: ["crustaceans"] },
{ uid: "lobster", allergenUids: ["crustaceans"] },
{ uid: "crab", allergenUids: ["crustaceans"] },
{ uid: "spinyLobster", allergenUids: ["crustaceans"] },
{ uid: "mussels", allergenUids: ["molluscs"] },
{ uid: "oysters", allergenUids: ["molluscs"] },
{ uid: "scallops", allergenUids: ["molluscs"] },
{ uid: "squid", allergenUids: ["molluscs"] },
{ uid: "octopus", allergenUids: ["molluscs"] },
{ uid: "clams", allergenUids: ["molluscs"] },
{ uid: "whelks", allergenUids: ["molluscs"] },
// Ciqual 2025 additions.
{ uid: "spiderCrab", allergenUids: ["crustaceans"] },
{ uid: "periwinkle", allergenUids: ["molluscs"] },
// Second Ciqual pass.
{ uid: "crayfish", allergenUids: ["crustaceans"] },
{ uid: "greyShrimp", allergenUids: ["crustaceans"] },
{ uid: "cockle", allergenUids: ["molluscs"] },
{ uid: "snail", allergenUids: ["molluscs"] },
{ uid: "cuttlefish", allergenUids: ["molluscs"] },
],
},
// =========================================================================
// Épicerie sèche
// =========================================================================
{
category: "dryGoods",
subcategory: "starches",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "GRAIN",
items: [
{ uid: "semolina", allergenUids: ["gluten"] },
{ uid: "couscous", allergenUids: ["gluten"] },
{ uid: "bulgur", allergenUids: ["gluten"] },
{ uid: "polenta", allergenUids: [] },
{ uid: "quinoa", allergenUids: [] },
{ uid: "pasta", allergenUids: ["gluten"] },
{ uid: "wholeWheatPasta", allergenUids: ["gluten"] },
{ uid: "rice", allergenUids: [] },
{ uid: "basmatiRice", allergenUids: [] },
{ uid: "brownRice", allergenUids: [] },
{ uid: "oats", allergenUids: ["gluten"] },
{ uid: "spaghetti", allergenUids: ["gluten"] },
{ uid: "penne", allergenUids: ["gluten"] },
{ uid: "tagliatelle", allergenUids: ["gluten"] },
{ uid: "lasagnaSheets", allergenUids: ["gluten"] },
{ uid: "gnocchi", allergenUids: ["gluten"] },
{ uid: "arborioRice", allergenUids: [] },
{ uid: "riceNoodles", allergenUids: [] },
{ uid: "udonNoodles", allergenUids: ["gluten"] },
{ uid: "sobaNoodles", allergenUids: ["gluten"] },
{ uid: "chineseNoodles", allergenUids: ["gluten"] },
{ uid: "riceVermicelli", allergenUids: [] },
{ uid: "soyVermicelli", allergenUids: [] },
{ uid: "stickyRice", allergenUids: [] },
{ uid: "sushiRice", allergenUids: [] },
{ uid: "jasmineRice", allergenUids: [] },
],
},
{
category: "dryGoods",
subcategory: "legumes",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "LEGUME",
items: [
{ uid: "greenLentils", allergenUids: [] },
{ uid: "redLentils", allergenUids: [] },
{ uid: "chickpeas", allergenUids: [] },
{ uid: "whiteBeans", allergenUids: [] },
{ uid: "kidneyBeans", allergenUids: [] },
{ uid: "blackBeans", allergenUids: [] },
{ uid: "splitPeas", allergenUids: [] },
{ uid: "favaBeans", allergenUids: [] },
{ uid: "edamame", allergenUids: ["soy"] },
{ uid: "pintoBeans", allergenUids: [] },
// Second Ciqual pass.
{ uid: "flageoletBeans", allergenUids: [] },
{ uid: "goldenLentils", allergenUids: [] },
],
},
{
category: "dryGoods",
subcategory: "nutsAndSeeds",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "NUT_SEED",
items: [
{ uid: "peanutsShelled", allergenUids: ["peanuts"] },
{ uid: "almonds", allergenUids: ["treeNuts"] },
{ uid: "walnuts", allergenUids: ["treeNuts"] },
{ uid: "hazelnuts", allergenUids: ["treeNuts"] },
{ uid: "cashews", allergenUids: ["treeNuts"] },
{ uid: "pistachios", allergenUids: ["treeNuts"] },
{ uid: "pecans", allergenUids: ["treeNuts"] },
{ uid: "almondPowder", allergenUids: ["treeNuts"] },
{ uid: "pineNuts", allergenUids: [] },
{ uid: "sunflowerSeeds", allergenUids: [] },
{ uid: "pumpkinSeeds", allergenUids: [] },
{ uid: "shreddedCoconut", allergenUids: [] },
{ uid: "raisins", allergenUids: ["sulfites"] },
{ uid: "prunes", allergenUids: ["sulfites"] },
{ uid: "driedApricots", allergenUids: ["sulfites"] },
{ uid: "sesameSeeds", allergenUids: ["sesameSeeds"] },
],
},
{
category: "dryGoods",
subcategory: "other",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "blackMushrooms", allergenUids: [] },
{ uid: "noriSeaweed", allergenUids: [] },
{ uid: "wakameSeaweed", allergenUids: [] },
{ uid: "kombuSeaweed", allergenUids: [] },
{ uid: "bambooShoots", allergenUids: [] },
{ uid: "waterChestnuts", allergenUids: [] },
],
},
// =========================================================================
// Boulangerie
// =========================================================================
{
category: "bakery",
subcategory: "breads",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "BREAD",
items: [
{ uid: "bread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "sandwichBread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "wholeWheatBread", allergenUids: ["gluten"] },
{ uid: "baguette", allergenUids: ["gluten"] },
{ uid: "ryeBread", allergenUids: ["gluten"] },
{ uid: "breadcrumbs", reproducible: true, allergenUids: ["gluten"] },
{
uid: "burgerBun",
reproducible: true,
allergenUids: ["gluten", "milk", "eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "briocheBun",
allergenUids: ["gluten", "milk", "eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "hotDogBun", reproducible: true, allergenUids: ["gluten"] },
{ uid: "pitaBread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "bagel", reproducible: true, allergenUids: ["gluten"] },
{ uid: "naan", reproducible: true, allergenUids: ["gluten"] },
{ uid: "wrapBread", allergenUids: ["gluten"] },
{
uid: "vienneseBread",
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "countryBread", allergenUids: ["gluten"] },
{ uid: "multigrainBread", allergenUids: ["gluten"] },
{ uid: "breadRoll", allergenUids: ["gluten"] },
{ uid: "swedishBread", allergenUids: ["gluten"] },
{ uid: "glutenFreeBread", allergenUids: [] },
{ uid: "rusk", allergenUids: ["gluten"] },
{ uid: "croutons", reproducible: true, allergenUids: ["gluten"] },
{ uid: "focaccia", reproducible: true, allergenUids: ["gluten"] },
{ uid: "ciabatta", reproducible: true, allergenUids: ["gluten"] },
{ uid: "cornTortilla", allergenUids: [] },
{ uid: "wheatTortilla", allergenUids: ["gluten"] },
// Second Ciqual pass.
{ uid: "breadstick", reproducible: true, allergenUids: ["gluten"] },
],
},
{
category: "bakery",
subcategory: "rawDough",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "DOUGH",
items: [
{
uid: "puffPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "shortcrustPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "pizzaDough", reproducible: true, allergenUids: ["gluten"] },
{
uid: "sweetShortcrustPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
],
},
// =========================================================================
// Crémerie & fromage
// =========================================================================
{
category: "dairyAndCheese",
subcategory: "dairy",
defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "MILK",
items: [
{ uid: "milk", allergenUids: ["milk"] },
{ uid: "butter", allergenUids: ["milk"] },
{ uid: "cremeFraiche", allergenUids: ["milk"] },
{ uid: "liquidCream", allergenUids: ["milk"] },
{ uid: "cheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "emmental", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gruyere", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "parmesan", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "mozzarella", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "goatCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "feta", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "comte", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "fromageBlanc", allergenUids: ["milk"] },
{ uid: "mascarpone", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "yogurt", allergenUids: ["milk"] },
{ uid: "burrata", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "ricotta", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "pecorino", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gorgonzola", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cheddar", icon: "CHEESE", allergenUids: ["milk"] },
// Ciqual 2025 additions — classic French regional cheeses the
// catalog only had a handful of internationally-known ones for
// (Emmental, Parmesan, Mozzarella…), missing the actual French
// aisle staples Ciqual's "aliments moyens" table surfaced.
{ uid: "brie", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "camembert", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "roquefort", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "munster", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "reblochon", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cantal", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "beaufort", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "saintNectaire", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "blueCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cancoillotte", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "tomme", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "epoisses", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "chaource", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "livarot", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "pontLeveque", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "morbier", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "racletteCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "fourmeDAmbert", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "salers", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "ossauIraty", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "vacherin", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "saintMarcellin", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "neufchatel", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "crottinDeChavignol", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "abondanceCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "carreDeLEst", icon: "CHEESE", allergenUids: ["milk"] },
// Second Ciqual pass.
{ uid: "edam", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gouda", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "mimolette", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "maroilles", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "montDor", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "kefir", allergenUids: ["milk"] },
{ uid: "greekYogurt", allergenUids: ["milk"] },
],
},
{
category: "dairyAndCheese",
subcategory: "eggs",
defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "EGG",
items: [{ uid: "egg", allergenUids: ["eggs"] }],
},
{
category: "dairyAndCheese",
subcategory: "plantBasedAlternatives",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SPROUT",
items: [
{ uid: "coconutMilk", icon: "MILK", allergenUids: [] },
{ uid: "coconutCream", icon: "MILK", allergenUids: [] },
{ uid: "almondMilk", icon: "MILK", allergenUids: ["treeNuts"] },
{ uid: "oatMilk", icon: "MILK", allergenUids: ["gluten"] },
{ uid: "tofu", allergenUids: ["soy"] },
{ uid: "silkenTofu", allergenUids: ["soy"] },
],
},
// =========================================================================
// Condiments & épices
// =========================================================================
{
category: "condimentsAndSpices",
subcategory: "spices",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SPICE",
items: [
{ uid: "herbesDeProvence", allergenUids: [] },
{ uid: "blackPepper", allergenUids: [] },
{ uid: "paprika", allergenUids: [] },
{ uid: "espelettePepper", allergenUids: [] },
{ uid: "cayennePepper", allergenUids: [] },
{ uid: "cumin", allergenUids: [] },
{ uid: "curryPowder", allergenUids: [] },
{ uid: "turmeric", allergenUids: [] },
{ uid: "cinnamon", allergenUids: [] },
{ uid: "nutmeg", allergenUids: [] },
{ uid: "saffron", allergenUids: [] },
{ uid: "clove", allergenUids: [] },
{ uid: "vanillaBean", allergenUids: [] },
{ uid: "whitePepper", allergenUids: [] },
{ uid: "pinkPepper", allergenUids: [] },
{ uid: "sichuanPepper", allergenUids: [] },
{ uid: "smokedPaprika", allergenUids: [] },
{ uid: "birdEyeChili", allergenUids: [] },
{ uid: "juniperBerries", allergenUids: [] },
{ uid: "starAnise", allergenUids: [] },
{ uid: "greenAnise", allergenUids: [] },
{ uid: "fennelSeeds", allergenUids: [] },
{ uid: "sumac", allergenUids: [] },
{ uid: "nigella", allergenUids: [] },
{ uid: "allspice", allergenUids: [] },
{ uid: "colomboPowder", allergenUids: [] },
{ uid: "baharat", allergenUids: [] },
{ uid: "horseradish", allergenUids: [] },
{ uid: "herbSalt", allergenUids: [] },
{ uid: "celerySalt", allergenUids: ["celery"] },
{ uid: "fleurDeSel", allergenUids: [] },
{ uid: "salt", allergenUids: [] },
{ uid: "fiveSpice", allergenUids: [] },
{ uid: "garamMasala", allergenUids: [] },
{ uid: "corianderSeeds", allergenUids: [] },
{ uid: "cardamom", allergenUids: [] },
{ uid: "fenugreek", allergenUids: [] },
{ uid: "jalapeno", allergenUids: [] },
{ uid: "chipotle", allergenUids: [] },
{ uid: "poblanoPepper", allergenUids: [] },
{ uid: "habanero", allergenUids: [] },
{ uid: "rasElHanout", allergenUids: [] },
{ uid: "zaatar", allergenUids: ["sesameSeeds"] },
],
},
{
category: "condimentsAndSpices",
subcategory: "sauces",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "soySauce", allergenUids: ["soy"] },
{ uid: "mustard", allergenUids: ["mustard"] },
{
uid: "mayonnaise",
reproducible: true,
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "ketchup", reproducible: true, allergenUids: [] },
{ uid: "tabasco", allergenUids: [] },
{
uid: "worcestershireSauce",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{
uid: "fishSauce",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{ uid: "wasabi", allergenUids: [] },
{ uid: "harissa", allergenUids: [] },
{ uid: "curryPaste", allergenUids: [] },
{ uid: "peanutButter", allergenUids: ["peanuts"] },
{ uid: "dijonMustard", allergenUids: ["mustard"] },
{ uid: "wholegrainMustard", allergenUids: ["mustard"] },
{ uid: "barbecueSauce", reproducible: true, allergenUids: [] },
{
uid: "tartarSauce",
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "cocktailSauce",
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "bearnaiseSauce",
allergenUids: ["eggs", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "hollandaiseSauce",
allergenUids: ["eggs", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "bechamelSauce",
reproducible: true,
allergenUids: ["milk", "gluten"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "teriyakiSauce", allergenUids: ["soy"] },
{
uid: "ponzuSauce",
allergenUids: ["soy", "fish"],
dietUids: ["pescatarian"],
},
{ uid: "chimichurri", allergenUids: [] },
{
uid: "redPesto",
allergenUids: ["milk", "treeNuts"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "pesto",
reproducible: true,
allergenUids: ["milk", "treeNuts"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "oysterSauce",
allergenUids: ["molluscs"],
dietUids: ["pescatarian"],
},
{ uid: "hoisinSauce", allergenUids: ["soy"] },
{ uid: "sriracha", allergenUids: [] },
{ uid: "sweetChiliSauce", allergenUids: [] },
{ uid: "miso", allergenUids: ["soy"] },
{
uid: "shrimpPaste",
allergenUids: ["crustaceans"],
dietUids: ["pescatarian"],
},
{ uid: "redCurryPaste", allergenUids: [] },
{ uid: "greenCurryPaste", allergenUids: [] },
{ uid: "tahini", reproducible: true, allergenUids: ["sesameSeeds"] },
// Second Ciqual pass.
{
uid: "aioli",
reproducible: true,
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "vinaigrette", reproducible: true, allergenUids: [] },
{ uid: "hummus", reproducible: true, allergenUids: ["sesameSeeds"] },
],
},
{
category: "condimentsAndSpices",
subcategory: "seasonings",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "BOTTLE",
items: [
{ uid: "oliveOil", allergenUids: [] },
{ uid: "sunflowerOil", allergenUids: [] },
{ uid: "rapeseedOil", allergenUids: [] },
{ uid: "coconutOil", allergenUids: [] },
{ uid: "sesameOil", allergenUids: ["sesameSeeds"] },
{ uid: "ciderVinegar", allergenUids: [] },
{ uid: "whiteVinegar", allergenUids: [] },
{ uid: "balsamicVinegar", allergenUids: ["sulfites"] },
{ uid: "capers", icon: "JAR", allergenUids: [] },
{ uid: "olives", icon: "JAR", allergenUids: [] },
{ uid: "blackOlives", icon: "JAR", allergenUids: [] },
{ uid: "greenOlives", icon: "JAR", allergenUids: [] },
{ uid: "whiteWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "redWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "roseWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "redWineVinegar", allergenUids: ["sulfites"] },
{ uid: "whiteWineVinegar", allergenUids: ["sulfites"] },
{ uid: "sherryVinegar", allergenUids: ["sulfites"] },
{ uid: "walnutOil", allergenUids: ["treeNuts"] },
{ uid: "hazelnutOil", allergenUids: ["treeNuts"] },
{ uid: "peanutOil", allergenUids: ["peanuts"] },
{ uid: "chiliOil", allergenUids: [] },
{ uid: "riceVinegar", allergenUids: [] },
// Second Ciqual pass.
{ uid: "cornOil", allergenUids: [] },
{ uid: "grapeseedOil", allergenUids: [] },
{ uid: "soybeanOil", allergenUids: ["soy"] },
{ uid: "palmOil", allergenUids: [] },
{ uid: "mirin", icon: "DRINK", allergenUids: [] },
{ uid: "sake", icon: "DRINK", allergenUids: [] },
{ uid: "lemonJuice", icon: "DRINK", allergenUids: [] },
{ uid: "limeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "orangeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "appleJuice", icon: "DRINK", allergenUids: [] },
{ uid: "grapeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "tomatoJuice", icon: "DRINK", allergenUids: [] },
{ uid: "cranberryJuice", icon: "DRINK", allergenUids: [] },
{ uid: "coffee", icon: "DRINK", allergenUids: [] },
{ uid: "tea", icon: "DRINK", allergenUids: [] },
{ uid: "beer", icon: "DRINK", allergenUids: ["gluten"] },
{ uid: "cider", icon: "DRINK", allergenUids: ["sulfites"] },
{
uid: "champagne",
icon: "DRINK",
allergenUids: ["sulfites"],
},
{ uid: "portWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "vinJaune", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "cognac", icon: "DRINK", allergenUids: [] },
{ uid: "rum", icon: "DRINK", allergenUids: [] },
{ uid: "whisky", icon: "DRINK", allergenUids: [] },
{ uid: "vodka", icon: "DRINK", allergenUids: [] },
],
},
// =========================================================================
// Aides culinaires
// =========================================================================
{
category: "cookingEssentials",
subcategory: "bases",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "GRAIN",
items: [
{ uid: "wheatFlour", allergenUids: ["gluten"] },
{ uid: "wholeWheatFlour", allergenUids: ["gluten"] },
{ uid: "cornFlour", allergenUids: [] },
{ uid: "buckwheatFlour", allergenUids: [] },
{ uid: "riceFlour", allergenUids: [] },
{ uid: "vegetableStockCube", icon: "STOCK_POT", allergenUids: ["celery"] },
{
uid: "chickenStockCube",
icon: "STOCK_POT",
allergenUids: ["celery"],
dietUids: [],
},
{ uid: "tomatoPaste", icon: "JAR", allergenUids: [] },
{ uid: "tomatoCoulis", icon: "JAR", allergenUids: [] },
{ uid: "cannedPeeledTomatoes", icon: "JAR", allergenUids: [] },
{ uid: "sunDriedTomatoes", icon: "JAR", allergenUids: [] },
{
uid: "vealStock",
icon: "STOCK_POT",
reproducible: true,
allergenUids: [],
dietUids: [],
},
{
uid: "chickenStock",
icon: "STOCK_POT",
reproducible: true,
allergenUids: [],
dietUids: [],
},
{
uid: "beefStockCube",
icon: "STOCK_POT",
allergenUids: ["celery"],
dietUids: [],
},
{
uid: "fishStockCube",
icon: "STOCK_POT",
allergenUids: ["fish", "celery"],
dietUids: ["pescatarian"],
},
{
uid: "vegetableBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
},
{
uid: "chickenBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
dietUids: [],
},
{
uid: "beefBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
dietUids: [],
},
{ uid: "courtBouillon", icon: "STOCK_POT", allergenUids: [] },
{
uid: "dashi",
icon: "STOCK_POT",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{
uid: "shellfishBisque",
icon: "STOCK_POT",
allergenUids: ["crustaceans"],
dietUids: ["pescatarian"],
},
{ uid: "tapiocaFlour", allergenUids: [] },
{ uid: "masaHarina", allergenUids: [] },
{ uid: "water", icon: "DRINK", allergenUids: [] },
{ uid: "sparklingWater", icon: "DRINK", allergenUids: [] },
{ uid: "orangeBlossomWater", icon: "DRINK", allergenUids: [] },
{ uid: "roseWater", icon: "DRINK", allergenUids: [] },
{
uid: "fishFumet",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
],
},
{
category: "cookingEssentials",
subcategory: "thickeners",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "bakersYeast", allergenUids: [] },
{ uid: "bakingPowder", allergenUids: [] },
{ uid: "cornstarch", allergenUids: [] },
{ uid: "lupinFlour", allergenUids: ["lupin"] },
// Animal collagen (bones/skin, usually pork or beef) — not
// vegetarian/vegan, and not reliably fish-derived either, so no
// pescetarian flag.
{ uid: "gelatin", allergenUids: [], dietUids: [] },
{ uid: "bakingSoda", allergenUids: [] },
{ uid: "potatoStarch", allergenUids: [] },
],
},
{
category: "cookingEssentials",
subcategory: "sugars",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SUGAR",
items: [
{ uid: "sugar", allergenUids: [] },
{ uid: "honey", allergenUids: [], dietUids: ["vegetarian", "pescatarian"] },
{ uid: "mapleSyrup", allergenUids: [] },
{ uid: "brownSugar", allergenUids: [] },
{ uid: "powderedSugar", allergenUids: [] },
{ uid: "demeraraSugar", allergenUids: [] },
{ uid: "darkChocolate", allergenUids: [] },
{
uid: "milkChocolate",
allergenUids: ["milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "whiteChocolate",
allergenUids: ["milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "chocolateChips", allergenUids: [] },
{ uid: "cocoaPowder", allergenUids: [] },
{ uid: "vanillaExtract", allergenUids: [] },
{ uid: "palmSugar", allergenUids: [] },
{ uid: "caneSyrup", allergenUids: [] },
],
},
];
const INGREDIENTS: Array<
Omit<IngredientSeed, "icon" | "dietUids" | "reproducible"> & {
category: IngredientCategory;
subcategory: IngredientSubcategory;
icon: IngredientIcon;
dietUids: string[];
reproducible: boolean;
}
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
items.map((item) => ({
...item,
category,
subcategory,
icon: item.icon ?? defaultIcon,
dietUids: item.dietUids ?? defaultDiets,
reproducible: item.reproducible ?? false,
})),
);
/**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data — upserts by
* `key`, all `@unique`) — used both by `prisma/seed.ts` (the CLI entry
* point, `prisma db seed`) and by `test-support/reset-db.ts` (so every
* test starts from the same realistic reference data the real app seeds,
* not an empty table).
*
* Every `uid` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) *is* the
* database `key`, authored directly — no French label, no separate
* lookup/translation table (that used to be `catalog-en-keys.ts` +
* `getEnglishKey()`, removed: product decision recorded in chat, this file
* carries zero natural-language text now). The French display label for
* each uid lives solely in `apps/web`'s `locales/fr/translation.json`
* (`catalog.*` namespace), maintained independently — adding a new
* diet/allergen/ingredient here means also adding its translation there by
* hand, tied together only by the matching uid string.
*/
export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
for (const key of DIETS) {
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
}
// `update: { type, toBaseFactor }` (not `{}`) — same reasoning as
// `ALLERGENS`' `kind` below: a reseed must correct a unit's
// type/toBaseFactor if it's ever edited above, not just skip existing rows.
for (const { uid: key, type, toBaseFactor } of UNITS) {
await prisma.unit.upsert({
where: { key },
update: { type, toBaseFactor },
create: { key, type, toBaseFactor },
});
}
// `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it,
// created only the first time. `update: { kind }` (not `{}`) — a reseed
// must correct `kind` on an already-existing category if the
// classification above ever changes, not just skip it.
for (const { uid: key, kind } of ALLERGENS) {
const category = await prisma.category.upsert({
where: { key },
update: { kind },
create: { key, kind },
});
const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } });
if (!existing) {
await prisma.allergy.create({ data: { categoryId: category.id } });
}
}
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
// hundred entries long, and `seedReferenceData` re-runs on every single
// test's `resetDatabase()` — a per-row round trip made the whole suite
// measurably slower). Bulk-create whatever's missing in one query, then
// reconcile `icon`/`category`/`subcategory` only for the rows where any
// of them actually changed — on a freshly-truncated table (the common
// test-suite case) that's zero updates, on a real re-deploy it's however
// many rows were edited in code since the last deploy, never the full
// list.
const ingredientKeys = INGREDIENTS.map((i) => i.uid);
const existingIngredients = await prisma.ingredient.findMany({
where: { key: { in: ingredientKeys } },
select: {
id: true,
key: true,
icon: true,
category: true,
subcategory: true,
reproducible: true,
},
});
const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(i.uid));
if (missingIngredients.length > 0) {
await prisma.ingredient.createMany({
data: missingIngredients.map(({ uid, icon, category, subcategory, reproducible }) => ({
key: uid,
icon,
category,
subcategory,
reproducible,
})),
});
}
const changed = INGREDIENTS.filter((i) => {
const existing = existingByKey.get(i.uid);
return (
existing &&
(existing.icon !== i.icon ||
existing.category !== i.category ||
existing.subcategory !== i.subcategory ||
existing.reproducible !== i.reproducible)
);
});
for (const { uid, icon, category, subcategory, reproducible } of changed) {
await prisma.ingredient.update({
where: { key: uid },
data: { icon, category, subcategory, reproducible },
});
}
// Re-resolve every ingredient's id (existing + just-created) and every
// allergy's id (by its category key) once, then link them in a single
// bulk insert — same "re-derived every time, not upserted per link"
// reasoning as before for `IngredientAllergy` (it has no natural per-row
// identity to upsert against), just batched instead of looped.
const allIngredients = await prisma.ingredient.findMany({
where: { key: { in: ingredientKeys } },
select: { id: true, key: true },
});
const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id]));
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id]));
const links: Array<{ ingredientId: number; allergyId: number }> = [];
for (const { uid, allergenUids } of INGREDIENTS) {
const ingredientId = ingredientIdByKey.get(uid);
if (ingredientId === undefined) continue;
for (const allergenUid of allergenUids) {
const allergyId = allergyIdByCategoryKey.get(allergenUid);
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
}
}
if (links.length > 0) {
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
}
// Same bulk-insert approach as the allergy links above, resolved against
// `dietUids` (item override, falling back to its group's `defaultDiets`
// in the `INGREDIENTS` flatten step) instead of `allergenUids`.
const diets = await prisma.diet.findMany();
const dietIdByKey = new Map(diets.map((d) => [d.key, d.id]));
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
for (const { uid, dietUids } of INGREDIENTS) {
const ingredientId = ingredientIdByKey.get(uid);
if (ingredientId === undefined) continue;
for (const dietUid of dietUids) {
const dietId = dietIdByKey.get(dietUid);
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
}
}
if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
}
}