batchCooking/apps/api/src/modules/recipe/recipe.service.ts
Nicolas 978fe71a11 feat(recipes): flag les ingrédients faisables maison + suggestion de recherche
Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle
part — confirmé par exploration : zéro usage en dehors de schema.prisma)
par un flag booléen `reproducible`, plus simple : pas de liaison
recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait
maison" plus un raccourci de recherche.

- Migration : drop `alternate_recipe` (colonne + FK), ajoute
  `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`.
- `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`,
  threadé dans le flatten + la réconciliation `seedReferenceData`. Premier
  lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base,
  bouillons/fonds) — même logique que la curation Ciqual : un lot solide
  plutôt qu'exhaustif sur les 546 ingrédients.
- `IngredientView` (shared) + les deux endroits qui la construisent
  (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`.
- `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple
  dans `IngredientPicker` (avec son propre toggle d'affichage), lien
  cliquable dans `IngredientRow` vers `/recettes?search=<nom>` ouvert
  dans un nouvel onglet (pour ne jamais perdre le formulaire de recette
  en cours — pas de persistance de brouillon dans `RecipeFormPage`).
- `RecipesPage` lit `?search=` au montage pour permettre ce deep-link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 19:09:39 +02:00

520 lines
20 KiB
TypeScript

import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type UpdateRecipeInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
/** 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. */
function recipeInclude(viewerId: number) {
return {
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
},
},
steps: { orderBy: { order: "asc" } },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return {
id: ingredient.id,
key: ingredient.key,
icon: ingredient.icon,
category: ingredient.category,
subcategory: ingredient.subcategory,
reproducible: ingredient.reproducible,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
key: allergy.category.key,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
};
}
function toDietView(diet: { id: number; key: string }): DietView {
return { id: diet.id, key: diet.key };
}
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
const byId = new Map<number, AllergyView>();
for (const ingredient of ingredients) {
for (const allergen of ingredient.allergens) {
byId.set(allergen.id, allergen);
}
}
return [...byId.values()];
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
const allergens = aggregateAllergens(
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
);
return {
id: recipe.id,
name: recipe.name,
description: recipe.description,
picture: recipe.picture,
authorId: recipe.authorId,
visibility: recipe.visibility,
allergens,
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
isFavorite: recipe.favoritedBy.length > 0,
};
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient),
quantity: Number(recipeIngredient.quantity),
unit: recipeIngredient.unit,
}));
return {
...toRecipeSummaryView(recipe),
ingredients,
steps: recipe.steps.map((step) => ({
id: step.id,
description: step.description,
picture: step.picture,
order: step.order,
})),
};
}
/**
* True if `viewerId`/`viewerHouseId` may *read* this recipe — the author
* always can, whatever the current visibility (even a `HOUSE` recipe if
* they've since left that household — access to your own creations never
* regresses). Otherwise follows `visibility` as documented on
* `RecipeVisibility` in schema.prisma.
*/
function canView(
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
viewerId: number,
viewerHouseId: number | null,
): boolean {
if (recipe.authorId === viewerId) return true;
if (recipe.visibility === "PUBLIC") return true;
if (recipe.visibility === "HOUSE") {
return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId;
}
return false;
}
/** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */
function visibleToViewerWhere(
viewerId: number,
viewerHouseId: number | null,
): Prisma.RecipeWhereInput {
return {
OR: [
{ authorId: viewerId },
{ visibility: "PUBLIC" },
...(viewerHouseId !== null
? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }]
: []),
],
};
}
/**
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
* and, for every member with a declared regime, are tagged with that
* regime — the planning recipe picker's "convient à tout le foyer" toggle
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* extra query to gather the household's members' allergy/regime ids)
* rather than exposed to the client as raw per-member data: a member's
* allergies/regime are private the same way visibility already keeps a
* recipe's existence private (404, never 403) — nothing here should let
* one member infer another's medical/dietary info from the shape of a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient` —
* the schema already treats disliked ingredients as a taste preference,
* not a safety constraint (see that model's doc comment), so it doesn't
* belong in a filter framed around what's safe/appropriate to serve.
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
});
const requiredDietIds = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
];
const excludedAllergyIds = [
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
];
const conditions: Prisma.RecipeWhereInput[] = [];
if (requiredDietIds.length > 0) {
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } },
},
});
}
return { AND: conditions };
}
/**
* Optional narrowing filters for {@link listRecipes}, on top of the
* mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object
* rather than a growing list of positional optional params now that the
* planning recipe picker adds two more on top of `search`/
* `suitableForHousehold`.
*/
export interface ListRecipesFilters {
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
dietIds?: number[];
}
/**
* The recipes visible to `viewerId` under one catalog tab, alphabetically,
* optionally filtered further (see {@link ListRecipesFilters}). No
* "toutes" tab — every recipe a viewer can see falls under exactly one of
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
* orthogonal, cross-cutting filter on top (and re-applies
* {@link visibleToViewerWhere} in case access to a previously-favorited
* recipe has since changed, e.g. leaving the house that granted it).
*/
export async function listRecipes(
viewerId: number,
viewerHouseId: number | null,
tab: RecipeTab,
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}
// No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing.
if (suitableForHousehold && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
}
if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
switch (tab) {
case "favoris":
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
break;
case "perso":
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
break;
case "foyer":
// No household — nothing can carry this viewer's authorHouseId.
if (viewerHouseId === null) return [];
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
break;
case "publique":
conditions.push({ visibility: "PUBLIC" });
break;
}
const recipes = await prisma.recipe.findMany({
where: { AND: conditions },
include: recipeInclude(viewerId),
orderBy: { name: "asc" },
});
return recipes.map(toRecipeSummaryView);
}
/**
* A single recipe's full detail.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one).
*/
export async function getRecipe(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe);
}
/**
* Creates a recipe with its ingredients, ordered steps and diet tags in one
* go — steps' `order` is derived from their position in `input.steps`,
* ingredients reference existing reference `Ingredient` rows by id (see
* `GET /reference/ingredients`; there's no way to create one here).
* `authorId`/`authorHouseId` are fixed at creation and never change on
* later edits (see {@link updateRecipe}).
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createRecipe(
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertDietsExist(input.dietIds);
const created = await prisma.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
authorId,
authorHouseId,
visibility: input.visibility,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unit: ingredient.unit,
})),
},
steps: {
create: input.steps.map((step, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
include: recipeInclude(authorId),
});
return toRecipeView(created);
}
/**
* Replaces a recipe's whole content — name/description/picture/visibility
* and the complete ingredient/step/diet lists (not a partial merge: a line
* missing from `input` is removed, same contract as `PATCH
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
* never transfers ownership.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function updateRecipe(
id: number,
input: UpdateRecipeInput,
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertDietsExist(input.dietIds);
await prisma.$transaction([
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
prisma.step.deleteMany({ where: { recipeId: id } }),
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
prisma.recipe.update({
where: { id },
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
visibility: input.visibility,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unit: ingredient.unit,
})),
},
steps: {
create: input.steps.map((step, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
}),
]);
return toRecipeView(await findRecipeOrThrow(id, viewerId));
}
/**
* Deletes a recipe outright — its ingredients/steps/diet tags/favorites
* cascade away (see `onDelete: Cascade` in schema.prisma).
*
* @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} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation.
*/
export async function deleteRecipe(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
await assertIsAuthor(id, viewerId, viewerHouseId);
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
if (usedInPlanning) {
throw new HttpError(
409,
ErrorCode.RECIPE_IN_USE,
"Recipe is still used by at least one planning item",
);
}
await prisma.recipe.delete({ where: { id } });
}
/**
* Favorites a recipe for `viewerId` — idempotent (favoriting an
* already-favorited recipe is a no-op, not an error).
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action.
*/
export async function addFavorite(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
await prisma.recipeFavorite.upsert({
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
update: {},
create: { userProfileId: viewerId, recipeId: id },
});
}
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
}
/**
* Guard for other modules that need to confirm a recipe is visible to a
* viewer before referencing it (e.g. `planning.service.ts`'s
* `addPlanningItem`, before creating a `PlanningItem` pointing at it) —
* exported rather than duplicating {@link canView} at the call site.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` — never `403`, same reasoning as `getRecipe`.
*/
export async function assertRecipeVisible(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
}
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
const recipe = await prisma.recipe.findUnique({
where: { id },
include: recipeInclude(viewerId),
});
if (!recipe) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return recipe;
}
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
async function assertIsAuthor(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
if (recipe.authorId !== viewerId) {
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
const uniqueIds = [...new Set(ingredientIds)];
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((ingredient) => ingredient.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(
404,
ErrorCode.INGREDIENT_NOT_FOUND,
`Ingredient(s) not found: ${missing.join(", ")}`,
);
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
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(", ")}`);
}
}