feat(recipes): régimes d'une recette calculés depuis ses ingrédients (#131)
All checks were successful
CI / lint (push) Successful in 2m58s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m57s
CI / e2e (push) Successful in 10m45s
CI / test (push) Successful in 18m56s
CI / build-android (push) Has been skipped

`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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-09 15:00:52 +02:00
parent 019e3fb5e5
commit e8596a55bc
15 changed files with 413 additions and 263 deletions

View file

@ -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";

View file

@ -49,7 +49,6 @@ model Diet {
key String @unique key String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[] ingredients IngredientDiet[]
@@map("diet") @@map("diet")
@ -375,7 +374,6 @@ model Recipe {
steps Step[] steps Step[]
planningItems PlanningItem[] planningItems PlanningItem[]
favoritedBy RecipeFavorite[] favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId]) @@unique([sourceId, externalId])
@@map("recipe") @@map("recipe")
@ -396,21 +394,6 @@ model RecipeFavorite {
@@map("recipe_favorite") @@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 /// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay /// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A /// 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 /// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped /// (`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 /// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against. /// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion { model TechStepTrainingSuggestion {

View file

@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools";
import { import {
type AllergyView, type AllergyView,
type CreateRecipeInput, type CreateRecipeInput,
computeRecipeDietViews,
type DietView, type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
@ -29,7 +30,7 @@ import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-
// is a parameter rather than baked into that module. // is a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr"; 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) { function recipeInclude(viewerId: number) {
return { return {
ingredients: { ingredients: {
@ -74,11 +75,31 @@ function recipeInclude(viewerId: number) {
}, },
}, },
}, },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } }, favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude; } 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<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
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<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>; include: ReturnType<typeof recipeInclude>;
}>; }>;
@ -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}. */ /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>(); const byId = new Map<number, AllergyView>();
@ -136,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return [...byId.values()]; 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. */ /** 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): RecipeSummaryView { function toRecipeSummaryView(
const allergens = aggregateAllergens( recipe: RecipeWithDetails,
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)), glutenFreeDiet: DietView | undefined,
): RecipeSummaryView {
const ingredientViews = recipe.ingredients.map((recipeIngredient) =>
toIngredientView(recipeIngredient.ingredient),
); );
return { return {
id: recipe.id, id: recipe.id,
@ -149,8 +169,9 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
portions: recipe.portions, portions: recipe.portions,
authorId: recipe.authorId, authorId: recipe.authorId,
visibility: recipe.visibility, visibility: recipe.visibility,
allergens, allergens: aggregateAllergens(ingredientViews),
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)), // Computed from the ingredients, not stored — see `RecipeSummaryView.diets`.
diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet),
isFavorite: recipe.favoritedBy.length > 0, isFavorite: recipe.favoritedBy.length > 0,
}; };
} }
@ -240,8 +261,8 @@ export function toStepTechStepViews(
return views; return views;
} }
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */ /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView { function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. // 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), unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
})); }));
return { return {
...toRecipeSummaryView(recipe), ...toRecipeSummaryView(recipe, glutenFreeDiet),
ingredients, ingredients,
steps: recipe.steps.map((step) => ({ steps: recipe.steps.map((step) => ({
id: step.id, id: step.id,
@ -302,54 +323,57 @@ function visibleToViewerWhere(
} }
/** /**
* `Recipe` rows that avoid every member of `houseId`'s declared allergens * The two halves of the planning recipe picker's "convient à tout le foyer"
* and, for every member with a declared regime, are tagged with that * toggle (`suitableForHousehold` on `listRecipes`):
* regime — the planning recipe picker's "convient à tout le foyer" toggle * - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small * contains an allergen declared by a member (still expressible in SQL).
* extra query to gather the household's members' allergy/regime ids) * - `requiredDietKeys` — every regime declared by a member (`omnivore`
* rather than exposed to the client as raw per-member data: a member's * dropped: it constrains nothing). A recipe must satisfy *all* of them —
* allergies/regime are private the same way visibility already keeps a * a recipe suiting a vegetarian member doesn't automatically suit a
* recipe's existence private (404, never 403) — nothing here should let * gluten-free one. Applied as a post-filter in {@link listRecipes} on the
* one member infer another's medical/dietary info from the shape of a * *computed* `RecipeSummaryView.diets`, since regimes are no longer a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` — * stored column to query (issue #131).
* the schema already treats disliked ingredients as a taste preference, *
* not a safety constraint (see that model's doc comment), so it doesn't * Computed server-side (a small extra query for the members' allergy/regime
* belong in a filter framed around what's safe/appropriate to serve. * 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<Prisma.RecipeWhereInput> { async function suitableForHousehold(
houseId: number,
): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> {
try { try {
const members = await prisma.userProfile.findMany({ const members = await prisma.userProfile.findMany({
where: { houseId }, where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } }, select: {
diet: { select: { key: true } },
allergies: { select: { allergyId: true } },
},
}); });
const requiredDietIds = [ const requiredDietKeys = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), ...new Set(
members
.map((member) => member.diet?.key)
.filter((key): key is string => key !== undefined && key !== "omnivore"),
),
]; ];
const excludedAllergyIds = [ 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[] = []; const allergenWhere: Prisma.RecipeWhereInput =
if (requiredDietIds.length > 0) { excludedAllergyIds.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 ingredients: {
// doesn't automatically suit a gluten-free one too. none: {
conditions.push({ ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } },
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })), },
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: {
ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
}, },
}, }
}, : {};
}); return { allergenWhere, requiredDietKeys };
}
return { AND: conditions };
} catch (err) { } catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it, // already logs it, see `error-logger.ts`) is what actually handles it,
@ -383,7 +407,7 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
).map((row) => row.sourceId); ).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] }; return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) { } 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<Prisma.Rec
export interface ListRecipesFilters { export interface ListRecipesFilters {
/** Case-insensitive name substring. */ /** Case-insensitive name substring. */
search?: string; search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */ /** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
suitableForHousehold?: boolean; suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */ /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[]; ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */ /** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
dietIds?: number[]; dietIds?: number[];
} }
@ -421,31 +445,32 @@ export async function listRecipes(
filters: ListRecipesFilters = {}, filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> { ): Promise<RecipeSummaryView[]> {
try { try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters; const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)]; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) { if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } }); 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 // No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing. // the `foyer` tab returning everything it can rather than throwing.
if (suitableForHousehold && viewerHouseId !== null) { if (suitable && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId)); const household = await suitableForHousehold(viewerHouseId);
conditions.push(household.allergenWhere);
requiredDietKeys = household.requiredDietKeys;
} }
if (ingredientIds && ingredientIds.length > 0) { if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of // One condition per required id (AND) — a recipe must carry all of
// them, not just one, same "every one, not any one" posture as // them, not just one.
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({ conditions.push({
AND: ingredientIds.map((ingredientId) => ({ AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } }, ingredients: { some: { ingredientId } },
})), })),
}); });
} }
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) { switch (tab) {
case "favoris": case "favoris":
@ -465,14 +490,38 @@ export async function listRecipes(
break; break;
} }
const recipes = await prisma.recipe.findMany({ const [recipes, referenceDiets] = await Promise.all([
where: { AND: conditions }, prisma.recipe.findMany({
include: recipeInclude(viewerId), where: { AND: conditions },
orderBy: { name: "asc" }, include: recipeInclude(viewerId),
}); orderBy: { name: "asc" },
return recipes.map(toRecipeSummaryView); }),
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) { } 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)) { if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
return toRecipeView(recipe); return toRecipeView(recipe, await loadGlutenFreeDiet());
} catch (err) { } 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 * Creates a recipe with its ingredients and ordered steps in one go —
* go — steps' `order` is derived from their position in `input.steps`, * steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see * ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here). * `GET /reference/ingredients`; there's no way to create one here). Its
* `authorId`/`authorHouseId` are fixed at creation and never change on * regimes aren't stored — they're computed from the ingredients on read
* later edits (see {@link updateRecipe}). * (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 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 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( export async function createRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -517,7 +566,7 @@ export async function createRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, null); return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) { } 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 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 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( export async function createImportedRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
@ -545,7 +593,7 @@ export async function createImportedRecipe(
try { try {
return await createRecipeInternal(input, authorId, authorHouseId, source); return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) { } 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 }; return { resolved, createdPlaceholders };
} catch (err) { } 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( await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), 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 // Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async // inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to // (a trained model, not a pure regex test), so its result has to
@ -709,7 +756,6 @@ async function createRecipeInternal(
techSteps: { create: toStepTechStepCreateInputs(matches) }, techSteps: { create: toStepTechStepCreateInputs(matches) },
})), })),
}, },
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
@ -727,24 +773,25 @@ async function createRecipeInternal(
}); });
} }
return toRecipeView(created); return toRecipeView(created, await loadGlutenFreeDiet());
} catch (err) { } 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 * 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 * missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing * /profile/allergies`). Regimes aren't part of the payload — they're
* never transfers ownership. * 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} `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} `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 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 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( export async function updateRecipe(
id: number, id: number,
@ -762,7 +809,6 @@ export async function updateRecipe(
await assertUnitsExist( await assertUnitsExist(
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
); );
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text // 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) => { const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } }); await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } }); await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines( const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients, input.ingredients,
viewerId, viewerId,
@ -808,7 +853,6 @@ export async function updateRecipe(
techSteps: { create: toStepTechStepCreateInputs(matches) }, techSteps: { create: toStepTechStepCreateInputs(matches) },
})), })),
}, },
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
}, },
}); });
return createdPlaceholders; 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) { } 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 } }); await prisma.recipe.delete({ where: { id } });
} catch (err) { } 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 }, create: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } 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<void
where: { userProfileId: viewerId, recipeId: id }, where: { userProfileId: viewerId, recipeId: id },
}); });
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHousehold()'s catch comment above
} }
} }
@ -918,7 +962,7 @@ export async function assertRecipeVisible(
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHousehold()'s catch comment above
} }
} }
@ -934,7 +978,7 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
} }
return recipe; return recipe;
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHousehold()'s catch comment above
} }
} }
@ -953,7 +997,7 @@ async function assertIsAuthor(
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this"); throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHousehold()'s catch comment above
} }
} }
@ -975,7 +1019,7 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
); );
} }
} catch (err) { } 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<void> {
); );
} }
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHousehold()'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<void> {
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
} }
} }

View file

@ -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<IngredientView, "diets" | "allergens" | "isPlaceholder"> {
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]);
});
});

View file

@ -62,7 +62,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Poulet basquaise", name: "Poulet basquaise",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomato, quantity: 3, unitId: piece }, { ingredientId: tomato, quantity: 3, unitId: piece },
{ placeholderName: "Piment d'Espelette", quantity: 1, 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({ await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }], ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -122,7 +120,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }], ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -132,7 +129,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const edited = await agent.patch(`/recipes/${created.body.id}`).send({ const edited = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: placeholderId, quantity: 2, unitId: piece }, { ingredientId: placeholderId, quantity: 2, unitId: piece },
{ ingredientId: tomato, quantity: 1, 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({ await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition", name: "Édition",
portions: 2, portions: 2,
dietIds: [],
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
steps: [{ description: "x" }], steps: [{ description: "x" }],
}); });
@ -163,7 +158,6 @@ describe("Recipes — off-catalog placeholder ingredients", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Invalide", name: "Invalide",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece }, { 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({ const res = await agent.post("/recipes").send({
name: "Doublons libres", name: "Doublons libres",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece }, { placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },

View file

@ -312,20 +312,16 @@ describe("Recipes", () => {
}); });
describe("POST /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 { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const oeuf = await ingredientId("egg"); const oeuf = await ingredientId("egg");
const piece = await unitId("piece"); const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" },
});
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Omelette provençale", name: "Omelette provençale",
description: "Rapide et savoureuse", description: "Rapide et savoureuse",
portions: 2, portions: 2,
dietIds: [vegetarien.id],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 2, unitId: piece }, { ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unitId: piece }, { ingredientId: oeuf, quantity: 3, unitId: piece },
@ -343,7 +339,84 @@ describe("Recipes", () => {
).to.deep.equal([0, 1]); ).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs". // Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include("eggs"); 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 () => { 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({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -367,7 +439,6 @@ describe("Recipes", () => {
name: "Foyer", name: "Foyer",
visibility: "HOUSE", visibility: "HOUSE",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -386,7 +457,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }], steps: [{ description }],
}); });
@ -413,7 +483,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }], steps: [{ description }],
}); });
@ -448,7 +517,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -467,7 +535,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [ steps: [
{ description: "Hacher les oignons" }, { description: "Hacher les oignons" },
@ -497,7 +564,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four"). // Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
steps: [{ description: "Cuire au four pendant 30 minutes" }], steps: [{ description: "Cuire au four pendant 30 minutes" }],
@ -519,7 +585,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Poêlée", name: "Poêlée",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques. // The case that motivated the sequence model: one instruction, two techniques.
steps: [{ description }], steps: [{ description }],
@ -550,7 +615,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -566,7 +630,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -575,30 +638,12 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND); 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 () => { it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup(); const { agent } = await signup();
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [], ingredients: [],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -621,7 +666,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece }, { ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece }, { ingredientId: tomate, quantity: 2, unitId: piece },
@ -639,7 +683,6 @@ describe("Recipes", () => {
const piece = await unitId("piece"); const piece = await unitId("piece");
const basePayload = { const basePayload = {
name: "Test", name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}; };
@ -662,7 +705,6 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Tomates au sel", name: "Tomates au sel",
portions: 2, portions: 2,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 200, unitId: gram }, { ingredientId: tomate, quantity: 200, unitId: gram },
{ ingredientId: sel }, // selon le goût — no quantity/unit { ingredientId: sel }, // selon le goût — no quantity/unit
@ -685,7 +727,6 @@ describe("Recipes", () => {
const base = { const base = {
name: "Test", name: "Test",
portions: 2, portions: 2,
dietIds: [],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}; };
@ -722,7 +763,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -808,7 +848,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -817,7 +856,6 @@ describe("Recipes", () => {
name: "Salade composée", name: "Salade composée",
portions: 6, portions: 6,
visibility: "PUBLIC", visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }], ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }], steps: [{ description: "Émincer" }, { description: "Mélanger" }],
}); });
@ -840,7 +878,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }], ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -848,7 +885,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade poivrée", name: "Salade poivrée",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 100, unitId: gram }, { ingredientId: tomate, quantity: 100, unitId: gram },
{ ingredientId: poivre }, // selon le goût { ingredientId: poivre }, // selon le goût
@ -872,7 +908,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -880,7 +915,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Émincer les tomates" }], steps: [{ description: "Émincer les tomates" }],
}); });
@ -903,7 +937,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [ steps: [
{ description: "Hacher les oignons" }, // chop { description: "Hacher les oignons" }, // chop
@ -932,7 +965,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }, { description: addedDescription }], steps: [{ description: editedDescription }, { description: addedDescription }],
}); });
@ -973,7 +1005,6 @@ describe("Recipes", () => {
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Servir immédiatement" }], steps: [{ description: "Servir immédiatement" }],
}); });
@ -990,7 +1021,6 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Gratin", name: "Gratin",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: editedDescription }], steps: [{ description: editedDescription }],
}); });
@ -1025,7 +1055,6 @@ describe("Recipes", () => {
const res = await agent.patch("/recipes/999999").send({ const res = await agent.patch("/recipes/999999").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -1034,30 +1063,6 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); 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 () => { it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup(); const { profileId } = await signup();
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
@ -1070,7 +1075,6 @@ describe("Recipes", () => {
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({ const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack", name: "Hack",
portions: 4, portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });

View file

@ -426,7 +426,6 @@ describe("Sources", () => {
return { return {
name: "Fake recipe 1 (revue)", name: "Fake recipe 1 (revue)",
portions: 4, portions: 4,
dietIds: [],
ingredients: [ ingredients: [
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") }, { ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
], ],

View file

@ -82,6 +82,10 @@ Feature: Recipe form — associating ingredients
Then the recipe creation request should have included the seasoning-only ingredient 1 Then the recipe creation request should have included the seasoning-only ingredient 1
And the URL should include "/recettes/44" 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 Scenario: The save/cancel bar stays pinned to the foot of a long form
When I visit "/recettes/nouvelle" When I visit "/recettes/nouvelle"
And I add a step And I add a step

View file

@ -1,7 +1,6 @@
import { import {
type CreateRecipeInput, type CreateRecipeInput,
createRecipeSchema, createRecipeSchema,
type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
type Meal, type Meal,
@ -16,7 +15,6 @@ import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client"; import { ApiError, apiClient } from "../../../api/client";
import { makeClientKey } from "../../../lib/client-key"; import { makeClientKey } from "../../../lib/client-key";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { DietTagSelect } from "../badges/DietTagSelect";
import { IngredientPicker } from "../ingredients/IngredientPicker"; import { IngredientPicker } from "../ingredients/IngredientPicker";
import { IngredientRow } from "../ingredients/IngredientRow"; import { IngredientRow } from "../ingredients/IngredientRow";
import { import {
@ -60,8 +58,8 @@ type LoadState = "loading" | "loaded" | "error";
* *
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`, * Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
* structurally the same form as `RecipeFormPage` — same sub-components * structurally the same form as `RecipeFormPage` — same sub-components
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`), * (`IngredientRow`, `IngredientPicker`, `StepListEditor`), same
* same `CreateRecipeInput` submit shape — plus one thing a manual creation * `CreateRecipeInput` submit shape — plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching * never has to handle: ingredient lines the automatic matching
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à * (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
* compléter" list, each needing a real ingredient picked (or the line * compléter" list, each needing a real ingredient picked (or the line
@ -95,7 +93,6 @@ export function RecipeImportForm({
const [loadState, setLoadState] = useState<LoadState>("loading"); const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]); const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]); const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -103,7 +100,6 @@ export function RecipeImportForm({
const [picture, setPicture] = useState(""); const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4"); const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL"); const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]); const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>( const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[], [],
@ -123,14 +119,12 @@ export function RecipeImportForm({
Promise.all([ Promise.all([
apiClient.getIngredients(), apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(), apiClient.getUnits(),
apiClient.previewSourceItem(sourceKey, externalId), apiClient.previewSourceItem(sourceKey, externalId),
]) ])
.then(([ingredients, diets, units, draft]) => { .then(([ingredients, units, draft]) => {
if (cancelled) return; if (cancelled) return;
setIngredientsCatalog(ingredients); setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units); setUnitsCatalog(units);
setName(draft.name); setName(draft.name);
@ -304,7 +298,6 @@ export function RecipeImportForm({
picture: picture.trim() || null, picture: picture.trim() || null,
portions: Number(portions), portions: Number(portions),
visibility, visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
// Free-text lines (nothing in the catalog matched) submit as // Free-text lines (nothing in the catalog matched) submit as
// `placeholderName` so the API creates the placeholder row — same // `placeholderName` so the API creates the placeholder row — same
@ -436,8 +429,6 @@ export function RecipeImportForm({
))} ))}
</select> </select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section"> <section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2> <h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list"> <ul className="recipe-form__ingredient-list">

View file

@ -16,9 +16,10 @@ import {
* fall back to the full review screen (`ImportRecipePage`), pre-filled from * fall back to the full review screen (`ImportRecipePage`), pre-filled from
* this exact same draft, for a person to fill in what's missing. * this exact same draft, for a person to fill in what's missing.
* *
* Default `visibility`/`dietIds` mirror what a person would otherwise leave * Default `visibility` mirrors what a person would otherwise leave
* untouched on that same form (`PERSONAL`, no diet tags) — nothing here is * untouched on that same form (`PERSONAL`) — nothing here is a guess about
* a guess about data the draft doesn't have an opinion on. * 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 * `createRecipeSchema.safeParse` is still the actual authority on whether
* the result is submittable (a positive-portions check, string lengths, * the result is submittable (a positive-portions check, string lengths,
* etc.) — the checks above it exist only for what a schema alone can't * 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, picture: draft.picture,
portions: draft.portions, portions: draft.portions,
visibility: "PERSONAL", visibility: "PERSONAL",
dietIds: [],
ingredients: draft.ingredients.map((line) => ({ ingredients: draft.ingredients.map((line) => ({
// Every line is fully resolved by this point — guarded above. // Every line is fully resolved by this point — guarded above.
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id, ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,

View file

@ -1,7 +1,6 @@
import { import {
type CreateRecipeInput, type CreateRecipeInput,
createRecipeSchema, createRecipeSchema,
type DietView,
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
type RecipeVisibility, type RecipeVisibility,
@ -11,7 +10,6 @@ import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
import { import {
@ -68,7 +66,6 @@ export function RecipeFormPage() {
const [loadState, setLoadState] = useState<LoadState>("loading"); const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]); const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]); const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -80,7 +77,6 @@ export function RecipeFormPage() {
// get past the gate if 4 is already right for their recipe. // get past the gate if 4 is already right for their recipe.
const [portions, setPortions] = useState("4"); const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL"); const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]); const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]); const [steps, setSteps] = useState<StepDraft[]>([]);
@ -93,14 +89,12 @@ export function RecipeFormPage() {
Promise.all([ Promise.all([
apiClient.getIngredients(), apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(), apiClient.getUnits(),
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
]) ])
.then(([ingredients, diets, units, recipe]) => { .then(([ingredients, units, recipe]) => {
if (cancelled) return; if (cancelled) return;
setIngredientsCatalog(ingredients); setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units); setUnitsCatalog(units);
if (recipe) { if (recipe) {
setName(recipe.name); setName(recipe.name);
@ -108,7 +102,6 @@ export function RecipeFormPage() {
setPicture(recipe.picture ?? ""); setPicture(recipe.picture ?? "");
setPortions(String(recipe.portions)); setPortions(String(recipe.portions));
setVisibility(recipe.visibility); setVisibility(recipe.visibility);
setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines( setIngredientLines(
recipe.ingredients.map((line) => ({ recipe.ingredients.map((line) => ({
key: makeClientKey(), key: makeClientKey(),
@ -216,7 +209,6 @@ export function RecipeFormPage() {
picture: picture.trim() || null, picture: picture.trim() || null,
portions: Number(portions), portions: Number(portions),
visibility, visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
// A just-added free-text line has no catalog id yet — ask the API to // A just-added free-text line has no catalog id yet — ask the API to
// create the placeholder row via `placeholderName`. An edit-loaded // create the placeholder row via `placeholderName`. An edit-loaded
@ -335,8 +327,6 @@ export function RecipeFormPage() {
))} ))}
</select> </select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section"> <section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2> <h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list"> <ul className="recipe-form__ingredient-list">

View file

@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js"; export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./logic/planning-portions.js"; export * from "./logic/planning-portions.js";
export * from "./logic/recipe-diets.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/admin.js"; export * from "./schemas/admin.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";

View file

@ -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<IngredientView, "diets" | "allergens" | "isPlaceholder">[],
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;
}

View file

@ -68,8 +68,6 @@ export const createRecipeSchema = z
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */ /** 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"), portions: z.number().int().positive("Le nombre de portions doit être positif"),
visibility: recipeVisibilitySchema.default("PERSONAL"), 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"), 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"), steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
}) })

View file

@ -161,10 +161,12 @@ export interface StepView {
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView}, * the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
* kept consistent so the table's badges match the detail panel's. * 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).
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma), while * `diets` is **computed from the recipe's ingredients**, not viewer-specific
* `diets` is a property of the recipe itself (manually tagged by its * and no longer hand-tagged (issue #131, `RecipeDiet` dropped) — see
* author, not viewer-specific). * {@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 { export interface RecipeSummaryView {
id: number; id: number;