diff --git a/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql b/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql new file mode 100644 index 0000000..1e85df8 --- /dev/null +++ b/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql @@ -0,0 +1,9 @@ +-- Replaces the never-wired-up `Ingredient.alternateRecipeId` FK (zero +-- usage anywhere outside schema.prisma — confirmed by repo-wide grep) +-- with a plain boolean flag: whether this ingredient is reasonably +-- makeable at home. Product decision: no ingredient↔recipe linking in +-- the database — the recipe form only nudges the author toward the +-- recipe catalog's own search, pre-filled with the ingredient's name. +ALTER TABLE "ingredients" DROP CONSTRAINT "ingredients_alternate_recipe_fkey"; +ALTER TABLE "ingredients" DROP COLUMN "alternate_recipe"; +ALTER TABLE "ingredients" ADD COLUMN "reproducible" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b7af9b9..5a42046 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -259,8 +259,6 @@ model Recipe { planningItems PlanningItem[] favoritedBy RecipeFavorite[] diets RecipeDiet[] - /// Ingredients for which this recipe is offered as a make-it-yourself alternative. - alternateFor Ingredient[] @relation("IngredientAlternateRecipe") @@map("recipe") } @@ -432,20 +430,29 @@ enum IngredientIcon { } model Ingredient { - id Int @id @default(autoincrement()) - key String @unique - icon IngredientIcon @default(JAR) - category IngredientCategory @default(EPICERIE_SECHE) - subcategory IngredientSubcategory @default(AUTRES) - alternateRecipeId Int? @map("alternate_recipe") + id Int @id @default(autoincrement()) + key String @unique + icon IngredientIcon @default(JAR) + category IngredientCategory @default(EPICERIE_SECHE) + subcategory IngredientSubcategory @default(AUTRES) + /// 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) — surfaced in the recipe form as a + /// badge/link nudging the author to go check the recipe catalog for a + /// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/ + /// `IngredientPicker`). Deliberately just a flag, not a link to a + /// specific recipe — replaces an earlier, never-wired-up + /// `alternateRecipeId` FK (product decision discussed in chat: no + /// ingredient↔recipe linking in the database, the UI only pre-fills the + /// catalog's own search with this ingredient's name). + reproducible Boolean @default(false) - alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) - recipes RecipeIngredient[] - allergies IngredientAllergy[] + recipes RecipeIngredient[] + allergies IngredientAllergy[] /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. - dislikedBy UserProfileDislikedIngredient[] + dislikedBy UserProfileDislikedIngredient[] /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. - diets IngredientDiet[] + diets IngredientDiet[] @@map("ingredients") } diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 1d7d63a..a827435 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -64,6 +64,18 @@ interface IngredientSeed { * why). */ dietNames?: 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 @@ -425,14 +437,15 @@ export const INGREDIENT_GROUPS: Array<{ defaultDiets: ["Végétarien", "Végan", "Pescétarien"], defaultIcon: "BREAD", items: [ - { name: "Pain", allergenNames: ["Gluten"] }, - { name: "Pain de mie", allergenNames: ["Gluten"] }, + { name: "Pain", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain de mie", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain complet", allergenNames: ["Gluten"] }, { name: "Baguette", allergenNames: ["Gluten"] }, { name: "Pain de seigle", allergenNames: ["Gluten"] }, - { name: "Chapelure", allergenNames: ["Gluten"] }, + { name: "Chapelure", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain à burger", + reproducible: true, allergenNames: ["Gluten", "Lait", "Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -441,10 +454,10 @@ export const INGREDIENT_GROUPS: Array<{ allergenNames: ["Gluten", "Lait", "Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Pain à hot-dog", allergenNames: ["Gluten"] }, - { name: "Pain pita", allergenNames: ["Gluten"] }, - { name: "Pain bagel", allergenNames: ["Gluten"] }, - { name: "Naan", allergenNames: ["Gluten"] }, + { name: "Pain à hot-dog", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain pita", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain bagel", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Naan", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain wrap", allergenNames: ["Gluten"] }, { name: "Pain viennois", @@ -457,9 +470,9 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Pain suédois", allergenNames: ["Gluten"] }, { name: "Pain sans gluten", allergenNames: [] }, { name: "Biscotte", allergenNames: ["Gluten"] }, - { name: "Croûtons", allergenNames: ["Gluten"] }, - { name: "Focaccia", allergenNames: ["Gluten"] }, - { name: "Ciabatta", allergenNames: ["Gluten"] }, + { name: "Croûtons", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Focaccia", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Ciabatta", reproducible: true, allergenNames: ["Gluten"] }, { name: "Tortilla de maïs", allergenNames: [] }, { name: "Tortilla de blé", allergenNames: ["Gluten"] }, ], @@ -472,17 +485,20 @@ export const INGREDIENT_GROUPS: Array<{ items: [ { name: "Pâte feuilletée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, { name: "Pâte brisée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Pâte à pizza", allergenNames: ["Gluten"] }, + { name: "Pâte à pizza", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pâte à tarte sablée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -604,10 +620,11 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Moutarde", allergenNames: ["Moutarde"] }, { name: "Mayonnaise", + reproducible: true, allergenNames: ["Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Ketchup", allergenNames: [] }, + { name: "Ketchup", reproducible: true, allergenNames: [] }, { name: "Tabasco", allergenNames: [] }, { name: "Sauce Worcestershire", @@ -625,7 +642,7 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Beurre de cacahuète", allergenNames: ["Arachides"] }, { name: "Moutarde de Dijon", allergenNames: ["Moutarde"] }, { name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] }, - { name: "Sauce barbecue", allergenNames: [] }, + { name: "Sauce barbecue", reproducible: true, allergenNames: [] }, { name: "Sauce tartare", allergenNames: ["Œufs"], @@ -648,6 +665,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Sauce béchamel", + reproducible: true, allergenNames: ["Lait", "Gluten"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -665,6 +683,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Pesto", + reproducible: true, allergenNames: ["Lait", "Fruits à coque"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -684,7 +703,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Pâte de curry rouge (thaï)", allergenNames: [] }, { name: "Pâte de curry vert (thaï)", allergenNames: [] }, - { name: "Tahini", allergenNames: ["Graines de sésame"] }, + { name: "Tahini", reproducible: true, allergenNames: ["Graines de sésame"] }, ], }, { @@ -764,8 +783,20 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Coulis de tomate", icon: "JAR", allergenNames: [] }, { name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] }, { name: "Tomates séchées", icon: "JAR", allergenNames: [] }, - { name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, - { name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, + { + name: "Fond de veau", + icon: "STOCK_POT", + reproducible: true, + allergenNames: [], + dietNames: [], + }, + { + name: "Fond de volaille", + icon: "STOCK_POT", + reproducible: true, + allergenNames: [], + dietNames: [], + }, { name: "Bouillon cube bœuf", icon: "STOCK_POT", @@ -778,14 +809,26 @@ export const INGREDIENT_GROUPS: Array<{ allergenNames: ["Poissons", "Céleri"], dietNames: ["Pescétarien"], }, - { name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] }, + { + name: "Bouillon de légumes", + icon: "STOCK_POT", + reproducible: true, + allergenNames: ["Céleri"], + }, { name: "Bouillon de volaille", icon: "STOCK_POT", + reproducible: true, + allergenNames: ["Céleri"], + dietNames: [], + }, + { + name: "Bouillon de bœuf", + icon: "STOCK_POT", + reproducible: true, allergenNames: ["Céleri"], dietNames: [], }, - { name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] }, { name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] }, { name: "Dashi (bouillon japonais)", @@ -808,6 +851,7 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Fumet de poisson", icon: "STOCK_POT", + reproducible: true, allergenNames: ["Poissons"], dietNames: ["Pescétarien"], }, @@ -864,11 +908,12 @@ export const INGREDIENT_GROUPS: Array<{ ]; const INGREDIENTS: Array< - Omit & { + Omit & { category: IngredientCategory; subcategory: IngredientSubcategory; icon: IngredientIcon; dietNames: string[]; + reproducible: boolean; } > = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) => items.map((item) => ({ @@ -877,6 +922,7 @@ const INGREDIENTS: Array< subcategory, icon: item.icon ?? defaultIcon, dietNames: item.dietNames ?? defaultDiets, + reproducible: item.reproducible ?? false, })), ); @@ -934,18 +980,26 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { const ingredientKeys = INGREDIENTS.map((i) => getEnglishKey(i.name)); const existingIngredients = await prisma.ingredient.findMany({ where: { key: { in: ingredientKeys } }, - select: { id: true, key: true, icon: true, category: true, subcategory: true }, + 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(getEnglishKey(i.name))); if (missingIngredients.length > 0) { await prisma.ingredient.createMany({ - data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ + data: missingIngredients.map(({ name, icon, category, subcategory, reproducible }) => ({ key: getEnglishKey(name), icon, category, subcategory, + reproducible, })), }); } @@ -956,13 +1010,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { existing && (existing.icon !== i.icon || existing.category !== i.category || - existing.subcategory !== i.subcategory) + existing.subcategory !== i.subcategory || + existing.reproducible !== i.reproducible) ); }); - for (const { name, icon, category, subcategory } of changed) { + for (const { name, icon, category, subcategory, reproducible } of changed) { await prisma.ingredient.update({ where: { key: getEnglishKey(name) }, - data: { icon, category, subcategory }, + data: { icon, category, subcategory, reproducible }, }); } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index bcb1608..aa8baa4 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -43,6 +43,7 @@ function toIngredientView(ingredient: IngredientWithDetails): IngredientView { icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, + reproducible: ingredient.reproducible, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, key: allergy.category.key, diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 247d4d2..749285a 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -53,6 +53,7 @@ export async function getIngredients(): Promise { icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, + reproducible: ingredient.reproducible, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, key: allergy.category.key, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 8fb423d..5d4a867 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -62,6 +62,7 @@ describe("Reference data", () => { "icon", "category", "subcategory", + "reproducible", "allergens", "diets", ]); diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx index 307d3a6..20ac680 100644 --- a/apps/web/src/features/recipes/IngredientPicker.tsx +++ b/apps/web/src/features/recipes/IngredientPicker.tsx @@ -11,6 +11,7 @@ import { CheckboxOption } from "../../components/ui/Checkbox"; import { SettingsIcon } from "../../layouts/nav-icons"; import { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; +import { ReproducibleBadge } from "./ReproducibleBadge"; import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons"; import "./recipes.scss"; @@ -60,6 +61,7 @@ export function IngredientPicker({ // badges per card too noisy while just browsing/searching by name. const [showAllergens, setShowAllergens] = useState(true); const [showDiets, setShowDiets] = useState(true); + const [showReproducible, setShowReproducible] = useState(true); const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false); function selectCategory(next: IngredientCategory | typeof ALL) { @@ -116,6 +118,9 @@ export function IngredientPicker({ {t("recipes.form.showAllergensLabel")} + + {t("recipes.form.showReproducibleLabel")} + )} @@ -184,6 +189,7 @@ export function IngredientPicker({ {showAllergens && } {showDiets && } + {showReproducible && } ))} diff --git a/apps/web/src/features/recipes/IngredientRow.tsx b/apps/web/src/features/recipes/IngredientRow.tsx index 7cb4a7e..7410f52 100644 --- a/apps/web/src/features/recipes/IngredientRow.tsx +++ b/apps/web/src/features/recipes/IngredientRow.tsx @@ -2,6 +2,7 @@ import type { IngredientView } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; import { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; +import { ReproducibleBadge } from "./ReproducibleBadge"; import { IngredientTypeIcon } from "./ingredient-icons"; import "./recipes.scss"; @@ -48,6 +49,10 @@ export function IngredientRow({ /> +