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.`. 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 }, // US customary units — added for English-language sources (TheMealDB and // similar routinely state quantities in cups/oz/lb, not metric), so // `ingredient-matcher.ts` has something to resolve them to instead of // always falling back to `unitId: null`. Convertible against their // metric siblings like any other MASS/VOLUME unit. { uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 }, { uid: "ounce", type: "MASS", toBaseFactor: 28.3495 }, { uid: "pound", type: "MASS", toBaseFactor: 453.5924 }, ]; // Cooking-technique catalog (French recipe-step normalization) — the // stable `key`s `tech-step-matcher.ts` auto-detects in a free-text // `Step.description` (a step can mention several, e.g. "faire chauffer une // poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see // `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English // camelCase uid, no French label" authoring as DIETS/UNITS — the label // lives in apps/web's locales/fr/translation.json under // `catalog.techSteps.`. // // Just a flat list of stable ids here — the actual matching data (per- // locale synonym lists + example phrasings the classifier trains on) lives // in `lib/recipe-matching/tech-step-training-data.ts`'s // `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by // `TechStepClassifierService`'s training pass, not the seed script, so it // doesn't belong alongside the rest of this file's DB-seeded reference // data. Every entry here must have a matching entry there. export const TECH_STEPS: string[] = [ "cook", "fry", "melt", "deglaze", "simmer", "boil", "roast", "grill", "panFry", "blanch", "marinate", "chop", "peel", "mince", "mix", "whisk", "foldIn", "setAside", "season", "drain", "brown", "rest", "preheat", "bake", "plate", "coat", ]; // 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: [] }, // Ground/minced meats, requested alongside `groundBeef` (already // seeded) — one per red-meat type the catalog otherwise only offers // as a whole cut. Ground poultry (turkey/chicken) lives in the // `poultry` subcategory below, next to their whole-cut counterparts. { uid: "groundVeal", allergenUids: [] }, { uid: "groundPork", allergenUids: [] }, { uid: "groundLamb", 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: "groundChicken", allergenUids: [] }, { uid: "turkey", allergenUids: [] }, { uid: "groundTurkey", 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"] }, { uid: "eggYolk", allergenUids: ["eggs"] }, { uid: "eggWhite", 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: "groundCoriander", 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 & { 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 { 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 }, }); } // TechStep: upsert by key (same idempotent-seed reasoning as everything // above) — just the stable id/key rows themselves now, no matching data // to replace alongside them (see `TECH_STEPS`' own comment for why). for (const key of TECH_STEPS) { await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } }); } // `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 }); } }