Merge pull request 'feat(recipes): régimes calculés depuis les ingrédients (#131)' (#137) from feat/auto-recipe-diets into main
All checks were successful
All checks were successful
This commit is contained in:
commit
81b4a149ff
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
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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({
|
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 },
|
||||||
|
|
|
||||||
|
|
@ -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" }],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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") },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
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. */
|
/** 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"),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue