diff --git a/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql b/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql new file mode 100644 index 0000000..ef07b6a --- /dev/null +++ b/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ingredient_diet" ( + "ingredient_id" INTEGER NOT NULL, + "diet_id" INTEGER NOT NULL, + + CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id") +); + +-- AddForeignKey +ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8ee4b93..ff60451 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -42,8 +42,9 @@ model Diet { id Int @id @default(autoincrement()) name String @unique - users UserProfile[] - recipes RecipeDiet[] + users UserProfile[] + recipes RecipeDiet[] + ingredients IngredientDiet[] @@map("diet") } @@ -336,10 +337,35 @@ model Ingredient { allergies IngredientAllergy[] /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. dislikedBy UserProfileDislikedIngredient[] + /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. + diets IngredientDiet[] @@map("ingredients") } +/// Explicit join table for the ingredients <-> diet regime association — +/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe +/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can +/// flag e.g. an ingredient as vegan without the user having to open its +/// packaging. Seeded by category in `reference-seed-data.ts` (most +/// ingredients in a category share the same compatible regimes, with +/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as +/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is +/// trivially compatible — storing it would be pure noise) and `Sans gluten` +/// (already fully derivable from whether `IngredientAllergy` links this +/// ingredient to the `Gluten` allergen — a second, hand-maintained source +/// for the same fact would only risk drifting out of sync with it). +model IngredientDiet { + ingredientId Int @map("ingredient_id") + dietId Int @map("diet_id") + + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) + + @@id([ingredientId, dietId]) + @@map("ingredient_diet") +} + /// Explicit join table for the ingredients <-> allergy association — not in /// the original spec doc, added so the recipe catalog can surface which /// allergens an ingredient (and by extension a recipe) carries. Same shape diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 5459f82..c1046e6 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -32,6 +32,17 @@ interface IngredientSeed { name: string; icon?: string; allergenNames: string[]; + /** + * Diet regimes this ingredient is compatible with, overriding its group's + * `defaultDiets` below — only needed for the exceptions within a category + * (a dairy cheese inside an otherwise-vegan cuisine group, a meat-based + * stock inside an otherwise-plant condiments group…). References `DIETS` + * by name, same as `allergenNames` references `ALLERGENS`. Deliberately + * never includes `"Omnivore"` (trivial, every ingredient qualifies) or + * `"Sans gluten"` (derived from `allergenNames` instead — see + * `IngredientDiet` in schema.prisma for why). + */ + dietNames?: string[]; } // A broad pantry list — the goal is to cover the large majority of what a @@ -52,9 +63,21 @@ interface IngredientSeed { // alone isn't enough to actually *find* something. 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. -const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: IngredientSeed[] }> = [ +// +// `defaultDiets` is the regime compatibility every item in the group shares +// unless it sets its own `dietNames` — most categories are diet-homogeneous +// (a vegetable is always vegan, a cut of meat never is), so this avoids +// repeating the same three diet names on hundreds of items; only the +// category's exceptions (a cheese inside "cuisine italienne", a fish sauce +// inside "cuisine asiatique"…) need a per-item override. +const INGREDIENT_GROUPS: Array<{ + category: IngredientCategory; + defaultDiets: string[]; + items: IngredientSeed[]; +}> = [ { category: "CEREALES_FECULENTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] }, { name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] }, @@ -87,6 +110,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "LEGUMINEUSES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Lentilles vertes", icon: "🫘", allergenNames: [] }, { name: "Lentilles corail", icon: "🫘", allergenNames: [] }, @@ -101,6 +125,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "VIANDES_VOLAILLES", + defaultDiets: [], items: [ { name: "Poulet", icon: "🍗", allergenNames: [] }, { name: "Dinde", icon: "🍗", allergenNames: [] }, @@ -126,6 +151,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "POISSONS_FRUITS_DE_MER", + defaultDiets: ["Pescétarien"], items: [ { name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] }, { name: "Thon", icon: "🐟", allergenNames: ["Poissons"] }, @@ -172,6 +198,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "PRODUITS_LAITIERS_OEUFS", + // Real dairy/egg products only — plant-based "milks" (coconut, almond, + // oat…) live under `LIQUIDES_BOISSONS` instead: they're not dairy, and + // grouping them here purely because the French name says "lait" was a + // categorization bug (they're vegan; nothing in this group is). + defaultDiets: ["Végétarien", "Pescétarien"], items: [ { name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] }, { name: "Lait", icon: "🥛", allergenNames: ["Lait"] }, @@ -189,14 +220,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] }, { name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] }, { name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] }, - { name: "Lait de coco", icon: "🥥", allergenNames: [] }, - { name: "Crème de coco", icon: "🥥", allergenNames: [] }, - { name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] }, - { name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] }, ], }, { category: "LEGUMES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Tomate", icon: "🍅", allergenNames: [] }, { name: "Oignon", icon: "🧅", allergenNames: [] }, @@ -240,6 +268,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "FRUITS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Citron", icon: "🍋", allergenNames: [] }, { name: "Citron vert", icon: "🍋", allergenNames: [] }, @@ -273,6 +302,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "FRUITS_SECS_OLEAGINEUX", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] }, { name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] }, @@ -293,6 +323,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "CONDIMENTS_SAUCES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Sel", icon: "🧂", allergenNames: [] }, { name: "Sucre", icon: "🍬", allergenNames: [] }, @@ -307,20 +338,35 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] }, { name: "Tofu", icon: "🧊", allergenNames: ["Soja"] }, { name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] }, - { name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] }, + { + name: "Mayonnaise", + icon: "🫙", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Ketchup", icon: "🫙", allergenNames: [] }, - { name: "Miel", icon: "🍯", allergenNames: [] }, + { name: "Miel", icon: "🍯", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] }, { name: "Sirop d'érable", icon: "🍁", allergenNames: [] }, { name: "Câpres", allergenNames: [] }, { name: "Olives", icon: "🫒", allergenNames: [] }, { name: "Tabasco", icon: "🌶️", allergenNames: [] }, - { name: "Sauce Worcestershire", icon: "🫙", allergenNames: ["Poissons"] }, - { name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] }, + { + name: "Sauce Worcestershire", + icon: "🫙", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { + name: "Sauce nuoc-mâm", + icon: "🫙", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, { name: "Wasabi", allergenNames: [] }, { name: "Harissa", icon: "🌶️", allergenNames: [] }, { name: "Pâte de curry", icon: "🍛", allergenNames: [] }, { name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] }, - { name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, { name: "Concentré de tomate", icon: "🍅", allergenNames: [] }, { name: "Coulis de tomate", icon: "🍅", allergenNames: [] }, { name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] }, @@ -338,34 +384,81 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] }, { name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] }, { name: "Sauce barbecue", icon: "🫙", allergenNames: [] }, - { name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] }, - { name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] }, - { name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] }, - { name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] }, - { name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] }, + { + name: "Sauce tartare", + icon: "🫙", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce cocktail", + icon: "🫙", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce béarnaise", + icon: "🫙", + allergenNames: ["Œufs", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce hollandaise", + icon: "🫙", + allergenNames: ["Œufs", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce béchamel", + icon: "🫙", + allergenNames: ["Lait", "Gluten"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] }, - { name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] }, + { + name: "Sauce ponzu", + icon: "🫙", + allergenNames: ["Soja", "Poissons"], + dietNames: ["Pescétarien"], + }, { name: "Chimichurri", icon: "🌿", allergenNames: [] }, { name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"], + dietNames: ["Végétarien", "Pescétarien"], }, { name: "Tomates séchées", icon: "🍅", allergenNames: [] }, - { name: "Fond de veau", icon: "🫙", allergenNames: [] }, - { name: "Fond de volaille", icon: "🫙", allergenNames: [] }, - { name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] }, - { name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] }, + { name: "Fond de veau", icon: "🫙", allergenNames: [], dietNames: [] }, + { name: "Fond de volaille", icon: "🫙", allergenNames: [], dietNames: [] }, + { name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, + { + name: "Bouillon cube poisson", + icon: "🫙", + allergenNames: ["Poissons", "Céleri"], + dietNames: ["Pescétarien"], + }, { name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] }, - { name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] }, - { name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, + { name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] }, { name: "Court-bouillon", icon: "🫙", allergenNames: [] }, - { name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] }, - { name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] }, + { + name: "Dashi (bouillon japonais)", + icon: "🫙", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { + name: "Bisque de crustacés", + icon: "🫙", + allergenNames: ["Crustacés"], + dietNames: ["Pescétarien"], + }, ], }, { category: "EPICES_HERBES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Basilic", icon: "🌿", allergenNames: [] }, { name: "Persil", icon: "🌿", allergenNames: [] }, @@ -418,21 +511,36 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "SUCRE_PATISSERIE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Sucre roux", icon: "🍬", allergenNames: [] }, { name: "Sucre glace", icon: "🍬", allergenNames: [] }, { name: "Cassonade", icon: "🍬", allergenNames: [] }, { name: "Chocolat noir", icon: "🍫", allergenNames: [] }, - { name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] }, - { name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] }, + { + name: "Chocolat au lait", + icon: "🍫", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Chocolat blanc", + icon: "🍫", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Pépites de chocolat", icon: "🍫", allergenNames: [] }, { name: "Cacao en poudre", icon: "🍫", allergenNames: [] }, - { name: "Gélatine", icon: "🫙", allergenNames: [] }, + // Animal collagen (bones/skin, usually pork or beef) — not + // vegetarian/vegan, and not reliably fish-derived either, so no + // pescetarian flag. + { name: "Gélatine", icon: "🫙", allergenNames: [], dietNames: [] }, { name: "Extrait de vanille", icon: "🌿", allergenNames: [] }, ], }, { category: "CUISINE_ITALIENNE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] }, { name: "Penne", icon: "🍝", allergenNames: ["Gluten"] }, @@ -440,15 +548,40 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] }, { name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] }, { name: "Riz arborio", icon: "🍚", allergenNames: [] }, - { name: "Burrata", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] }, - { name: "Prosciutto", icon: "🍖", allergenNames: [] }, - { name: "Pancetta", icon: "🥓", allergenNames: [] }, - { name: "Mortadelle", icon: "🍖", allergenNames: [] }, - { name: "Salami", icon: "🍖", allergenNames: [] }, - { name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] }, + { + name: "Burrata", + icon: "🧀", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Ricotta", + icon: "🧀", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pecorino", + icon: "🧀", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Gorgonzola", + icon: "🧀", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Prosciutto", icon: "🍖", allergenNames: [], dietNames: [] }, + { name: "Pancetta", icon: "🥓", allergenNames: [], dietNames: [] }, + { name: "Mortadelle", icon: "🍖", allergenNames: [], dietNames: [] }, + { name: "Salami", icon: "🍖", allergenNames: [], dietNames: [] }, + { + name: "Pesto", + icon: "🫙", + allergenNames: ["Lait", "Fruits à coque"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Tomates cerises", icon: "🍅", allergenNames: [] }, { name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] }, { name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] }, @@ -456,8 +589,14 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "CUISINE_ASIATIQUE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ - { name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] }, + { + name: "Sauce huître", + icon: "🫙", + allergenNames: ["Mollusques"], + dietNames: ["Pescétarien"], + }, { name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] }, { name: "Sauce sriracha", icon: "🫙", allergenNames: [] }, { name: "Sauce sweet chili", icon: "🫙", allergenNames: [] }, @@ -496,14 +635,25 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Sucre de palme", icon: "🍬", allergenNames: [] }, { name: "Pousses de bambou", allergenNames: [] }, { name: "Châtaignes d'eau", allergenNames: [] }, - { name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] }, - { name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] }, + { + name: "Poisson séché", + icon: "🐟", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { + name: "Pâte de crevettes", + icon: "🫙", + allergenNames: ["Crustacés"], + dietNames: ["Pescétarien"], + }, { name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] }, { name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] }, ], }, { category: "CUISINE_MEXICAINE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Tortilla de maïs", icon: "🌮", allergenNames: [] }, { name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] }, @@ -513,11 +663,17 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Piment poblano", icon: "🌶️", allergenNames: [] }, { name: "Piment habanero", icon: "🌶️", allergenNames: [] }, { name: "Masa harina", icon: "🌽", allergenNames: [] }, - { name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] }, + { + name: "Cheddar", + icon: "🧀", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, ], }, { category: "MAGHREB_MOYEN_ORIENT", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Ras el hanout", icon: "🌿", allergenNames: [] }, { name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] }, @@ -530,15 +686,31 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient // seeded under `CEREALES_FECULENTS` above — this category covers the // shapes specifically reached for to build a sandwich, plus a few // international flatbreads not tied to one cuisine category above. + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ - { name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] }, - { name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] }, + { + name: "Pain à burger", + icon: "🍔", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pain brioché", + icon: "🍞", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] }, { name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] }, { name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] }, { name: "Naan", icon: "🫓", allergenNames: ["Gluten"] }, { name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] }, - { name: "Pain viennois", icon: "🍞", allergenNames: ["Gluten", "Lait"] }, + { + name: "Pain viennois", + icon: "🍞", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, { name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] }, { name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] }, { name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] }, @@ -550,6 +722,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "EPICERIE_DIVERS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] }, { name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] }, @@ -557,6 +730,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient }, { category: "LIQUIDES_BOISSONS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], items: [ { name: "Eau", icon: "💧", allergenNames: [] }, { name: "Eau gazeuse", icon: "💧", allergenNames: [] }, @@ -581,13 +755,27 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient { name: "Whisky", icon: "🥃", allergenNames: [] }, { name: "Vodka", icon: "🥃", allergenNames: [] }, { name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] }, - { name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] }, + { + name: "Fumet de poisson", + icon: "🫙", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + // Plant-based milk/cream substitutes — moved here from + // `PRODUITS_LAITIERS_OEUFS` (they're not dairy; see that group's + // comment) alongside the other drinkable/pourable liquids. + { name: "Lait de coco", icon: "🥥", allergenNames: [] }, + { name: "Crème de coco", icon: "🥥", allergenNames: [] }, + { name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] }, + { name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] }, ], }, ]; -const INGREDIENTS: Array = - INGREDIENT_GROUPS.flatMap(({ category, items }) => items.map((item) => ({ ...item, category }))); +const INGREDIENTS: Array = + INGREDIENT_GROUPS.flatMap(({ category, defaultDiets, items }) => + items.map((item) => ({ ...item, category, dietNames: item.dietNames ?? defaultDiets })), + ); /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent @@ -675,4 +863,23 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { if (links.length > 0) { await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true }); } + + // Same bulk-insert approach as the allergy links above, resolved against + // `dietNames` (item override, falling back to its group's `defaultDiets` + // in the `INGREDIENTS` flatten step) instead of `allergenNames`. + const diets = await prisma.diet.findMany(); + const dietIdByName = new Map(diets.map((d) => [d.name, d.id])); + + const dietLinks: Array<{ ingredientId: number; dietId: number }> = []; + for (const { name, dietNames } of INGREDIENTS) { + const ingredientId = ingredientIdByName.get(name); + if (ingredientId === undefined) continue; + for (const dietName of dietNames) { + const dietId = dietIdByName.get(dietName); + if (dietId !== undefined) dietLinks.push({ ingredientId, dietId }); + } + } + if (dietLinks.length > 0) { + await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); + } } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 76f13ba..08cf70a 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -19,7 +19,10 @@ function recipeInclude(viewerId: number) { ingredients: { include: { ingredient: { - include: { allergies: { include: { allergy: { include: { category: true } } } } }, + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, + }, }, }, }, @@ -30,10 +33,10 @@ function recipeInclude(viewerId: number) { } type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; -type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"]; +type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; -/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ -function toIngredientView(ingredient: IngredientWithAllergies): IngredientView { +/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ +function toIngredientView(ingredient: IngredientWithDetails): IngredientView { return { id: ingredient.id, name: ingredient.name, @@ -44,6 +47,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView { name: allergy.category.name, kind: allergy.category.kind, })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), }; } diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 0936b2a..98ec9e7 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -26,14 +26,16 @@ export async function getAllergies(): Promise { /** * All reference ingredients, alphabetically, each resolved to its allergens - * (see `IngredientAllergy` in schema.prisma) — same aplattening approach as - * {@link getAllergies}. Ingredients with no linked allergen come back with - * `allergens: []`. + * (see `IngredientAllergy` in schema.prisma) and compatible diet regimes + * (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}. + * Ingredients with no linked allergen/diet come back with `allergens: []`/ + * `diets: []`. */ export async function getIngredients(): Promise { const ingredients = await prisma.ingredient.findMany({ include: { allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, }, orderBy: { name: "asc" }, }); @@ -47,5 +49,6 @@ export async function getIngredients(): Promise { name: allergy.category.name, kind: allergy.category.kind, })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), })); } diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index ff1f51e..542e5d5 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -54,7 +54,7 @@ describe("Reference data", () => { expect(res.status).to.equal(200); expect(res.body.length).to.be.greaterThan(0); expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); - expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens"]); + expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens", "diets"]); }); it("resolves each ingredient's linked allergens, empty for one with none", async () => { diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx index f1750c5..bb47d3f 100644 --- a/apps/web/src/features/recipes/IngredientPicker.tsx +++ b/apps/web/src/features/recipes/IngredientPicker.tsx @@ -6,6 +6,7 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AllergenBadges } from "./AllergenBadges"; +import { DietBadges } from "./DietBadges"; import "./recipes.scss"; /** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */ @@ -100,6 +101,7 @@ export function IngredientPicker({ {ingredient.name} + ))} diff --git a/apps/web/src/features/recipes/IngredientRow.tsx b/apps/web/src/features/recipes/IngredientRow.tsx index 310f359..58fd0dc 100644 --- a/apps/web/src/features/recipes/IngredientRow.tsx +++ b/apps/web/src/features/recipes/IngredientRow.tsx @@ -1,6 +1,7 @@ import type { IngredientView } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; import { AllergenBadges } from "./AllergenBadges"; +import { DietBadges } from "./DietBadges"; import "./recipes.scss"; /** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */ @@ -45,6 +46,7 @@ export function IngredientRow({ aria-label={t("recipes.form.unitLabel")} /> +