* feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp) Le matching par regex ne généralisait jamais au-delà de son propre vocabulaire — une étape décrivant la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle" ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le sens est sans ambiguïté. Nouveau pipeline en 3 étapes (TechStepClassifierService, node-nlp 4.27.0 — la 5.x est encore alpha, non retenue) : 1. NER (entités enum) trouve les mentions candidates + leur position exacte, à partir de listes de synonymes (tech-step-training-data.ts) plutôt que de regex écrites à la main. ner.threshold: 1 (exact, après normalisation) — le défaut à 0.8 faisait matcher "faire" (verbe auxiliaire omniprésent) contre "frire" par pure proximité de chaîne. 2. La description est découpée en clauses autour de ces candidats (splitIntoClauses, pure/testable sans modèle). 3. Le NlpManager classe chaque clause individuellement, entraîné sur des phrases qui n'emploient jamais le verbe de la technique — c'est ce qui apporte la compréhension du sens. En dessous de CONFIDENCE_THRESHOLD (0.65, ajusté empiriquement), retombe sur la technique impliquée par l'ancre NER plutôt que d'abandonner un match clairement ancré sur un mot-clé. TechStepMapping (table de regex par technique/locale) supprimée — migration 20260821130000_drop_tech_step_mapping — plus aucune table n'est interrogée à l'exécution, les données de matching vivent en code. TECH_STEPS (reference-seed-data.ts) simplifié en simple liste de uid, les mappings ayant disparu. Deux pièges trouvés en construisant ce pipeline, corrigés à la source : - db/prisma.ts construisait PrismaClient sans importer config/env.ts — un run de test isolé pouvait faire gagner la course au .env interne de Prisma (dev) contre .env.test. Fixé en important config/env.js en tout premier, pour effet de bord. - NlpManager a autoSave/autoLoad: true par défaut — persiste le modèle entraîné dans model.nlp et le recharge au lieu de ré-entraîner au prochain démarrage. Les deux désactivés explicitement (sinon un modèle obsolète masquerait silencieusement toute mise à jour du corpus/seuil) ; model.nlp ajouté au .gitignore en garde-fou. apps/api/src/db/prisma.ts, recipe.service.ts, sources.service.ts et recipe-translation.ts adaptés à la matching async (le classifieur entraîné remplace le couple loadTechStepMappingRules+matchTechStepSpans synchrone) ; server.ts appelle techStepClassifier.warmUp() avant d'accepter du trafic (le tout premier appel réel à NlpManager.process() charge les ressources par langue de node-nlp, plusieurs secondes). Vérifié : tsc --noEmit, biome check (0 erreur), build complet des 6 packages, 308 tests API (dont un test-support/reset-db.ts corrigé — référençait encore tech_step_mapping dans son TRUNCATE). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur Deux évolutions du pipeline NLP de détection des tech steps (PR #63) : 1. Délimitation de contexte — en plus du mot-clé qui déclenche un match (start/end), chaque TechStepMatch porte maintenant contextStart/ contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude" comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur StepTechStep (colonnes nullables, migration dédiée), exposé via StepTechStepView, et rendu côté web avec un style plus discret que le mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses coupe désormais sur l'espace le plus proche du milieu de l'écart entre deux candidats plutôt que sur le milieu brut, pour ne jamais couper un mot en deux (findGapSplitPoint). 2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par technique (FR/EN) pour fiabiliser la détection sur des formulations que le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et corrigés en cours de route, tous confirmés par la suite de tests complète (309 tests) : - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court déjà enregistré pour la même technique fait matcher les deux comme candidats NER distincts et chevauchants, corrompant le découpage en clauses (parfois jusqu'à une mauvaise classification) — retiré partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil, roast, chop, mince, marinate, preheat, bake, plate, coat) ; - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même racine que le nom "poêle" par le stemmer français de node-nlp, provoquant un faux positif sur toute mention nue de "poêle" (dont celle de preheat) — retiré ; - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase d'entraînement de foldIn partage la même locution) — corrigé en ajoutant des phrases d'entraînement dédiées à whisk ; - "Émincer les tomates" est passé sous le seuil de confiance vers melt après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé en élargissant les phrases d'entraînement de mince à un autre légume. Le test unitaire de splitIntoClauses avec un point de coupure obsolète (pré-datant findGapSplitPoint) est aussi corrigé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(api): corrige la découpe des clauses et le seuil de confiance du classifieur de tech steps Trouvé en examinant des vraies recettes déjà en base après le dernier étoffement du vocabulaire : plusieurs étapes bien réelles se faisaient classer sur la mauvaise technique, sans lien avec un mot-clé manquant. - splitIntoClauses coupe désormais sur la limite de phrase (juste après un ".", "!" ou "?") la plus proche du milieu de l'écart entre deux candidats quand il y en a une, plutôt que sur l'espace brut le plus proche du milieu. Une description à deux techniques dans deux phrases distinctes ("Préchauffer le four à 180°C. Dans un saladier, mettre le beurre... et mélanger.") ne coupait qu'au milieu brut, ce qui pouvait trancher en pleine deuxième phrase et envoyer au classifieur une clause tronquée ("...(thermostat 6). Dans un saladier, mettre" sans complément) — assez éloignée des phrases d'entraînement courtes et complètes pour se faire mal classer avec confiance (préchauffer prédit "mix", mélanger prédit "melt"). - CONFIDENCE_THRESHOLD passe de 0.65 à 0.75 : du texte anglais passé dans le classifieur français (qui doit ne rien trouver, garanti par le test d'isolation des locales) scorait 0.69 sur "boil" — du bruit de petit corpus, pas un vrai verdict. Les cas réels que ce seuil sert à faire confiance scorent 0.91 à 1.0 en pratique ; 0.75 sépare proprement le bruit du signal sans rien casser (309 tests toujours verts). - Deux phrases d'entraînement ajoutées à `cook` pour deux clauses réelles mal classées (feu doux + remuant, découvert + laisser cuire) qui n'avaient pourtant pas de mot-clé manquant. Ajoute aussi src/scripts/backfill-tech-steps.ts : la détection ne tourne qu'à la création/modification d'une recette, jamais rétroactivement — ce script recalcule le start/end/contextStart/ contextEnd de chaque étape existante contre le classifieur actuel, pour ne pas avoir à rouvrir et resauvegarder chaque recette à la main après un changement de corpus. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(web): rend le contexte des tech steps réellement visible Le style existant (fond teinté à 6% d'opacité, sans autre indice visuel) était structurellement correct — vérifié en base, l'API et le DOM contenaient bien les spans de contexte — mais imperceptible à l'œil sur ce thème sombre : --color-primary n'est pas assez saturé pour qu'une teinte de quelques % se distingue du fond de la carte. Vérifié en créant une recette test dans le navigateur et en zoomant le texte rendu : littéralement aucune différence visible avant, un rectangle net après. Passe à 10% de fond + une bordure basse pleine à 45% d'opacité comme second indice visuel indépendant, tout en gardant le mot-clé (soulignement pointillé + fond à 14% + curseur + tooltip) nettement plus marqué que son contexte. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(web): retire l'affichage visuel du contexte des tech steps Ne touche que le rendu — le backend continue de calculer et de persister contextStart/contextEnd (tech-step-matcher.ts, StepTechStep), et splitDescriptionByTechSteps continue de découper la description autour du contexte (segments isKeyword: false). StepDescription.tsx rend désormais ces segments comme du texte brut, comme un segment sans technique — plus d'encadré/bordure autour de la clause, seul le mot-clé reste surligné avec sa tooltip. .step-tech-step-context (CSS) retirée, devenue inutilisée. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1318 lines
54 KiB
TypeScript
1318 lines
54 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 },
|
|
// 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.<key>`.
|
|
//
|
|
// 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<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 },
|
|
});
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
}
|