feat(recipes): régimes calculés depuis les ingrédients (#131) #137
15 changed files with 413 additions and 263 deletions
|
|
@ -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";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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<{
|
||||
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}. */
|
||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||
const byId = new Map<number, AllergyView>();
|
||||
|
|
@ -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<Prisma.RecipeWhereInput> {
|
||||
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({
|
||||
const allergenWhere: Prisma.RecipeWhereInput =
|
||||
excludedAllergyIds.length > 0
|
||||
? {
|
||||
ingredients: {
|
||||
none: {
|
||||
ingredient: {
|
||||
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
|
||||
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<Prisma.Rec
|
|||
).map((row) => 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<Prisma.Rec
|
|||
export interface ListRecipesFilters {
|
||||
/** Case-insensitive name substring. */
|
||||
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;
|
||||
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
|
||||
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[];
|
||||
}
|
||||
|
||||
|
|
@ -421,31 +445,32 @@ export async function listRecipes(
|
|||
filters: ListRecipesFilters = {},
|
||||
): Promise<RecipeSummaryView[]> {
|
||||
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({
|
||||
const [recipes, referenceDiets] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
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) {
|
||||
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<void
|
|||
where: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} 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`);
|
||||
}
|
||||
} 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;
|
||||
} 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");
|
||||
}
|
||||
} 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) {
|
||||
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) {
|
||||
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<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
|
||||
throw err; // see suitableForHousehold()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
apps/api/test/recipe-diets.test.ts
Normal file
86
apps/api/test/recipe-diets.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<LoadState>("loading");
|
||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
|
@ -103,7 +100,6 @@ export function RecipeImportForm({
|
|||
const [picture, setPicture] = useState("");
|
||||
const [portions, setPortions] = useState("4");
|
||||
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
||||
[],
|
||||
|
|
@ -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({
|
|||
))}
|
||||
</select>
|
||||
|
||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||
|
||||
<section className="recipe-form__section">
|
||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||
<ul className="recipe-form__ingredient-list">
|
||||
|
|
|
|||
|
|
@ -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<typeof line.ingredient>).id,
|
||||
|
|
|
|||
|
|
@ -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<LoadState>("loading");
|
||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||
|
||||
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<RecipeVisibility>("PERSONAL");
|
||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
|
||||
|
|
@ -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() {
|
|||
))}
|
||||
</select>
|
||||
|
||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||
|
||||
<section className="recipe-form__section">
|
||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||
<ul className="recipe-form__ingredient-list">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
71
packages/shared/src/logic/recipe-diets.ts
Normal file
71
packages/shared/src/logic/recipe-diets.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue