From e8596a55bc6a02262b8af0bff4ec0d90cec1dae3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 15:00:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20r=C3=A9gimes=20d'une=20recette?= =?UTF-8?q?=20calcul=C3=A9s=20depuis=20ses=20ingr=C3=A9dients=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RecipeDiet` (tag manuel) est supprimé — table droppée, `dietIds` retiré de `createRecipeSchema`, `DietTagSelect` retiré des formulaires de recette (création + import). `RecipeSummaryView.diets` / `RecipeView.diets` sont désormais **dérivés** via `computeRecipeDietViews` (`@batch-cooking/shared`, pur, testé isolément) : - un régime (vegetarian / vegan / pescatarian) est retenu ssi *tous* les ingrédients y sont compatibles (`IngredientDiet`, exposé par `IngredientView.diets`) ; - `glutenFree` est dérivé des allergènes (aucun ingrédient ne porte `gluten`) — cohérent avec le fait que le seed ne le stocke pas dans `IngredientDiet` ; - `[]` dès qu'une recette contient un ingrédient placeholder (hors-catalogue) : un ingrédient inconnu ne certifie rien. Filtres du sélecteur de planning (`suitableForHousehold`, `dietIds`) : ne peuvent plus être du SQL sur une colonne stockée — devenus des post-filtres sur `diets` calculé dans `listRecipes` ; `omnivore` traité comme « aucune contrainte ». `assertDietsExist` / `ErrorCode.DIET_NOT_FOUND` sur la création recette supprimés (le code d'erreur reste, utilisé par la préférence profil). `DietBadges` inchangé (affiche `diets` tel quel). `RecipePickerDialog` garde son filtre régime (fonctionne via le post-filtre). Tests : `recipe-diets.test.ts` (fonction pure) + `recipe.test.ts` réécrit (régimes calculés attendus, tests DIET_NOT_FOUND retirés) ; Cypress OK (formulaire sans sélecteur de régime, régression recettes/sources/planning). Migration `DROP TABLE recipe_diet` — aucune donnée à conserver. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 7 + apps/api/prisma/schema.prisma | 19 +- apps/api/src/modules/recipe/recipe.service.ts | 279 ++++++++++-------- apps/api/test/recipe-diets.test.ts | 86 ++++++ apps/api/test/recipe-placeholder.test.ts | 7 - apps/api/test/recipe.test.ts | 154 +++++----- apps/api/test/sources/sources.test.ts | 1 - apps/web/cypress/e2e/recipe-form.feature | 4 + .../recipes/sources/RecipeImportForm.tsx | 15 +- .../recipes/sources/recipe-import-draft.ts | 8 +- apps/web/src/pages/recipes/RecipeFormPage.tsx | 12 +- packages/shared/src/index.ts | 1 + packages/shared/src/logic/recipe-diets.ts | 71 +++++ packages/shared/src/schemas/recipe.ts | 2 - packages/shared/src/types/recipe.ts | 10 +- 15 files changed, 413 insertions(+), 263 deletions(-) create mode 100644 apps/api/prisma/migrations/20260909130000_drop_recipe_diet/migration.sql create mode 100644 apps/api/test/recipe-diets.test.ts create mode 100644 packages/shared/src/logic/recipe-diets.ts diff --git a/apps/api/prisma/migrations/20260909130000_drop_recipe_diet/migration.sql b/apps/api/prisma/migrations/20260909130000_drop_recipe_diet/migration.sql new file mode 100644 index 0000000..762e83f --- /dev/null +++ b/apps/api/prisma/migrations/20260909130000_drop_recipe_diet/migration.sql @@ -0,0 +1,7 @@ +-- Régimes d'une recette calculés depuis ses ingrédients (issue #131, puce 4 +-- de #3) — `RecipeDiet` (tag manuel) est supprimé : `RecipeSummaryView.diets` +-- est désormais dérivé de `IngredientDiet` + de l'allergène `gluten` côté +-- lecture (voir `computeRecipeDietViews`). Aucune donnée à conserver. + +-- DropTable +DROP TABLE "recipe_diet"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d7cb5b8..eb943f4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,7 +49,6 @@ model Diet { key String @unique users UserProfile[] - recipes RecipeDiet[] ingredients IngredientDiet[] @@map("diet") @@ -375,7 +374,6 @@ model Recipe { steps Step[] planningItems PlanningItem[] favoritedBy RecipeFavorite[] - diets RecipeDiet[] @@unique([sourceId, externalId]) @@map("recipe") @@ -396,21 +394,6 @@ model RecipeFavorite { @@map("recipe_favorite") } -/// Explicit join table for the recipe <-> diet "associated regime" tags -/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a -/// manual reminder set by whoever creates/edits the recipe, not computed -/// from its ingredients. -model RecipeDiet { - recipeId Int @map("recipe_id") - dietId Int @map("diet_id") - - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) - diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) - - @@id([recipeId, dietId]) - @@map("recipe_diet") -} - /// `key` is `@unique` — not in the original spec doc, added so the seed /// script (reference-seed-data.ts) can `upsert` by key and stay /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A @@ -1001,7 +984,7 @@ model StepTechStepCorrection { /// /// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays /// (`String[]`), not a join table — unlike this schema's other list-shaped -/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text +/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text /// proposed once for a human to read, not ids referencing another catalog /// table, so there's nothing for a join table to normalize against. model TechStepTrainingSuggestion { diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 24389ed..436ca01 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools"; import { type AllergyView, type CreateRecipeInput, + computeRecipeDietViews, type DietView, ErrorCode, type IngredientView, @@ -29,7 +30,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step- // is a parameter rather than baked into that module. const DEFAULT_TECH_STEP_LOCALE = "fr"; -/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ +/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */ function recipeInclude(viewerId: number) { return { ingredients: { @@ -74,11 +75,31 @@ function recipeInclude(viewerId: number) { }, }, }, - diets: { include: { diet: true } }, favoritedBy: { where: { userProfileId: viewerId } }, } satisfies Prisma.RecipeInclude; } +/** + * Every reference `Diet` row (`{ id, key }`), loaded once and memoized — + * `Diet` is seeded reference data that never changes at runtime, so caching + * it for the process's lifetime is safe. Used to attach a real `DietView` to + * the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to + * resolve the planning picker's `dietIds` filter / a household's members' + * regimes for the post-filter in {@link listRecipes}. + */ +let referenceDietsPromise: Promise | null = null; +function loadReferenceDiets(): Promise { + if (referenceDietsPromise === null) { + referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } }); + } + return referenceDietsPromise; +} + +/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */ +async function loadGlutenFreeDiet(): Promise { + return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree"); +} + type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType; }>; @@ -121,10 +142,6 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV }; } -function toDietView(diet: { id: number; key: string }): DietView { - return { id: diet.id, key: diet.key }; -} - /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { const byId = new Map(); @@ -136,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { return [...byId.values()]; } -/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */ -function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { - const allergens = aggregateAllergens( - recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)), +/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */ +function toRecipeSummaryView( + recipe: RecipeWithDetails, + glutenFreeDiet: DietView | undefined, +): RecipeSummaryView { + const ingredientViews = recipe.ingredients.map((recipeIngredient) => + toIngredientView(recipeIngredient.ingredient), ); return { id: recipe.id, @@ -149,8 +169,9 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { portions: recipe.portions, authorId: recipe.authorId, visibility: recipe.visibility, - allergens, - diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)), + allergens: aggregateAllergens(ingredientViews), + // Computed from the ingredients, not stored — see `RecipeSummaryView.diets`. + diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet), isFavorite: recipe.favoritedBy.length > 0, }; } @@ -240,8 +261,8 @@ export function toStepTechStepViews( return views; } -/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */ -function toRecipeView(recipe: RecipeWithDetails): RecipeView { +/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */ +function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ ingredient: toIngredientView(recipeIngredient.ingredient), // Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. @@ -249,7 +270,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView { unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), })); return { - ...toRecipeSummaryView(recipe), + ...toRecipeSummaryView(recipe, glutenFreeDiet), ingredients, steps: recipe.steps.map((step) => ({ id: step.id, @@ -302,54 +323,57 @@ function visibleToViewerWhere( } /** - * `Recipe` rows that avoid every member of `houseId`'s declared allergens - * and, for every member with a declared regime, are tagged with that - * regime — the planning recipe picker's "convient à tout le foyer" toggle - * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small - * extra query to gather the household's members' allergy/regime ids) - * rather than exposed to the client as raw per-member data: a member's - * allergies/regime are private the same way visibility already keeps a - * recipe's existence private (404, never 403) — nothing here should let - * one member infer another's medical/dietary info from the shape of a - * filtered list. Deliberately excludes `UserProfileDislikedIngredient` — - * the schema already treats disliked ingredients as a taste preference, - * not a safety constraint (see that model's doc comment), so it doesn't - * belong in a filter framed around what's safe/appropriate to serve. + * The two halves of the planning recipe picker's "convient à tout le foyer" + * toggle (`suitableForHousehold` on `listRecipes`): + * - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that + * contains an allergen declared by a member (still expressible in SQL). + * - `requiredDietKeys` — every regime declared by a member (`omnivore` + * dropped: it constrains nothing). A recipe must satisfy *all* of them — + * a recipe suiting a vegetarian member doesn't automatically suit a + * gluten-free one. Applied as a post-filter in {@link listRecipes} on the + * *computed* `RecipeSummaryView.diets`, since regimes are no longer a + * stored column to query (issue #131). + * + * Computed server-side (a small extra query for the members' allergy/regime + * data) rather than exposed to the client as raw per-member data: a + * member's allergies/regime are private the same way visibility already + * keeps a recipe's existence private. Deliberately excludes + * `UserProfileDislikedIngredient` — a taste preference, not a safety + * constraint (see that model's doc comment). */ -async function suitableForHouseholdWhere(houseId: number): Promise { +async function suitableForHousehold( + houseId: number, +): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> { try { const members = await prisma.userProfile.findMany({ where: { houseId }, - select: { dietId: true, allergies: { select: { allergyId: true } } }, + select: { + diet: { select: { key: true } }, + allergies: { select: { allergyId: true } }, + }, }); - const requiredDietIds = [ - ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), + const requiredDietKeys = [ + ...new Set( + members + .map((member) => member.diet?.key) + .filter((key): key is string => key !== undefined && key !== "omnivore"), + ), ]; const excludedAllergyIds = [ - ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))), + ...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))), ]; - const conditions: Prisma.RecipeWhereInput[] = []; - if (requiredDietIds.length > 0) { - // Every diet declared by a member must be among this recipe's tags — - // not "at least one", since a recipe suiting a vegetarian member - // doesn't automatically suit a gluten-free one too. - conditions.push({ - AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })), - }); - } - if (excludedAllergyIds.length > 0) { - conditions.push({ - ingredients: { - none: { - ingredient: { - allergies: { some: { allergyId: { in: excludedAllergyIds } } }, + const allergenWhere: Prisma.RecipeWhereInput = + excludedAllergyIds.length > 0 + ? { + ingredients: { + none: { + ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } }, + }, }, - }, - }, - }); - } - return { AND: conditions }; + } + : {}; + return { allergenWhere, requiredDietKeys }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // already logs it, see `error-logger.ts`) is what actually handles it, @@ -383,7 +407,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise row.sourceId); return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] }; } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -397,11 +421,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise { try { - const { search, suitableForHousehold, ingredientIds, dietIds } = filters; + const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)]; if (search) { conditions.push({ name: { contains: search, mode: "insensitive" } }); } + // Regimes are computed from ingredients, not a stored column (issue + // #131), so the household-regime and `dietIds` filters can't be SQL + // anymore — they run as post-filters on the mapped summaries below. + // Everything that still *can* be a `where` stays one. + let requiredDietKeys: string[] = []; // No-op without a household — nothing to filter against, same posture as // the `foyer` tab returning everything it can rather than throwing. - if (suitableForHousehold && viewerHouseId !== null) { - conditions.push(await suitableForHouseholdWhere(viewerHouseId)); + if (suitable && viewerHouseId !== null) { + const household = await suitableForHousehold(viewerHouseId); + conditions.push(household.allergenWhere); + requiredDietKeys = household.requiredDietKeys; } if (ingredientIds && ingredientIds.length > 0) { // One condition per required id (AND) — a recipe must carry all of - // them, not just one, same "every one, not any one" posture as - // suitableForHouseholdWhere's requiredDietIds. + // them, not just one. conditions.push({ AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } }, })), }); } - if (dietIds && dietIds.length > 0) { - conditions.push({ - AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })), - }); - } switch (tab) { case "favoris": @@ -465,14 +490,38 @@ export async function listRecipes( break; } - const recipes = await prisma.recipe.findMany({ - where: { AND: conditions }, - include: recipeInclude(viewerId), - orderBy: { name: "asc" }, - }); - return recipes.map(toRecipeSummaryView); + const [recipes, referenceDiets] = await Promise.all([ + prisma.recipe.findMany({ + where: { AND: conditions }, + include: recipeInclude(viewerId), + orderBy: { name: "asc" }, + }), + loadReferenceDiets(), + ]); + const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree"); + + let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet)); + + // Post-filters on the *computed* `diets` (see the comment where the SQL + // conditions are built). "Every one, not any one" for both. + if (requiredDietKeys.length > 0) { + summaries = summaries.filter((summary) => + requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)), + ); + } + if (dietIds && dietIds.length > 0) { + // `omnivore` in the filter would match nothing (it's never emitted) — + // treat it as "no constraint" rather than silently returning an empty list. + const requiredIds = dietIds.filter( + (id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore", + ); + summaries = summaries.filter((summary) => + requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)), + ); + } + return summaries; } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -491,23 +540,23 @@ export async function getRecipe( if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } - return toRecipeView(recipe); + return toRecipeView(recipe, await loadGlutenFreeDiet()); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } /** - * Creates a recipe with its ingredients, ordered steps and diet tags in one - * go — steps' `order` is derived from their position in `input.steps`, + * Creates a recipe with its ingredients and ordered steps in one go — + * steps' `order` is derived from their position in `input.steps`, * ingredients reference existing reference `Ingredient` rows by id (see - * `GET /reference/ingredients`; there's no way to create one here). - * `authorId`/`authorHouseId` are fixed at creation and never change on - * later edits (see {@link updateRecipe}). + * `GET /reference/ingredients`; there's no way to create one here). Its + * regimes aren't stored — they're computed from the ingredients on read + * (issue #131). `authorId`/`authorHouseId` are fixed at creation and never + * change on later edits (see {@link updateRecipe}). * * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. - * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. */ export async function createRecipe( input: CreateRecipeInput, @@ -517,7 +566,7 @@ export async function createRecipe( try { return await createRecipeInternal(input, authorId, authorHouseId, null); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -534,7 +583,6 @@ export async function createRecipe( * * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. - * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. */ export async function createImportedRecipe( input: CreateRecipeInput, @@ -545,7 +593,7 @@ export async function createImportedRecipe( try { return await createRecipeInternal(input, authorId, authorHouseId, source); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -642,7 +690,7 @@ async function resolveIngredientLines( } return { resolved, createdPlaceholders }; } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -659,7 +707,6 @@ async function createRecipeInternal( await assertUnitsExist( input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), ); - await assertDietsExist(input.dietIds); // Matched up front (one call per step, in parallel) rather than inline // inside the `steps.create` map below — `techStepClassifier` is async // (a trained model, not a pure regex test), so its result has to @@ -709,7 +756,6 @@ async function createRecipeInternal( techSteps: { create: toStepTechStepCreateInputs(matches) }, })), }, - diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, }, include: recipeInclude(authorId), }); @@ -727,24 +773,25 @@ async function createRecipeInternal( }); } - return toRecipeView(created); + return toRecipeView(created, await loadGlutenFreeDiet()); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } /** * Replaces a recipe's whole content — name/description/picture/visibility - * and the complete ingredient/step/diet lists (not a partial merge: a line + * and the complete ingredient/step lists (not a partial merge: a line * missing from `input` is removed, same contract as `PATCH - * /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing - * never transfers ownership. + * /profile/allergies`). Regimes aren't part of the payload — they're + * recomputed from the ingredients on the next read (issue #131). + * `authorId`/`authorHouseId` are untouched — editing never transfers + * ownership. * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`. * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. - * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. */ export async function updateRecipe( id: number, @@ -762,7 +809,6 @@ export async function updateRecipe( await assertUnitsExist( input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), ); - await assertDietsExist(input.dietIds); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); // Interactive transaction (not the array form) so any new free-text @@ -773,7 +819,6 @@ export async function updateRecipe( const createdPlaceholders = await prisma.$transaction(async (tx) => { await tx.recipeIngredient.deleteMany({ where: { recipeId: id } }); await tx.step.deleteMany({ where: { recipeId: id } }); - await tx.recipeDiet.deleteMany({ where: { recipeId: id } }); const { resolved, createdPlaceholders } = await resolveIngredientLines( input.ingredients, viewerId, @@ -808,7 +853,6 @@ export async function updateRecipe( techSteps: { create: toStepTechStepCreateInputs(matches) }, })), }, - diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, }, }); return createdPlaceholders; @@ -821,9 +865,9 @@ export async function updateRecipe( }); } - return toRecipeView(await findRecipeOrThrow(id, viewerId)); + return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet()); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -856,7 +900,7 @@ export async function deleteRecipe( await prisma.recipe.delete({ where: { id } }); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -884,7 +928,7 @@ export async function addFavorite( create: { userProfileId: viewerId, recipeId: id }, }); } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -895,7 +939,7 @@ export async function removeFavorite(id: number, viewerId: number): Promise { ); } } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } @@ -997,29 +1041,6 @@ async function assertUnitsExist(unitIds: number[]): Promise { ); } } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above - } -} - -/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */ -async function assertDietsExist(dietIds: number[]): Promise { - try { - const uniqueIds = [...new Set(dietIds)]; - if (uniqueIds.length === 0) return; - const found = await prisma.diet.findMany({ - where: { id: { in: uniqueIds } }, - select: { id: true }, - }); - if (found.length !== uniqueIds.length) { - const foundIds = new Set(found.map((diet) => diet.id)); - const missing = uniqueIds.filter((id) => !foundIds.has(id)); - throw new HttpError( - 404, - ErrorCode.DIET_NOT_FOUND, - `Diet(s) not found: ${missing.join(", ")}`, - ); - } - } catch (err) { - throw err; // see suitableForHouseholdWhere()'s catch comment above + throw err; // see suitableForHousehold()'s catch comment above } } diff --git a/apps/api/test/recipe-diets.test.ts b/apps/api/test/recipe-diets.test.ts new file mode 100644 index 0000000..8eb9937 --- /dev/null +++ b/apps/api/test/recipe-diets.test.ts @@ -0,0 +1,86 @@ +import { computeRecipeDietViews, type DietView, type IngredientView } from "@batch-cooking/shared"; +import { expect } from "chai"; + +/** + * Pure regime derivation from `@batch-cooking/shared` — no DB, so this suite + * skips `resetDatabase`. `recipe.service.ts`'s `toRecipeSummaryView` calls + * it for every recipe read (issue #131 dropped the hand-tagged `RecipeDiet`). + */ + +const VEGETARIAN: DietView = { id: 1, key: "vegetarian" }; +const VEGAN: DietView = { id: 2, key: "vegan" }; +const PESCATARIAN: DietView = { id: 3, key: "pescatarian" }; +const GLUTEN_FREE: DietView = { id: 4, key: "glutenFree" }; + +/** A minimal ingredient fixture — only the fields `computeRecipeDietViews` reads. */ +function ingredient( + diets: DietView[], + opts: { gluten?: boolean; placeholder?: boolean } = {}, +): Pick { + return { + diets, + allergens: opts.gluten ? [{ id: 9, key: "gluten", kind: "INTOLERANCE" }] : [], + isPlaceholder: opts.placeholder ?? false, + }; +} + +describe("computeRecipeDietViews", () => { + it("keeps only the regimes every ingredient is compatible with", () => { + const result = computeRecipeDietViews( + [ + ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), + ingredient([VEGETARIAN, PESCATARIAN]), // e.g. egg — not vegan + ], + GLUTEN_FREE, + ); + expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "pescatarian", "glutenFree"]); + }); + + it("drops every ingredient-derived regime when one ingredient is compatible with none", () => { + const result = computeRecipeDietViews( + [ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([])], + GLUTEN_FREE, + ); + // Still gluten-free — nothing carries the allergen. + expect(result.map((d) => d.key)).to.deep.equal(["glutenFree"]); + }); + + it("is not gluten-free when any ingredient carries the gluten allergen", () => { + const result = computeRecipeDietViews( + [ + ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), + ingredient([VEGETARIAN, VEGAN, PESCATARIAN], { gluten: true }), + ], + GLUTEN_FREE, + ); + expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]); + }); + + it("returns [] for a recipe with any placeholder (off-catalog) ingredient", () => { + const result = computeRecipeDietViews( + [ingredient([VEGETARIAN, VEGAN, PESCATARIAN]), ingredient([], { placeholder: true })], + GLUTEN_FREE, + ); + expect(result).to.deep.equal([]); + }); + + it("returns [] when there are no ingredients", () => { + expect(computeRecipeDietViews([], GLUTEN_FREE)).to.deep.equal([]); + }); + + it("omits gluten-free entirely when no glutenFree DietView is supplied", () => { + const result = computeRecipeDietViews( + [ingredient([VEGAN, VEGETARIAN, PESCATARIAN])], + undefined, + ); + expect(result.map((d) => d.key)).to.deep.equal(["vegetarian", "vegan", "pescatarian"]); + }); + + it("borrows each regime's DietView (id + key) from the ingredients themselves", () => { + const result = computeRecipeDietViews( + [ingredient([VEGETARIAN]), ingredient([VEGETARIAN])], + undefined, + ); + expect(result).to.deep.equal([VEGETARIAN]); + }); +}); diff --git a/apps/api/test/recipe-placeholder.test.ts b/apps/api/test/recipe-placeholder.test.ts index 5c2059c..2ee1ba4 100644 --- a/apps/api/test/recipe-placeholder.test.ts +++ b/apps/api/test/recipe-placeholder.test.ts @@ -62,7 +62,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { const res = await agent.post("/recipes").send({ name: "Poulet basquaise", portions: 4, - dietIds: [], ingredients: [ { ingredientId: tomato, quantity: 3, unitId: piece }, { placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece }, @@ -99,7 +98,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { await agent.post("/recipes").send({ name: "Test", portions: 2, - dietIds: [], ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); @@ -122,7 +120,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { const created = await agent.post("/recipes").send({ name: "Édition", portions: 2, - dietIds: [], ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); @@ -132,7 +129,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { const edited = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Édition", portions: 2, - dietIds: [], ingredients: [ { ingredientId: placeholderId, quantity: 2, unitId: piece }, { ingredientId: tomato, quantity: 1, unitId: piece }, @@ -146,7 +142,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { await agent.patch(`/recipes/${created.body.id}`).send({ name: "Édition", portions: 2, - dietIds: [], ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }], steps: [{ description: "x" }], }); @@ -163,7 +158,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { const res = await agent.post("/recipes").send({ name: "Invalide", portions: 2, - dietIds: [], ingredients: [ { ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece }, ], @@ -181,7 +175,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => { const res = await agent.post("/recipes").send({ name: "Doublons libres", portions: 2, - dietIds: [], ingredients: [ { placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece }, diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 13e8985..57ac0b2 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -312,20 +312,16 @@ describe("Recipes", () => { }); describe("POST /recipes", () => { - it("creates a recipe with its ingredients, ordered steps and diet tags", async () => { + it("creates a recipe with its ingredients and ordered steps, and computes its regimes from the ingredients", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const oeuf = await ingredientId("egg"); const piece = await unitId("piece"); - const vegetarien = await prisma.diet.findFirstOrThrow({ - where: { key: "vegetarian" }, - }); const res = await agent.post("/recipes").send({ name: "Omelette provençale", description: "Rapide et savoureuse", portions: 2, - dietIds: [vegetarien.id], ingredients: [ { ingredientId: tomate, quantity: 2, unitId: piece }, { ingredientId: oeuf, quantity: 3, unitId: piece }, @@ -343,7 +339,84 @@ describe("Recipes", () => { ).to.deep.equal([0, 1]); // Allergens aggregated across ingredients — "Œuf" carries "Œufs". expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs"); - expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["vegetarian"]); + // Regimes computed (issue #131), not tagged: tomato is vegetarian/ + // vegan/pescatarian, egg is vegetarian/pescatarian (not vegan) — the + // intersection is vegetarian + pescatarian; neither carries gluten, so + // glutenFree too. + expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([ + "vegetarian", + "pescatarian", + "glutenFree", + ]); + }); + + it("drops a regime the moment one ingredient is incompatible, keeps glutenFree if nothing carries gluten (issue #131)", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const poulet = await ingredientId("chicken"); + const piece = await unitId("piece"); + + const res = await agent.post("/recipes").send({ + name: "Poulet aux tomates", + portions: 2, + ingredients: [ + { ingredientId: tomate, quantity: 2, unitId: piece }, + { ingredientId: poulet, quantity: 1, unitId: piece }, + ], + steps: [{ description: "Cuire le poulet" }], + }); + + expect(res.status).to.equal(201); + // Chicken is compatible with none of vegetarian/vegan/pescatarian, so + // the recipe qualifies for none of them — but it has no gluten. + expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal(["glutenFree"]); + }); + + it("is not glutenFree when an ingredient carries the gluten allergen (issue #131)", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const farine = await ingredientId("wheatFlour"); + const piece = await unitId("piece"); + const gram = await unitId("gram"); + + const res = await agent.post("/recipes").send({ + name: "Galette de tomates", + portions: 2, + ingredients: [ + { ingredientId: tomate, quantity: 2, unitId: piece }, + { ingredientId: farine, quantity: 100, unitId: gram }, + ], + steps: [{ description: "Mélanger" }], + }); + + expect(res.status).to.equal(201); + // Both ingredients are vegetarian/vegan/pescatarian, but wheat flour + // carries `gluten` — no glutenFree. + expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([ + "vegetarian", + "vegan", + "pescatarian", + ]); + }); + + it("has no regimes at all when a recipe contains a placeholder ingredient (issue #131)", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + + const res = await agent.post("/recipes").send({ + name: "Recette incomplète", + portions: 2, + ingredients: [ + { ingredientId: tomate, quantity: 2, unitId: piece }, + { placeholderName: "Épice mystère", quantity: 1, unitId: piece }, + ], + steps: [{ description: "Assembler" }], + }); + + expect(res.status).to.equal(201); + // An off-catalog ingredient can't certify anything. + expect(res.body.diets).to.deep.equal([]); }); it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { @@ -355,7 +428,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -367,7 +439,6 @@ describe("Recipes", () => { name: "Foyer", visibility: "HOUSE", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -386,7 +457,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Ragoût", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description }], }); @@ -413,7 +483,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Gratin", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description }], }); @@ -448,7 +517,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Servir immédiatement" }], }); @@ -467,7 +535,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Ragoût", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [ { description: "Hacher les oignons" }, @@ -497,7 +564,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Gratin", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], // Matches both `cook` (weight 10) and `bake` (weight 25, "au four"). steps: [{ description: "Cuire au four pendant 30 minutes" }], @@ -519,7 +585,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Poêlée", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], // The case that motivated the sequence model: one instruction, two techniques. steps: [{ description }], @@ -550,7 +615,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -566,7 +630,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }], steps: [{ description: "Étape" }], }); @@ -575,30 +638,12 @@ describe("Recipes", () => { expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND); }); - it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { - const { agent } = await signup(); - const tomate = await ingredientId("tomato"); - const piece = await unitId("piece"); - - const res = await agent.post("/recipes").send({ - name: "Test", - portions: 4, - dietIds: [999_999], - ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], - steps: [{ description: "Étape" }], - }); - - expect(res.status).to.equal(404); - expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND); - }); - it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => { const { agent } = await signup(); const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [], steps: [{ description: "Étape" }], }); @@ -621,7 +666,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [ { ingredientId: tomate, quantity: 1, unitId: piece }, { ingredientId: tomate, quantity: 2, unitId: piece }, @@ -639,7 +683,6 @@ describe("Recipes", () => { const piece = await unitId("piece"); const basePayload = { name: "Test", - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }; @@ -662,7 +705,6 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Tomates au sel", portions: 2, - dietIds: [], ingredients: [ { ingredientId: tomate, quantity: 200, unitId: gram }, { ingredientId: sel }, // selon le goût — no quantity/unit @@ -685,7 +727,6 @@ describe("Recipes", () => { const base = { name: "Test", portions: 2, - dietIds: [], steps: [{ description: "Étape" }], }; @@ -722,7 +763,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -808,7 +848,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -817,7 +856,6 @@ describe("Recipes", () => { name: "Salade composée", portions: 6, visibility: "PUBLIC", - dietIds: [], ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }], steps: [{ description: "Émincer" }, { description: "Mélanger" }], }); @@ -840,7 +878,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }], steps: [{ description: "Couper" }], }); @@ -848,7 +885,6 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Salade poivrée", portions: 4, - dietIds: [], ingredients: [ { ingredientId: tomate, quantity: 100, unitId: gram }, { ingredientId: poivre }, // selon le goût @@ -872,7 +908,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Servir immédiatement" }], }); @@ -880,7 +915,6 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Salade", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Émincer les tomates" }], }); @@ -903,7 +937,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Ragoût", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [ { description: "Hacher les oignons" }, // chop @@ -932,7 +965,6 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Ragoût", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: editedDescription }, { description: addedDescription }], }); @@ -973,7 +1005,6 @@ describe("Recipes", () => { const created = await agent.post("/recipes").send({ name: "Gratin", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Servir immédiatement" }], }); @@ -990,7 +1021,6 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Gratin", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: editedDescription }], }); @@ -1025,7 +1055,6 @@ describe("Recipes", () => { const res = await agent.patch("/recipes/999999").send({ name: "Test", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -1034,30 +1063,6 @@ describe("Recipes", () => { expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); }); - it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { - const { agent } = await signup(); - const tomate = await ingredientId("tomato"); - const piece = await unitId("piece"); - const created = await agent.post("/recipes").send({ - name: "Salade", - portions: 4, - dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], - steps: [{ description: "Couper" }], - }); - - const res = await agent.patch(`/recipes/${created.body.id}`).send({ - name: "Salade", - portions: 4, - dietIds: [999_999], - ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], - steps: [{ description: "Couper" }], - }); - - expect(res.status).to.equal(404); - expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND); - }); - it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => { const { profileId } = await signup(); const { agent: otherAgent } = await signup(); @@ -1070,7 +1075,6 @@ describe("Recipes", () => { const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({ name: "Hack", portions: 4, - dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); diff --git a/apps/api/test/sources/sources.test.ts b/apps/api/test/sources/sources.test.ts index 6075cd1..20e58e9 100644 --- a/apps/api/test/sources/sources.test.ts +++ b/apps/api/test/sources/sources.test.ts @@ -426,7 +426,6 @@ describe("Sources", () => { return { name: "Fake recipe 1 (revue)", portions: 4, - dietIds: [], ingredients: [ { ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") }, ], diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index dded0fa..0f62162 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -82,6 +82,10 @@ Feature: Recipe form — associating ingredients Then the recipe creation request should have included the seasoning-only ingredient 1 And the URL should include "/recettes/44" + Scenario: The recipe form has no regime picker — regimes are computed from ingredients (issue #131) + When I visit "/recettes/nouvelle" + Then I should not see "Régime(s) associé(s)" + Scenario: The save/cancel bar stays pinned to the foot of a long form When I visit "/recettes/nouvelle" And I add a step diff --git a/apps/web/src/features/recipes/sources/RecipeImportForm.tsx b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx index ff8880c..36141aa 100644 --- a/apps/web/src/features/recipes/sources/RecipeImportForm.tsx +++ b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx @@ -1,7 +1,6 @@ import { type CreateRecipeInput, createRecipeSchema, - type DietView, ErrorCode, type IngredientView, type Meal, @@ -16,7 +15,6 @@ import { useTranslation } from "react-i18next"; import { ApiError, apiClient } from "../../../api/client"; import { makeClientKey } from "../../../lib/client-key"; import { errorMessageService } from "../../../services/error-message.service"; -import { DietTagSelect } from "../badges/DietTagSelect"; import { IngredientPicker } from "../ingredients/IngredientPicker"; import { IngredientRow } from "../ingredients/IngredientRow"; import { @@ -60,8 +58,8 @@ type LoadState = "loading" | "loaded" | "error"; * * Pre-filled from `GET /sources/:sourceKey/preview/:externalId`, * structurally the same form as `RecipeFormPage` — same sub-components - * (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`), - * same `CreateRecipeInput` submit shape — plus one thing a manual creation + * (`IngredientRow`, `IngredientPicker`, `StepListEditor`), same + * `CreateRecipeInput` submit shape — plus one thing a manual creation * never has to handle: ingredient lines the automatic matching * (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à * compléter" list, each needing a real ingredient picked (or the line @@ -95,7 +93,6 @@ export function RecipeImportForm({ const [loadState, setLoadState] = useState("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); - const [dietsCatalog, setDietsCatalog] = useState([]); const [unitsCatalog, setUnitsCatalog] = useState([]); const [name, setName] = useState(""); @@ -103,7 +100,6 @@ export function RecipeImportForm({ const [picture, setPicture] = useState(""); const [portions, setPortions] = useState("4"); const [visibility, setVisibility] = useState("PERSONAL"); - const [dietIds, setDietIds] = useState([]); const [ingredientLines, setIngredientLines] = useState([]); const [unresolvedIngredients, setUnresolvedIngredients] = useState( [], @@ -123,14 +119,12 @@ export function RecipeImportForm({ Promise.all([ apiClient.getIngredients(), - apiClient.getDiets(), apiClient.getUnits(), apiClient.previewSourceItem(sourceKey, externalId), ]) - .then(([ingredients, diets, units, draft]) => { + .then(([ingredients, units, draft]) => { if (cancelled) return; setIngredientsCatalog(ingredients); - setDietsCatalog(diets); setUnitsCatalog(units); setName(draft.name); @@ -304,7 +298,6 @@ export function RecipeImportForm({ picture: picture.trim() || null, portions: Number(portions), visibility, - dietIds, ingredients: ingredientLines.map((line) => ({ // Free-text lines (nothing in the catalog matched) submit as // `placeholderName` so the API creates the placeholder row — same @@ -436,8 +429,6 @@ export function RecipeImportForm({ ))} - -

{t("recipes.ingredientsTitle")}

    diff --git a/apps/web/src/features/recipes/sources/recipe-import-draft.ts b/apps/web/src/features/recipes/sources/recipe-import-draft.ts index 29a5bef..4a18432 100644 --- a/apps/web/src/features/recipes/sources/recipe-import-draft.ts +++ b/apps/web/src/features/recipes/sources/recipe-import-draft.ts @@ -16,9 +16,10 @@ import { * fall back to the full review screen (`ImportRecipePage`), pre-filled from * this exact same draft, for a person to fill in what's missing. * - * Default `visibility`/`dietIds` mirror what a person would otherwise leave - * untouched on that same form (`PERSONAL`, no diet tags) — nothing here is - * a guess about data the draft doesn't have an opinion on. + * Default `visibility` mirrors what a person would otherwise leave + * untouched on that same form (`PERSONAL`) — nothing here is a guess about + * data the draft doesn't have an opinion on. Regimes aren't part of the + * payload anymore (issue #131) — they're computed from the ingredients. * `createRecipeSchema.safeParse` is still the actual authority on whether * the result is submittable (a positive-portions check, string lengths, * etc.) — the checks above it exist only for what a schema alone can't @@ -42,7 +43,6 @@ export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateReci picture: draft.picture, portions: draft.portions, visibility: "PERSONAL", - dietIds: [], ingredients: draft.ingredients.map((line) => ({ // Every line is fully resolved by this point — guarded above. ingredientId: (line.ingredient as NonNullable).id, diff --git a/apps/web/src/pages/recipes/RecipeFormPage.tsx b/apps/web/src/pages/recipes/RecipeFormPage.tsx index d3c34e9..9d91757 100644 --- a/apps/web/src/pages/recipes/RecipeFormPage.tsx +++ b/apps/web/src/pages/recipes/RecipeFormPage.tsx @@ -1,7 +1,6 @@ import { type CreateRecipeInput, createRecipeSchema, - type DietView, ErrorCode, type IngredientView, type RecipeVisibility, @@ -11,7 +10,6 @@ import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; import { ApiError, apiClient } from "../../api/client"; -import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect"; import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; import { @@ -68,7 +66,6 @@ export function RecipeFormPage() { const [loadState, setLoadState] = useState("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); - const [dietsCatalog, setDietsCatalog] = useState([]); const [unitsCatalog, setUnitsCatalog] = useState([]); const [name, setName] = useState(""); @@ -80,7 +77,6 @@ export function RecipeFormPage() { // get past the gate if 4 is already right for their recipe. const [portions, setPortions] = useState("4"); const [visibility, setVisibility] = useState("PERSONAL"); - const [dietIds, setDietIds] = useState([]); const [ingredientLines, setIngredientLines] = useState([]); const [steps, setSteps] = useState([]); @@ -93,14 +89,12 @@ export function RecipeFormPage() { Promise.all([ apiClient.getIngredients(), - apiClient.getDiets(), apiClient.getUnits(), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), ]) - .then(([ingredients, diets, units, recipe]) => { + .then(([ingredients, units, recipe]) => { if (cancelled) return; setIngredientsCatalog(ingredients); - setDietsCatalog(diets); setUnitsCatalog(units); if (recipe) { setName(recipe.name); @@ -108,7 +102,6 @@ export function RecipeFormPage() { setPicture(recipe.picture ?? ""); setPortions(String(recipe.portions)); setVisibility(recipe.visibility); - setDietIds(recipe.diets.map((diet) => diet.id)); setIngredientLines( recipe.ingredients.map((line) => ({ key: makeClientKey(), @@ -216,7 +209,6 @@ export function RecipeFormPage() { picture: picture.trim() || null, portions: Number(portions), visibility, - dietIds, ingredients: ingredientLines.map((line) => ({ // A just-added free-text line has no catalog id yet — ask the API to // create the placeholder row via `placeholderName`. An edit-loaded @@ -335,8 +327,6 @@ export function RecipeFormPage() { ))} - -

    {t("recipes.ingredientsTitle")}

      diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d2bc57d..10f8cb4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-fr.js"; export * from "./errors/error-codes.js"; export * from "./logic/planning-portions.js"; +export * from "./logic/recipe-diets.js"; export * from "./schemas/account.js"; export * from "./schemas/admin.js"; export * from "./schemas/auth.js"; diff --git a/packages/shared/src/logic/recipe-diets.ts b/packages/shared/src/logic/recipe-diets.ts new file mode 100644 index 0000000..05bfa9d --- /dev/null +++ b/packages/shared/src/logic/recipe-diets.ts @@ -0,0 +1,71 @@ +import type { DietView, IngredientView } from "../types/reference.js"; + +/** + * Pure derivation of a recipe's dietary regimes from its ingredients — no + * I/O, so it runs identically in the API (shaping `RecipeSummaryView.diets` + * / `RecipeView.diets`) and, if ever needed, the web app. Colocated in + * `@batch-cooking/shared` for that "one formula, both sides" reason, same as + * {@link ../logic/planning-portions}. + * + * Regimes are no longer hand-tagged on a recipe (issue #131) — `RecipeDiet` + * was dropped. A recipe now follows a regime **iff every one of its + * ingredients is compatible with it** (`IngredientView.diets`, resolved from + * the `IngredientDiet` seed data), with two special cases the seed + * deliberately never stores on `IngredientDiet` (see its schema doc + * comment): + * - `omnivore` — every ingredient trivially qualifies, so it's never + * surfaced as a tag (would be pure noise). + * - `glutenFree` — derived here from allergens instead: the recipe is + * gluten-free iff no ingredient carries the `gluten` allergen. + */ + +/** + * The regime keys the `IngredientDiet` seed actually stores compatibility + * for (`reference-seed-data.ts`'s `defaultDiets`/`dietUids`) — in the + * canonical `DIETS` order, minus the two special cases above. + */ +const INGREDIENT_DERIVED_DIET_KEYS = ["vegetarian", "vegan", "pescatarian"] as const; + +/** Allergen key (`AllergyView.key`) that rules a recipe out of `glutenFree`. */ +const GLUTEN_ALLERGEN_KEY = "gluten"; + +/** + * The regimes a recipe qualifies for, as full {@link DietView}s, in + * canonical order. Empty for a recipe with **no ingredients**, or with **any + * placeholder (off-catalog) ingredient** — an ingredient the catalog doesn't + * know can't certify anything. + * + * The three ingredient-derived regimes' `DietView`s are taken straight from + * the ingredients' own already-resolved `diets` (`{ id, key }`); `glutenFree` + * has no `IngredientDiet` rows to borrow one from, so its `DietView` is + * passed in by the caller (which has the reference `Diet` table to hand). + * Pass `undefined` to leave `glutenFree` out entirely. + */ +export function computeRecipeDietViews( + ingredients: Pick[], + glutenFreeDiet: DietView | undefined, +): DietView[] { + if (ingredients.length === 0 || ingredients.some((ingredient) => ingredient.isPlaceholder)) { + return []; + } + + const result: DietView[] = []; + for (const key of INGREDIENT_DERIVED_DIET_KEYS) { + const perIngredient = ingredients.map((ingredient) => + ingredient.diets.find((diet) => diet.key === key), + ); + const first = perIngredient[0]; + if (first !== undefined && perIngredient.every((diet) => diet !== undefined)) { + result.push(first); + } + } + + const carriesGluten = ingredients.some((ingredient) => + ingredient.allergens.some((allergen) => allergen.key === GLUTEN_ALLERGEN_KEY), + ); + if (!carriesGluten && glutenFreeDiet !== undefined) { + result.push(glutenFreeDiet); + } + + return result; +} diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index 45ad0d1..3696144 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -68,8 +68,6 @@ export const createRecipeSchema = z /** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */ portions: z.number().int().positive("Le nombre de portions doit être positif"), visibility: recipeVisibilitySchema.default("PERSONAL"), - /** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */ - dietIds: z.array(z.number().int().positive()), ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"), steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"), }) diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index 1e3f6ec..9cd2128 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -161,10 +161,12 @@ export interface StepView { * the same aggregation `GET /recipes/:id` performs for {@link RecipeView}, * kept consistent so the table's badges match the detail panel's. * - * `isFavorite` and `diets` are resolved for the *requesting* user/recipe — - * `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma), while - * `diets` is a property of the recipe itself (manually tagged by its - * author, not viewer-specific). + * `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma). + * `diets` is **computed from the recipe's ingredients**, not viewer-specific + * and no longer hand-tagged (issue #131, `RecipeDiet` dropped) — see + * {@link computeRecipeDietViews}: a regime iff every ingredient is + * compatible with it, plus `glutenFree` iff no ingredient carries the + * `gluten` allergen. `[]` for a recipe with any placeholder ingredient. */ export interface RecipeSummaryView { id: number; -- 2.45.2