feat(recipes): catalogue v2 - visibilité, favoris, régimes et catalogue d'ingrédients exhaustif

Recipe catalog v2:
- Recipe gagne visibility (PERSONAL/HOUSE/PUBLIC), authorId, authorHouseId
- Favoris par utilisateur (RecipeFavorite), régimes associés (RecipeDiet)
- Aliments "pas aimés" par utilisateur (UserProfileDislikedIngredient),
  distinct des allergies médicales
- API: GET /recipes?tab=favoris|perso|foyer|publique avec contrôle d'accès
  complet, POST/DELETE /recipes/:id/favorite, édition/suppression réservées
  à l'auteur (403 NOT_RECIPE_AUTHOR), GET/PATCH /profile/disliked-ingredients
- Frontend: vue maître-détail (onglets + tableau + panneau détail),
  formulaire enrichi (visibilité, régimes), section préférences pour les
  aliments pas aimés

Catalogue d'ingrédients de référence:
- Extension du seed de 39 à ~430 ingrédients (viandes, poissons/fruits de
  mer, légumes, fruits, féculents, condiments/sauces, épices/herbes, pains
  à sandwich, cuisines italienne/asiatique/mexicaine/maghrébine, liquides
  et boissons de cuisine, bouillons/fonds)
- Chaque ingrédient lié à ses allergènes UE (IngredientAllergy) — les 14
  allergènes réglementaires restent tous couverts
- Seeding optimisé en requêtes groupées (createMany/diff ciblé) plutôt
  qu'un upsert par ligne, pour garder resetDatabase() rapide en test

Tests: 102 tests Mocha + 32 scénarios BDD, tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-18 09:29:11 +02:00
parent 1367f62f32
commit acab18ac4a
43 changed files with 4714 additions and 41 deletions

View file

@ -0,0 +1,59 @@
Feature: Recipe catalog
As a signed-in user
I want to browse, create and manage recipes
So that the household can plan meals from a shared catalog
Scenario: A visitor without a session cannot browse the catalog
When I request the recipe catalog
Then the response status should be 401
And the response error code should be "NOT_AUTHENTICATED"
Scenario: A signed-in user creates a recipe with an ingredient and a step
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I create a recipe named "Ratatouille" with ingredient "Tomate" and step "Couper les légumes"
Then the response status should be 201
And the created recipe should have ingredient "Tomate" and step "Couper les légumes"
Scenario: Creating a recipe with an unknown ingredient is rejected
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
When I create a recipe named "Ratatouille" with unknown ingredient id 999999 and step "Couper les légumes"
Then the response status should be 404
And the response error code should be "INGREDIENT_NOT_FOUND"
Scenario: A signed-in user sees their own recipe in the "perso" tab
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
When I request the recipe catalog tab "perso"
Then the response status should be 200
And the recipe catalog response should include "Ratatouille"
Scenario: A signed-in user favorites a recipe and finds it in the "favoris" tab
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
When I favorite the recipe named "Ratatouille"
And I request the recipe catalog tab "favoris"
Then the response status should be 200
And the recipe catalog response should include "Ratatouille"
Scenario: Only a recipe's author can edit it
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a public recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
When the second user tries to modify the recipe named "Ratatouille"
Then the second user's response status should be 403
And the second user's response error code should be "NOT_RECIPE_AUTHOR"
Scenario: Deleting a recipe still used by a planning item is rejected
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
And my household has a planning that uses the recipe named "Ratatouille"
When I delete the recipe named "Ratatouille"
Then the response status should be 409
And the response error code should be "RECIPE_IN_USE"

View file

@ -28,7 +28,9 @@ Given(
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
const recipe = await prisma.recipe.create({
data: { name: recipeName, authorId: houseRes.body.adminId },
});
const planning = await prisma.planning.create({
data: {
houseId,

View file

@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js";
/** Resolves a reference ingredient by its seeded name — every scenario below names an ingredient by its `reference-seed-data.ts` name, never a raw id. */
async function findIngredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } });
return ingredient.id;
}
When("I request the recipe catalog", async function (this: CustomWorld) {
this.response = await this.agent.get("/recipes");
});
When("I request the recipe catalog tab {string}", async function (this: CustomWorld, tab: string) {
this.response = await this.agent.get("/recipes").query({ tab });
});
Then(
"the recipe catalog response should include {string}",
function (this: CustomWorld, name: string) {
const names = (this.response.body as Array<{ name: string }>).map((recipe) => recipe.name);
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
},
);
When(
"I create a recipe named {string} with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
When(
"I create a recipe named {string} with unknown ingredient id {int} and step {string}",
async function (this: CustomWorld, name: string, unknownIngredientId: number, step: string) {
this.response = await this.agent.post("/recipes").send({
name,
dietIds: [],
ingredients: [{ ingredientId: unknownIngredientId, quantity: 1, unit: "unité" }],
steps: [{ description: step }],
});
},
);
Then(
"the created recipe should have ingredient {string} and step {string}",
function (this: CustomWorld, ingredientName: string, step: string) {
const body = this.response.body as {
ingredients: Array<{ ingredient: { name: string } }>;
steps: Array<{ description: string }>;
};
assert.ok(body.ingredients.some((line) => line.ingredient.name === ingredientName));
assert.ok(body.steps.some((s) => s.description === step));
},
);
// Created directly via Prisma (with a nested ingredient + step), not through
// the API — same rationale as `planning.steps.ts`'s equivalent "already
// exists" step: this is background state the scenario needs in place before
// its actual `When`, not the behavior under test. `authorId` is the
// currently-logged-in agent's own profile — `visibility` defaults to
// `PERSONAL` (schema.prisma), matching a recipe this agent just created for
// themselves.
Given(
"a recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Same as above but `visibility: PUBLIC` — needed for scenarios where a
// *second* user must be able to see (though not necessarily edit) the
// recipe, e.g. the "only the author can edit" scenario: a `PERSONAL`
// recipe would 404 for anyone else before the authorship check even runs
// (see `recipe.service.ts`'s `canView`).
Given(
"a public recipe named {string} already exists with ingredient {string} and step {string}",
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
const ingredientId = await findIngredientId(ingredientName);
const me = await this.agent.get("/auth/me");
await prisma.recipe.create({
data: {
name,
authorId: me.body.id,
visibility: "PUBLIC",
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
steps: { create: [{ description: step, order: 0 }] },
},
});
},
);
// Distinct from `planning.steps.ts`'s "my household has a planning covering
// today with recipe {string}..." — that step always creates a *new* recipe
// row with the given name, which wouldn't exercise the actual `RECIPE_IN_USE`
// check against a recipe this feature already created. This step instead
// looks up the already-existing recipe by name and points the planning item
// at its real id.
Given(
"my household has a planning that uses the recipe named {string}",
async function (this: CustomWorld, recipeName: string) {
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name: recipeName } });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
},
);
When("I delete the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.delete(`/recipes/${recipe.id}`);
});
When("I favorite the recipe named {string}", async function (this: CustomWorld, name: string) {
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.response = await this.agent.post(`/recipes/${recipe.id}/favorite`);
});
When(
"the second user tries to modify the recipe named {string}",
async function (this: CustomWorld, name: string) {
const ingredientId = await findIngredientId("Tomate");
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
this.secondResponse = await this.secondAgent.patch(`/recipes/${recipe.id}`).send({
name,
dietIds: [],
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
steps: [{ description: "Hack" }],
});
},
);

View file

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ingredient_allergy" (
"ingredient_id" INTEGER NOT NULL,
"allergy_id" INTEGER NOT NULL,
CONSTRAINT "ingredient_allergy_pkey" PRIMARY KEY ("ingredient_id","allergy_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ingredients_name_key" ON "ingredients"("name");
-- AddForeignKey
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ingredient_allergy" ADD CONSTRAINT "ingredient_allergy_allergy_id_fkey" FOREIGN KEY ("allergy_id") REFERENCES "allergy"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,56 @@
-- CreateEnum
CREATE TYPE "RecipeVisibility" AS ENUM ('PERSONAL', 'HOUSE', 'PUBLIC');
-- AlterTable
ALTER TABLE "recipe" ADD COLUMN "author_house_id" INTEGER,
ADD COLUMN "author_id" INTEGER NOT NULL,
ADD COLUMN "visibility" "RecipeVisibility" NOT NULL DEFAULT 'PERSONAL';
-- CreateTable
CREATE TABLE "user_profile_disliked_ingredient" (
"user_profile_id" INTEGER NOT NULL,
"ingredient_id" INTEGER NOT NULL,
CONSTRAINT "user_profile_disliked_ingredient_pkey" PRIMARY KEY ("user_profile_id","ingredient_id")
);
-- CreateTable
CREATE TABLE "recipe_favorite" (
"user_profile_id" INTEGER NOT NULL,
"recipe_id" INTEGER NOT NULL,
CONSTRAINT "recipe_favorite_pkey" PRIMARY KEY ("user_profile_id","recipe_id")
);
-- CreateTable
CREATE TABLE "recipe_diet" (
"recipe_id" INTEGER NOT NULL,
"diet_id" INTEGER NOT NULL,
CONSTRAINT "recipe_diet_pkey" PRIMARY KEY ("recipe_id","diet_id")
);
-- AddForeignKey
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_profile_disliked_ingredient" ADD CONSTRAINT "user_profile_disliked_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe" ADD CONSTRAINT "recipe_author_house_id_fkey" FOREIGN KEY ("author_house_id") REFERENCES "house"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe_favorite" ADD CONSTRAINT "recipe_favorite_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "recipe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recipe_diet" ADD CONSTRAINT "recipe_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -24,9 +24,12 @@ model House {
/// member — see `house.service.ts`'s generator for the charset/length.
inviteCode String @unique @map("invite_code")
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
members UserProfile[] @relation("HouseMember")
plannings Planning[]
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
members UserProfile[] @relation("HouseMember")
plannings Planning[]
/// Recipes whose author belonged to this household when they created
/// them — see `Recipe.authorHouseId`.
authoredRecipes Recipe[]
@@map("house")
}
@ -39,7 +42,8 @@ model Diet {
id Int @id @default(autoincrement())
name String @unique
users UserProfile[]
users UserProfile[]
recipes RecipeDiet[]
@@map("diet")
}
@ -70,8 +74,9 @@ model Allergy {
id Int @id @default(autoincrement())
categoryId Int @map("cat_id")
category Category @relation(fields: [categoryId], references: [id])
users UserProfileAllergy[]
category Category @relation(fields: [categoryId], references: [id])
users UserProfileAllergy[]
ingredients IngredientAllergy[]
@@map("allergy")
}
@ -90,19 +95,43 @@ model UserProfile {
houseId Int? @map("house_id")
dietId Int? @map("diet_id")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
allergies UserProfileAllergy[]
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
allergies UserProfileAllergy[]
/// Ingredients this profile personally dislikes — a taste preference, not
/// a medical constraint (see {@link UserProfileDislikedIngredient} and
/// `allergies` above for the distinct medical list).
dislikedIngredients UserProfileDislikedIngredient[]
/// Recipes authored by this profile — see `Recipe.authorId`.
authoredRecipes Recipe[]
/// Recipes this profile has favorited — see {@link RecipeFavorite}.
favoriteRecipes RecipeFavorite[]
/// Households this profile administers. In practice at most one — a
/// profile can only ever belong to (and thus admin) a single household at
/// a time — but Prisma models the admin side of a one-to-many FK as a
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
@@map("user_profiles")
}
/// Explicit join table for the user_profiles <-> ingredient "disliked"
/// association — same shape as `UserProfileAllergy`, but a personal taste
/// preference rather than a medical restriction: not surfaced as a safety
/// warning, just a reminder on a recipe's detail view (see
/// `RecipeView`/`RecipeDetailPanel`, apps/web).
model UserProfileDislikedIngredient {
userProfileId Int @map("user_profile_id")
ingredientId Int @map("ingredient_id")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
@@id([userProfileId, ingredientId])
@@map("user_profile_disliked_ingredient")
}
/// Not in the original spec doc — personalization settings (theme for now,
/// meant to grow), one row per profile, created on demand (see
/// `preferences.service.ts`) rather than at signup — same "absent means the
@ -184,35 +213,115 @@ model Source {
@@map("sources")
}
model Recipe {
id Int @id @default(autoincrement())
name String
sourceId Int? @map("source_id")
description String?
picture String?
/// Not in the original spec doc — who can *read* a recipe. Controls only
/// visibility, never editing: a recipe can only ever be edited/deleted by
/// its `author`, whatever this is set to (see `recipe.service.ts`).
enum RecipeVisibility {
/// Visible to its author only.
PERSONAL
/// Visible to `authorHouseId`'s members (a snapshot of the author's
/// household *at creation time* — see `Recipe.authorHouseId`).
HOUSE
/// Visible to every signed-in user — the "shared catalog" behavior the
/// very first version of this feature shipped with.
PUBLIC
}
model Recipe {
id Int @id @default(autoincrement())
name String
sourceId Int? @map("source_id")
description String?
picture String?
/// Creator — not in the original spec doc, required once recipes carry a
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
authorId Int @map("author_id")
/// The author's household *at the time this recipe was created* — a
/// snapshot (same idea as `Planning.houseId`), not a live lookup: it
/// doesn't follow the author if they later change household. `null` if
/// the author had no household yet.
authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL)
author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
ingredients RecipeIngredient[]
steps Step[]
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
@@map("recipe")
}
/// Explicit join table for the user_profiles <-> recipe "favorited"
/// association — same shape as `UserProfileAllergy`. Per-user, not
/// per-household: two members of the same household can favorite different
/// recipes independently.
model RecipeFavorite {
userProfileId Int @map("user_profile_id")
recipeId Int @map("recipe_id")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@id([userProfileId, recipeId])
@@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")
}
/// `name` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by name and stay
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
/// Ingredients are reference data (like Diet/Allergy): seeded, never
/// created/edited/deleted through the API.
model Ingredient {
id Int @id @default(autoincrement())
name String
name String @unique
icon String?
alternateRecipeId Int? @map("alternate_recipe")
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
recipes RecipeIngredient[]
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
@@map("ingredients")
}
/// Explicit join table for the ingredients <-> allergy association — not in
/// the original spec doc, added so the recipe catalog can surface which
/// allergens an ingredient (and by extension a recipe) carries. Same shape
/// as `UserProfileAllergy`.
model IngredientAllergy {
ingredientId Int @map("ingredient_id")
allergyId Int @map("allergy_id")
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
@@id([ingredientId, allergyId])
@@map("ingredient_allergy")
}
/// recipe <-> ingredients association. The spec documents this as a plain
/// many-to-many, but a shopping list / batch-cooking calculation needs a
/// quantity per recipe, so this join table carries quantity + unit

View file

@ -8,6 +8,7 @@ import { houseRouter } from "./modules/house/house.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js";
/**
@ -31,6 +32,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
// Serves the built frontend (production Docker image only — see

View file

@ -28,6 +28,492 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
{ name: "Mollusques", kind: "ALLERGY" },
];
// A broad pantry list — the goal is to cover the large majority of what a
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
// condiments, épices...), not just enough to exercise the recipe catalog in
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
// `name` is `@unique`, there's no create/update/delete endpoint), so this is
// meant to already be comprehensive at first deploy rather than grown
// piecemeal as recipes need more of it. `allergenNames` reference
// `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is
// covered by at least one ingredient here. `icon` is left unset (rather than
// forcing a misleading emoji) for the handful of items with no good match in
// the standard emoji set (e.g. `Radis`, `Asperge`).
const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[] }> = [
// --- Céréales, farines & féculents ---------------------------------------
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Farine de maïs", icon: "🌽", allergenNames: [] },
{ name: "Farine de sarrasin", icon: "🌾", allergenNames: [] },
{ name: "Farine de riz", icon: "🍚", allergenNames: [] },
{ name: "Semoule", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Couscous", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Boulgour", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Polenta", icon: "🌽", allergenNames: [] },
{ name: "Quinoa", icon: "🌾", allergenNames: [] },
{ name: "Pâtes", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Pâtes complètes", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Riz", icon: "🍚", allergenNames: [] },
{ name: "Riz basmati", icon: "🍚", allergenNames: [] },
{ name: "Riz complet", icon: "🍚", allergenNames: [] },
{ name: "Flocons d'avoine", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Pomme de terre", icon: "🥔", allergenNames: [] },
{ name: "Patate douce", icon: "🍠", allergenNames: [] },
{ name: "Pain", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain de mie", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain complet", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Baguette", icon: "🥖", allergenNames: ["Gluten"] },
{ name: "Pain de seigle", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Chapelure", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Levure boulangère", icon: "🫙", allergenNames: [] },
{ name: "Levure chimique", icon: "🫙", allergenNames: [] },
{ name: "Maïzena", icon: "🫙", allergenNames: [] },
// --- Légumineuses ---------------------------------------------------------
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
{ name: "Pois chiches", icon: "🫘", allergenNames: [] },
{ name: "Haricots blancs", icon: "🫘", allergenNames: [] },
{ name: "Haricots rouges", icon: "🫘", allergenNames: [] },
{ name: "Haricots noirs", icon: "🫘", allergenNames: [] },
{ name: "Pois cassés", icon: "🫘", allergenNames: [] },
{ name: "Fèves", icon: "🫘", allergenNames: [] },
{ name: "Edamame", icon: "🫛", allergenNames: ["Soja"] },
// --- Viandes & volailles ---------------------------------------------------
{ name: "Poulet", icon: "🍗", allergenNames: [] },
{ name: "Dinde", icon: "🍗", allergenNames: [] },
{ name: "Canard", icon: "🦆", allergenNames: [] },
{ name: "Magret de canard", icon: "🦆", allergenNames: [] },
{ name: "Lapin", icon: "🐇", allergenNames: [] },
{ name: "Bœuf haché", icon: "🥩", allergenNames: [] },
{ name: "Steak de bœuf", icon: "🥩", allergenNames: [] },
{ name: "Rôti de bœuf", icon: "🥩", allergenNames: [] },
{ name: "Escalope de veau", icon: "🥩", allergenNames: [] },
{ name: "Filet mignon de porc", icon: "🥩", allergenNames: [] },
{ name: "Côte de porc", icon: "🥩", allergenNames: [] },
{ name: "Agneau", icon: "🍖", allergenNames: [] },
{ name: "Gigot d'agneau", icon: "🍖", allergenNames: [] },
{ name: "Lardons", icon: "🥓", allergenNames: [] },
{ name: "Bacon", icon: "🥓", allergenNames: [] },
{ name: "Jambon blanc", icon: "🍖", allergenNames: [] },
{ name: "Jambon cru", icon: "🍖", allergenNames: [] },
{ name: "Saucisse", icon: "🌭", allergenNames: [] },
{ name: "Chorizo", icon: "🌭", allergenNames: [] },
{ name: "Merguez", icon: "🌭", allergenNames: [] },
// --- Poissons & fruits de mer -----------------------------------------------
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Truite", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Sardine", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Anchois", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Merlan", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Surimi", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Bar (loup de mer)", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Dorade", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Sole", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Turbot", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Merlu", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Colin", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Lieu noir", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Églefin", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Maquereau", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Hareng", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Rouget", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Raie", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Lotte", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Flétan", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Espadon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Carpe", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Brochet", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Perche", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Tilapia", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Panga", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Saumon fumé", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Crevettes", icon: "🍤", allergenNames: ["Crustacés"] },
{ name: "Langoustines", icon: "🍤", allergenNames: ["Crustacés"] },
{ name: "Homard", icon: "🦞", allergenNames: ["Crustacés"] },
{ name: "Crabe", icon: "🦀", allergenNames: ["Crustacés"] },
{ name: "Langouste", icon: "🦞", allergenNames: ["Crustacés"] },
{ name: "Moules", icon: "🦪", allergenNames: ["Mollusques"] },
{ name: "Huîtres", icon: "🦪", allergenNames: ["Mollusques"] },
{ name: "Saint-Jacques", icon: "🦪", allergenNames: ["Mollusques"] },
{ name: "Calamar", icon: "🦑", allergenNames: ["Mollusques"] },
{ name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] },
{ name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] },
{ name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] },
// --- Produits laitiers & œufs -----------------------------------------------
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
{ name: "Beurre", icon: "🧈", allergenNames: ["Lait"] },
{ name: "Crème fraîche", icon: "🥛", allergenNames: ["Lait"] },
{ name: "Crème liquide", icon: "🥛", allergenNames: ["Lait"] },
{ name: "Fromage", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Emmental", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Gruyère", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Parmesan", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Mozzarella", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Chèvre (fromage)", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Feta", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Comté", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] },
{ name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] },
{ name: "Lait de coco", icon: "🥥", allergenNames: [] },
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
// --- Légumes -----------------------------------------------------------------
{ name: "Tomate", icon: "🍅", allergenNames: [] },
{ name: "Oignon", icon: "🧅", allergenNames: [] },
{ name: "Échalote", icon: "🧅", allergenNames: [] },
{ name: "Ail", icon: "🧄", allergenNames: [] },
{ name: "Carotte", icon: "🥕", allergenNames: [] },
{ name: "Courgette", icon: "🥒", allergenNames: [] },
{ name: "Concombre", icon: "🥒", allergenNames: [] },
{ name: "Cornichons", icon: "🥒", allergenNames: [] },
{ name: "Poivron", icon: "🫑", allergenNames: [] },
{ name: "Champignon", icon: "🍄", allergenNames: [] },
{ name: "Cèpes", icon: "🍄", allergenNames: [] },
{ name: "Aubergine", icon: "🍆", allergenNames: [] },
{ name: "Brocoli", icon: "🥦", allergenNames: [] },
{ name: "Chou-fleur", icon: "🥦", allergenNames: [] },
{ name: "Chou blanc", icon: "🥬", allergenNames: [] },
{ name: "Chou rouge", icon: "🥬", allergenNames: [] },
{ name: "Chou de Bruxelles", icon: "🥬", allergenNames: [] },
{ name: "Épinard", icon: "🥬", allergenNames: [] },
{ name: "Blette", icon: "🥬", allergenNames: [] },
{ name: "Salade", icon: "🥬", allergenNames: [] },
{ name: "Roquette", icon: "🥬", allergenNames: [] },
{ name: "Cresson", icon: "🥬", allergenNames: [] },
{ name: "Poireau", icon: "🥬", allergenNames: [] },
{ name: "Céleri", icon: "🥬", allergenNames: ["Céleri"] },
{ name: "Radis", allergenNames: [] },
{ name: "Betterave", allergenNames: [] },
{ name: "Navet", allergenNames: [] },
{ name: "Panais", allergenNames: [] },
{ name: "Haricot vert", icon: "🫛", allergenNames: [] },
{ name: "Petit pois", icon: "🫛", allergenNames: [] },
{ name: "Maïs", icon: "🌽", allergenNames: [] },
{ name: "Artichaut", allergenNames: [] },
{ name: "Fenouil", allergenNames: [] },
{ name: "Endive", allergenNames: [] },
{ name: "Potiron", icon: "🎃", allergenNames: [] },
{ name: "Butternut", icon: "🎃", allergenNames: [] },
{ name: "Asperge", allergenNames: [] },
{ name: "Avocat", icon: "🥑", allergenNames: [] },
// --- Fruits ------------------------------------------------------------------
{ name: "Citron", icon: "🍋", allergenNames: [] },
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
{ name: "Pomme", icon: "🍎", allergenNames: [] },
{ name: "Poire", icon: "🍐", allergenNames: [] },
{ name: "Banane", icon: "🍌", allergenNames: [] },
{ name: "Orange", icon: "🍊", allergenNames: [] },
{ name: "Clémentine", icon: "🍊", allergenNames: [] },
{ name: "Pamplemousse", icon: "🍊", allergenNames: [] },
{ name: "Fraise", icon: "🍓", allergenNames: [] },
{ name: "Framboise", icon: "🍓", allergenNames: [] },
{ name: "Myrtille", icon: "🫐", allergenNames: [] },
{ name: "Mûre", icon: "🫐", allergenNames: [] },
{ name: "Cerise", icon: "🍒", allergenNames: [] },
{ name: "Abricot", icon: "🍑", allergenNames: [] },
{ name: "Pêche", icon: "🍑", allergenNames: [] },
{ name: "Prune", allergenNames: [] },
{ name: "Raisin", icon: "🍇", allergenNames: [] },
{ name: "Melon", icon: "🍈", allergenNames: [] },
{ name: "Pastèque", icon: "🍉", allergenNames: [] },
{ name: "Ananas", icon: "🍍", allergenNames: [] },
{ name: "Mangue", icon: "🥭", allergenNames: [] },
{ name: "Kiwi", icon: "🥝", allergenNames: [] },
{ name: "Figue", allergenNames: [] },
{ name: "Datte", allergenNames: [] },
{ name: "Litchi", allergenNames: [] },
{ name: "Grenade", allergenNames: [] },
{ name: "Rhubarbe", allergenNames: [] },
{ name: "Coing", allergenNames: [] },
// --- Fruits secs & oléagineux ------------------------------------------------
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Noisettes", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Noix de cajou", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Pistaches", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Noix de pécan", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Poudre d'amande", icon: "🌰", allergenNames: ["Fruits à coque"] },
{ name: "Pignons de pin", icon: "🌰", allergenNames: [] },
{ name: "Graines de tournesol", icon: "🌻", allergenNames: [] },
{ name: "Graines de courge", allergenNames: [] },
{ name: "Noix de coco râpée", icon: "🥥", allergenNames: [] },
{ name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] },
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
{ name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] },
// --- Condiments, sauces & huiles ----------------------------------------------
{ name: "Sel", icon: "🧂", allergenNames: [] },
{ name: "Sucre", icon: "🍬", allergenNames: [] },
{ name: "Huile d'olive", icon: "🫒", allergenNames: [] },
{ name: "Huile de tournesol", icon: "🧴", allergenNames: [] },
{ name: "Huile de colza", icon: "🧴", allergenNames: [] },
{ name: "Huile de coco", icon: "🥥", allergenNames: [] },
{ name: "Huile de sésame", icon: "🧴", allergenNames: ["Graines de sésame"] },
{ name: "Vinaigre de cidre", icon: "🧴", allergenNames: [] },
{ name: "Vinaigre blanc", icon: "🧴", allergenNames: [] },
{ name: "Vinaigre balsamique", icon: "🧴", allergenNames: ["Sulfites"] },
{ name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] },
{ name: "Tofu", icon: "🧊", allergenNames: ["Soja"] },
{ name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] },
{ name: "Ketchup", icon: "🫙", allergenNames: [] },
{ name: "Miel", icon: "🍯", allergenNames: [] },
{ name: "Sirop d'érable", icon: "🍁", allergenNames: [] },
{ name: "Câpres", allergenNames: [] },
{ name: "Olives", icon: "🫒", allergenNames: [] },
{ name: "Tabasco", icon: "🌶️", allergenNames: [] },
{ name: "Sauce Worcestershire", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Wasabi", allergenNames: [] },
{ name: "Harissa", icon: "🌶️", allergenNames: [] },
{ name: "Pâte de curry", icon: "🍛", allergenNames: [] },
{ name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Concentré de tomate", icon: "🍅", allergenNames: [] },
{ name: "Coulis de tomate", icon: "🍅", allergenNames: [] },
{ name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] },
{ name: "Vin blanc (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
{ name: "Vin rouge (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de vin rouge", icon: "🧴", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de vin blanc", icon: "🧴", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de xérès", icon: "🧴", allergenNames: ["Sulfites"] },
{ name: "Huile de noix", icon: "🧴", allergenNames: ["Fruits à coque"] },
{ name: "Huile de noisette", icon: "🧴", allergenNames: ["Fruits à coque"] },
{ name: "Huile d'arachide", icon: "🧴", allergenNames: ["Arachides"] },
{ name: "Huile pimentée", icon: "🌶️", allergenNames: [] },
{ name: "Beurre de cacahuète", icon: "🥜", allergenNames: ["Arachides"] },
{ name: "Farine de lupin", icon: "🌱", allergenNames: ["Lupin"] },
{ name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Sauce barbecue", icon: "🫙", allergenNames: [] },
{ name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] },
{ name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] },
{ name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
{ name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
{ name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] },
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
{ name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] },
{ name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
// --- Épices & herbes -----------------------------------------------------------
{ name: "Basilic", icon: "🌿", allergenNames: [] },
{ name: "Persil", icon: "🌿", allergenNames: [] },
{ name: "Thym", icon: "🌿", allergenNames: [] },
{ name: "Romarin", icon: "🌿", allergenNames: [] },
{ name: "Laurier", icon: "🌿", allergenNames: [] },
{ name: "Ciboulette", icon: "🌿", allergenNames: [] },
{ name: "Coriandre fraîche", icon: "🌿", allergenNames: [] },
{ name: "Menthe", icon: "🌿", allergenNames: [] },
{ name: "Origan", icon: "🌿", allergenNames: [] },
{ name: "Aneth", icon: "🌿", allergenNames: [] },
{ name: "Estragon", icon: "🌿", allergenNames: [] },
{ name: "Herbes de Provence", icon: "🌿", allergenNames: [] },
{ name: "Poivre noir", allergenNames: [] },
{ name: "Paprika", icon: "🌶️", allergenNames: [] },
{ name: "Piment d'Espelette", icon: "🌶️", allergenNames: [] },
{ name: "Piment de Cayenne", icon: "🌶️", allergenNames: [] },
{ name: "Cumin", icon: "🌿", allergenNames: [] },
{ name: "Curry (poudre)", icon: "🌿", allergenNames: [] },
{ name: "Curcuma", icon: "🌿", allergenNames: [] },
{ name: "Cannelle", icon: "🌿", allergenNames: [] },
{ name: "Gingembre", icon: "🫚", allergenNames: [] },
{ name: "Muscade", icon: "🌿", allergenNames: [] },
{ name: "Safran", icon: "🌿", allergenNames: [] },
{ name: "Clou de girofle", icon: "🌿", allergenNames: [] },
{ name: "Vanille (gousse)", icon: "🌿", allergenNames: [] },
{ name: "Poivre blanc", allergenNames: [] },
{ name: "Poivre rose", allergenNames: [] },
{ name: "Poivre du Sichuan", allergenNames: [] },
{ name: "Paprika fumé", icon: "🌶️", allergenNames: [] },
{ name: "Piment oiseau", icon: "🌶️", allergenNames: [] },
{ name: "Baies de genièvre", allergenNames: [] },
{ name: "Anis étoilé (badiane)", icon: "🌿", allergenNames: [] },
{ name: "Anis vert", icon: "🌿", allergenNames: [] },
{ name: "Graines de fenouil", icon: "🌿", allergenNames: [] },
{ name: "Sarriette", icon: "🌿", allergenNames: [] },
{ name: "Marjolaine", icon: "🌿", allergenNames: [] },
{ name: "Sauge", icon: "🌿", allergenNames: [] },
{ name: "Cerfeuil", icon: "🌿", allergenNames: [] },
{ name: "Sumac", allergenNames: [] },
{ name: "Nigelle", allergenNames: [] },
{ name: "Quatre épices", icon: "🌿", allergenNames: [] },
{ name: "Colombo (poudre)", icon: "🌿", allergenNames: [] },
{ name: "Baharat", icon: "🌿", allergenNames: [] },
{ name: "Raifort", allergenNames: [] },
{ name: "Sel aux herbes", icon: "🧂", allergenNames: [] },
{ name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] },
{ name: "Fleur de sel", icon: "🧂", allergenNames: [] },
// --- Sucre & pâtisserie ---------------------------------------------------------
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
{ name: "Chocolat noir", icon: "🍫", allergenNames: [] },
{ name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] },
{ name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] },
{ name: "Pépites de chocolat", icon: "🍫", allergenNames: [] },
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
// --- Cuisine italienne ---------------------------------------------------------
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Riz arborio", icon: "🍚", allergenNames: [] },
{ name: "Burrata", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Prosciutto", icon: "🍖", allergenNames: [] },
{ name: "Pancetta", icon: "🥓", allergenNames: [] },
{ name: "Mortadelle", icon: "🍖", allergenNames: [] },
{ name: "Salami", icon: "🍖", allergenNames: [] },
{ name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
// --- Cuisine asiatique (chinoise, japonaise, thaïe, coréenne, indienne...) ------
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
{ name: "Sauce sweet chili", icon: "🫙", allergenNames: [] },
{ name: "Vinaigre de riz", icon: "🧴", allergenNames: [] },
{ name: "Mirin", icon: "🍶", allergenNames: [] },
{ name: "Saké (cuisine)", icon: "🍶", allergenNames: [] },
{ name: "Miso", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Tofu soyeux", icon: "🧊", allergenNames: ["Soja"] },
{ name: "Graines de sésame", icon: "🌱", allergenNames: ["Graines de sésame"] },
{ name: "Nouilles de riz", icon: "🍜", allergenNames: [] },
{ name: "Nouilles udon", icon: "🍜", allergenNames: ["Gluten"] },
{ name: "Nouilles soba", icon: "🍜", allergenNames: ["Gluten"] },
{ name: "Nouilles chinoises", icon: "🍜", allergenNames: ["Gluten"] },
{ name: "Vermicelles de riz", icon: "🍜", allergenNames: [] },
{ name: "Vermicelles de soja", icon: "🍜", allergenNames: [] },
{ name: "Riz gluant", icon: "🍚", allergenNames: [] },
{ name: "Riz à sushi", icon: "🍚", allergenNames: [] },
{ name: "Riz jasmin", icon: "🍚", allergenNames: [] },
{ name: "Citronnelle", icon: "🌿", allergenNames: [] },
{ name: "Combava", icon: "🌿", allergenNames: [] },
{ name: "Pak-choï", icon: "🥬", allergenNames: [] },
{ name: "Germes de soja", icon: "🌱", allergenNames: ["Soja"] },
{ name: "Shiitake", icon: "🍄", allergenNames: [] },
{ name: "Champignons noirs", icon: "🍄", allergenNames: [] },
{ name: "Daikon", allergenNames: [] },
{ name: "Algue nori", icon: "🌿", allergenNames: [] },
{ name: "Algue wakamé", icon: "🌿", allergenNames: [] },
{ name: "Algue kombu", icon: "🌿", allergenNames: [] },
{ name: "Cinq épices", icon: "🌿", allergenNames: [] },
{ name: "Garam masala", icon: "🌿", allergenNames: [] },
{ name: "Graines de coriandre", icon: "🌿", allergenNames: [] },
{ name: "Cardamome", icon: "🌿", allergenNames: [] },
{ name: "Fenugrec", icon: "🌿", allergenNames: [] },
{ name: "Piment vert frais", icon: "🌶️", allergenNames: [] },
{ name: "Farine de tapioca", icon: "🌾", allergenNames: [] },
{ name: "Sucre de palme", icon: "🍬", allergenNames: [] },
{ name: "Pousses de bambou", allergenNames: [] },
{ name: "Châtaignes d'eau", allergenNames: [] },
{ name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
// --- Cuisine mexicaine -----------------------------------------------------------
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
{ name: "Haricots pinto", icon: "🫘", allergenNames: [] },
{ name: "Piment jalapeño", icon: "🌶️", allergenNames: [] },
{ name: "Piment chipotle", icon: "🌶️", allergenNames: [] },
{ name: "Piment poblano", icon: "🌶️", allergenNames: [] },
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
// --- Maghreb & Moyen-Orient --------------------------------------------------------
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
{ name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] },
// --- Pains & sandwichs ---------------------------------------------------------------
// `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are
// seeded further up (Céréales, farines & féculents) — this section covers
// the shapes specifically reached for to build a sandwich, plus a few
// international flatbreads not tied to one cuisine section above.
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
{ name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] },
{ name: "Naan", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain viennois", icon: "🍞", allergenNames: ["Gluten", "Lait"] },
{ name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain suédois", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain sans gluten", icon: "🍞", allergenNames: [] },
{ name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] },
// --- Épicerie divers ---------------------------------------------------------------
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
// --- Liquides & boissons de cuisine -------------------------------------------
{ name: "Eau", icon: "💧", allergenNames: [] },
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
{ name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] },
{ name: "Eau de rose", icon: "💧", allergenNames: [] },
{ name: "Jus de citron", icon: "🧃", allergenNames: [] },
{ name: "Jus de citron vert", icon: "🧃", allergenNames: [] },
{ name: "Jus d'orange", icon: "🧃", allergenNames: [] },
{ name: "Jus de pomme", icon: "🧃", allergenNames: [] },
{ name: "Jus de raisin", icon: "🧃", allergenNames: [] },
{ name: "Jus de tomate", icon: "🧃", allergenNames: [] },
{ name: "Jus de cranberry", icon: "🧃", allergenNames: [] },
{ name: "Café", icon: "☕", allergenNames: [] },
{ name: "Thé", icon: "🍵", allergenNames: [] },
{ name: "Bière (cuisine)", icon: "🍺", allergenNames: ["Gluten"] },
{ name: "Cidre (cuisine)", allergenNames: ["Sulfites"] },
{ name: "Champagne / vin pétillant (cuisine)", icon: "🥂", allergenNames: ["Sulfites"] },
{ name: "Porto (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
{ name: "Vin jaune (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
{ name: "Cognac", icon: "🥃", allergenNames: [] },
{ name: "Rhum", icon: "🥃", allergenNames: [] },
{ name: "Whisky", icon: "🥃", allergenNames: [] },
{ name: "Vodka", icon: "🥃", allergenNames: [] },
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
];
/**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data upserts by
@ -58,4 +544,60 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
await prisma.allergy.create({ data: { categoryId: category.id } });
}
}
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
// hundred entries long, and `seedReferenceData` re-runs on every single
// test's `resetDatabase()` — a per-row round trip made the whole suite
// measurably slower). Bulk-create whatever's missing in one query, then
// reconcile `icon` only for the rows where it actually changed — on a
// freshly-truncated table (the common test-suite case) that's zero
// updates, on a real re-deploy it's however many icons were edited in
// code since the last deploy, never the full list.
const existingIngredients = await prisma.ingredient.findMany({
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
select: { id: true, name: true, icon: true },
});
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
if (missingIngredients.length > 0) {
await prisma.ingredient.createMany({
data: missingIngredients.map(({ name, icon }) => ({ name, icon })),
});
}
const changedIcons = INGREDIENTS.filter((i) => {
const existing = existingByName.get(i.name);
return existing && existing.icon !== (i.icon ?? null);
});
for (const { name, icon } of changedIcons) {
await prisma.ingredient.update({ where: { name }, data: { icon } });
}
// Re-resolve every ingredient's id (existing + just-created) and every
// allergy's id (by its category name) once, then link them in a single
// bulk insert — same "re-derived every time, not upserted per link"
// reasoning as before for `IngredientAllergy` (it has no natural per-row
// identity to upsert against), just batched instead of looped.
const allIngredients = await prisma.ingredient.findMany({
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
select: { id: true, name: true },
});
const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id]));
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id]));
const links: Array<{ ingredientId: number; allergyId: number }> = [];
for (const { name, allergenNames } of INGREDIENTS) {
const ingredientId = ingredientIdByName.get(name);
if (ingredientId === undefined) continue;
for (const allergenName of allergenNames) {
const allergyId = allergyIdByCategoryName.get(allergenName);
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
}
}
if (links.length > 0) {
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
}
}

View file

@ -1,8 +1,18 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
import {
updateAllergiesSchema,
updateDietSchema,
updateDislikedIngredientsSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
import {
getAllergyIds,
getDislikedIngredientIds,
updateAllergies,
updateDiet,
updateDislikedIngredients,
} from "./profile.service.js";
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router();
@ -37,3 +47,26 @@ profileRouter.patch(
res.status(200).json(allergyIds);
}),
);
profileRouter.get(
"/disliked-ingredients",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const ids = await getDislikedIngredientIds(res.locals.userProfile.id);
res.status(200).json(ids);
}),
);
/** Personal taste preference — distinct from `/allergies`, which is medical. Managed from `/parametres/preferences` (see `PreferencesPage.tsx`). */
profileRouter.patch(
"/disliked-ingredients",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = updateDislikedIngredientsSchema.parse(req.body);
const ids = await updateDislikedIngredients(
res.locals.userProfile.id,
input.dislikedIngredientIds,
);
res.status(200).json(ids);
}),
);

View file

@ -74,3 +74,49 @@ export async function updateAllergies(
return allergyIds;
}
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
const rows = await prisma.userProfileDislikedIngredient.findMany({
where: { userProfileId },
select: { ingredientId: true },
});
return rows.map((row) => row.ingredientId);
}
/**
* Replaces a profile's full disliked-ingredient set (not a merge the
* caller sends the complete list every time, same contract as
* {@link updateAllergies}).
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference `Ingredient` row.
*/
export async function updateDislikedIngredients(
userProfileId: number,
dislikedIngredientIds: number[],
): Promise<number[]> {
if (dislikedIngredientIds.length > 0) {
const found = await prisma.ingredient.findMany({
where: { id: { in: dislikedIngredientIds } },
select: { id: true },
});
const foundIds = new Set(found.map((ingredient) => ingredient.id));
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.INGREDIENT_NOT_FOUND,
`Unknown ingredient id(s): ${missing.join(", ")}`,
);
}
}
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
}),
]);
return dislikedIngredientIds;
}

View file

@ -0,0 +1,104 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
createRecipeSchema,
listRecipesSchema,
updateRecipeSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import {
addFavorite,
createRecipe,
deleteRecipe,
getRecipe,
listRecipes,
removeFavorite,
updateRecipe,
} from "./recipe.service.js";
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
export const recipeRouter = Router();
/** Parses and validates an `:id` route param, shared by every route below that targets one recipe. */
function parseRecipeId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
}
return id;
}
recipeRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = listRecipesSchema.parse(req.query);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await listRecipes(viewerId, houseId, input.tab, input.search));
}),
);
recipeRouter.get(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await getRecipe(id, viewerId, houseId));
}),
);
recipeRouter.post(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = createRecipeSchema.parse(req.body);
const { id: authorId, houseId } = res.locals.userProfile;
res.status(201).json(await createRecipe(input, authorId, houseId));
}),
);
recipeRouter.patch(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const input = updateRecipeSchema.parse(req.body);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await updateRecipe(id, input, viewerId, houseId));
}),
);
recipeRouter.delete(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
await deleteRecipe(id, viewerId, houseId);
res.status(204).end();
}),
);
recipeRouter.post(
"/:id/favorite",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
await addFavorite(id, viewerId, houseId);
res.status(204).end();
}),
);
recipeRouter.delete(
"/:id/favorite",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
await removeFavorite(id, res.locals.userProfile.id);
res.status(204).end();
}),
);

View file

@ -0,0 +1,415 @@
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 } } } } },
},
},
},
steps: { orderBy: { order: "asc" } },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
return {
id: ingredient.id,
name: ingredient.name,
icon: ingredient.icon,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
})),
};
}
function toDietView(diet: { id: number; name: string }): DietView {
return { id: diet.id, name: diet.name };
}
/** 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 }]
: []),
],
};
}
/**
* The recipes visible to `viewerId` under one catalog tab, alphabetically,
* optionally filtered further by a case-insensitive name substring. 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,
search?: string,
): Promise<RecipeSummaryView[]> {
const conditions: Prisma.RecipeWhereInput[] = [];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}
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 } });
}
/** 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(", ")}`);
}
}

View file

@ -1,13 +1,15 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { getAllergies, getDiets } from "./reference.service.js";
import { getAllergies, getDiets, getIngredients } from "./reference.service.js";
/**
* Router mounted at `/reference` in app.ts. Both routes are deliberately
* Router mounted at `/reference` in app.ts. Every route is deliberately
* public (no `requireAuth`) this is static reference data, not
* per-household state, and the signup wizard (household/regime/allergen
* steps) needs to read it before an account and therefore a session
* exists.
* exists. `/ingredients` follows the same reasoning even though it's only
* consumed post-login (the recipe catalog) it's still non-administrable
* reference data, no reason to require a session to read it.
*/
export const referenceRouter = Router();
@ -24,3 +26,10 @@ referenceRouter.get(
res.status(200).json(await getAllergies());
}),
);
referenceRouter.get(
"/ingredients",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getIngredients());
}),
);

View file

@ -1,4 +1,4 @@
import type { AllergyView, DietView } from "@batch-cooking/shared";
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
@ -23,3 +23,28 @@ export async function getAllergies(): Promise<AllergyView[]> {
kind: allergy.category.kind,
}));
}
/**
* All reference ingredients, alphabetically, each resolved to its allergens
* (see `IngredientAllergy` in schema.prisma) same aplattening approach as
* {@link getAllergies}. Ingredients with no linked allergen come back with
* `allergens: []`.
*/
export async function getIngredients(): Promise<IngredientView[]> {
const ingredients = await prisma.ingredient.findMany({
include: {
allergies: { include: { allergy: { include: { category: true } } } },
},
orderBy: { name: "asc" },
});
return ingredients.map((ingredient) => ({
id: ingredient.id,
name: ingredient.name,
icon: ingredient.icon,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
})),
}));
}

View file

@ -97,7 +97,9 @@ describe("Planning", () => {
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: houseRes.body.adminId },
});
const planning = await prisma.planning.create({
data: {
houseId,
@ -145,7 +147,9 @@ describe("Planning", () => {
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
const recipe = await prisma.recipe.create({
data: { name: "Curry de lentilles", authorId: houseRes.body.adminId },
});
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
data: {

View file

@ -125,4 +125,64 @@ describe("Profile", () => {
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND);
});
});
describe("GET /profile/disliked-ingredients + PATCH /profile/disliked-ingredients", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const getRes = await request(app).get("/profile/disliked-ingredients");
const patchRes = await request(app)
.patch("/profile/disliked-ingredients")
.send({ dislikedIngredientIds: [] });
expect(getRes.status).to.equal(401);
expect(patchRes.status).to.equal(401);
});
it("starts empty, then reflects a saved selection", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } });
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } });
const initial = await agent.get("/profile/disliked-ingredients");
expect(initial.body).to.deep.equal([]);
const patchRes = await agent
.patch("/profile/disliked-ingredients")
.send({ dislikedIngredientIds: [tomate.id, oignon.id] });
expect(patchRes.status).to.equal(200);
expect(patchRes.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
const refetch = await agent.get("/profile/disliked-ingredients");
expect(refetch.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
});
it("replaces (not merges) the previous selection", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } });
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } });
await agent
.patch("/profile/disliked-ingredients")
.send({ dislikedIngredientIds: [tomate.id] });
await agent
.patch("/profile/disliked-ingredients")
.send({ dislikedIngredientIds: [oignon.id] });
const res = await agent.get("/profile/disliked-ingredients");
expect(res.body).to.deep.equal([oignon.id]);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent
.patch("/profile/disliked-ingredients")
.send({ dislikedIngredientIds: [999_999] });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
});
});

View file

@ -0,0 +1,526 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` name. */
async function ingredientId(name: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } });
return ingredient.id;
}
describe("Recipes", () => {
const app = createApp();
/** Signs up a fresh profile and returns both its session `agent` and profile id — most tests below need the id for `authorId` on directly-created fixture rows. */
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /recipes", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/recipes").query({ tab: "publique" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a missing ?tab= with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes");
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty catalog when no recipe exists yet", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("filters the catalog by name when ?search= is given", async () => {
const { agent, profileId } = await signup();
await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" },
});
await prisma.recipe.create({
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC" },
});
const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });
expect(res.status).to.equal(200);
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Ratatouille"]);
});
it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
const { agent, profileId } = await signup();
const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } });
await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } });
const res = await agent.get("/recipes").query({ tab: "perso" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["La mienne"]);
});
it("foyer tab only returns HOUSE recipes authored within the viewer's current house", async () => {
const { agent, profileId } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const { profileId: otherId } = await signup();
const otherHouseRes = await request.agent(app).post("/house").send({ name: "Chez un autre" });
await prisma.recipe.create({
data: {
name: "Recette du foyer",
authorId: profileId,
visibility: "HOUSE",
authorHouseId: houseRes.body.id,
},
});
await prisma.recipe.create({
data: {
name: "Recette d'un autre foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: otherHouseRes.body.id,
},
});
const res = await agent.get("/recipes").query({ tab: "foyer" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Recette du foyer"]);
});
it("foyer tab is empty when the viewer has no household", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes").query({ tab: "foyer" });
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("favoris tab only returns recipes the viewer has favorited", async () => {
const { agent, profileId } = await signup();
const favorited = await prisma.recipe.create({
data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC" },
});
await prisma.recipe.create({
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC" },
});
await agent.post(`/recipes/${favorited.id}/favorite`);
const res = await agent.get("/recipes").query({ tab: "favoris" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Favorite"]);
});
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body).to.deep.equal([]);
});
});
describe("POST /recipes", () => {
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const oeuf = await ingredientId("Œuf");
const vegetarien = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } });
const res = await agent.post("/recipes").send({
name: "Omelette provençale",
description: "Rapide et savoureuse",
dietIds: [vegetarien.id],
ingredients: [
{ ingredientId: tomate, quantity: 2, unit: "unité" },
{ ingredientId: oeuf, quantity: 3, unit: "unité" },
],
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
});
expect(res.status).to.equal(201);
expect(res.body.name).to.equal("Omelette provençale");
expect(res.body.ingredients).to.have.length(2);
expect(
res.body.steps.map((s: { description: string; order: number }) => s.order),
).to.deep.equal([0, 1]);
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
expect(res.body.allergens.map((a: { name: string }) => a.name)).to.include("Œufs");
expect(res.body.diets.map((d: { name: string }) => d.name)).to.deep.equal(["Végétarien"]);
});
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const tomate = await ingredientId("Tomate");
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.body.visibility).to.equal("PERSONAL");
// authorHouseId isn't in the API response, but the "foyer" tab
// proves it was stamped — a HOUSE recipe created next should show up.
const houseRecipe = await agent.post("/recipes").send({
name: "Foyer",
visibility: "HOUSE",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
expect(foyerRes.body.map((r: { id: number }) => r.id)).to.include(houseRecipe.body.id);
expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent } = await signup();
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const res = await agent.post("/recipes").send({
name: "Test",
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const res = await agent
.post("/recipes")
.send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /recipes/:id", () => {
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const res = await agent.get("/recipes/999999");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("returns the full recipe detail", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.get(`/recipes/${created.body.id}`);
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade");
expect(res.body.ingredients[0].ingredient.name).to.equal("Tomate");
expect(res.body.isFavorite).to.equal(false);
});
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.get(`/recipes/${recipe.id}`);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("returns 200 for a PUBLIC recipe belonging to someone else", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC" },
});
const res = await agent.get(`/recipes/${recipe.id}`);
expect(res.status).to.equal(200);
});
it("returns 200 for a HOUSE recipe shared with the viewer's household, 404 otherwise", async () => {
const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const { profileId: otherId } = await signup();
const inHouse = await prisma.recipe.create({
data: {
name: "Du foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: houseRes.body.id,
},
});
const otherHouseId = (
await prisma.house.create({
data: { name: "Autre", adminId: otherId, inviteCode: "TESTHOUS" },
})
).id;
const outsideHouse = await prisma.recipe.create({
data: {
name: "D'un autre foyer",
authorId: otherId,
visibility: "HOUSE",
authorHouseId: otherHouseId,
},
});
const inHouseRes = await agent.get(`/recipes/${inHouse.id}`);
const outsideHouseRes = await agent.get(`/recipes/${outsideHouse.id}`);
expect(inHouseRes.status).to.equal(200);
expect(outsideHouseRes.status).to.equal(404);
});
});
describe("PATCH /recipes/:id", () => {
it("replaces the recipe's whole content", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const oignon = await ingredientId("Oignon");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade composée",
visibility: "PUBLIC",
dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
});
expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade composée");
expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.name).to.equal("Oignon");
expect(res.body.steps).to.have.length(2);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const tomate = await ingredientId("Tomate");
const res = await agent.patch("/recipes/999999").send({
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
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("Tomate");
const created = await agent.post("/recipes").send({
name: "Salade",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade",
dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
});
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { agent, profileId } = await signup();
const { agent: otherAgent } = await signup();
const tomate = await ingredientId("Tomate");
const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
});
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
});
});
describe("DELETE /recipes/:id", () => {
it("deletes a recipe not referenced by any planning item", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "À supprimer", authorId: profileId },
});
const res = await agent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(204);
const getRes = await agent.get(`/recipes/${recipe.id}`);
expect(getRes.status).to.equal(404);
});
it("rejects deleting a recipe still used by a planning item with 409 RECIPE_IN_USE", async () => {
const { agent, profileId } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId },
});
const planning = await prisma.planning.create({
data: {
houseId: houseRes.body.id,
startDate: new Date(Date.UTC(2026, 0, 1)),
finishDate: new Date(Date.UTC(2026, 0, 7)),
},
});
await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
});
const res = await agent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.RECIPE_IN_USE);
});
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup();
const res = await agent.delete("/recipes/999999");
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects deleting someone else's recipe with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup();
const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
});
const res = await otherAgent.delete(`/recipes/${recipe.id}`);
expect(res.status).to.equal(403);
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
});
});
describe("POST/DELETE /recipes/:id/favorite", () => {
it("adds and removes a recipe from the viewer's favorites", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
});
const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(addRes.status).to.equal(204);
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(true);
const removeRes = await agent.delete(`/recipes/${recipe.id}/favorite`);
expect(removeRes.status).to.equal(204);
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(false);
});
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
});
await agent.post(`/recipes/${recipe.id}/favorite`);
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(res.status).to.equal(204);
});
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
const { agent } = await signup();
const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
});
});

View file

@ -46,4 +46,23 @@ describe("Reference data", () => {
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
});
});
describe("GET /reference/ingredients", () => {
it("returns the seeded ingredients, no session required", async () => {
const res = await request(app).get("/reference/ingredients");
expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]);
});
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
const res = await request(app).get("/reference/ingredients");
const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name);
expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs");
expect(byName("Tomate").allergens).to.deep.equal([]);
});
});
});

View file

@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout";
import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage";
import { RecipeFormPage } from "./pages/RecipeFormPage";
import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage";
@ -47,7 +48,14 @@ export function App() {
}
>
<Route path="/" element={<PlanningPage />} />
{/* Same component for both a master-detail layout, not a
navigation to a separate page: the tab bar + table stay
mounted, only RecipesPage's detail panel changes with `:id`
(see RecipesPage.tsx). */}
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
<Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
<Route path="/parametres/preferences" element={<PreferencesPage />} />

View file

@ -1,15 +1,21 @@
import {
type AllergyView,
type ApiErrorResponse,
type CreateRecipeInput,
type DietView,
ErrorCode,
type HouseView,
type IngredientView,
type LoginInput,
type PlanningView,
type PreferencesView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type SafeUserProfile,
type SignupInput,
type ThemePreference,
type UpdateRecipeInput,
} from "@batch-cooking/shared";
/**
@ -132,6 +138,48 @@ export class ApiClient {
return this.request("/reference/allergies");
}
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
public getIngredients(): Promise<IngredientView[]> {
return this.request("/reference/ingredients");
}
/** One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), optionally filtered further by a name substring. */
public listRecipes(tab: RecipeTab, search?: string): Promise<RecipeSummaryView[]> {
const params = new URLSearchParams({ tab });
if (search) params.set("search", search);
return this.request(`/recipes?${params.toString()}`);
}
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
public getRecipe(id: number): Promise<RecipeView> {
return this.request(`/recipes/${id}`);
}
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
return this.request("/recipes", { method: "POST", body: JSON.stringify(input) });
}
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
}
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
public deleteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}`, { method: "DELETE" });
}
/** Favorites a recipe for the current user — idempotent. */
public addFavoriteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}/favorite`, { method: "POST" });
}
/** Unfavorites a recipe for the current user — idempotent. */
public removeFavoriteRecipe(id: number): Promise<void> {
return this.request(`/recipes/${id}/favorite`, { method: "DELETE" });
}
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current");
@ -185,6 +233,19 @@ export class ApiClient {
});
}
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
public getDislikedIngredientIds(): Promise<number[]> {
return this.request("/profile/disliked-ingredients");
}
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
return this.request("/profile/disliked-ingredients", {
method: "PATCH",
body: JSON.stringify({ dislikedIngredientIds }),
});
}
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
public getPreferences(): Promise<PreferencesView> {
return this.request("/preferences");

View file

@ -0,0 +1,68 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's
// ingredient picker, features/recipes/) rather than a second search field —
// same reference ingredient list, same "type to filter" behavior, just
// without the recipe form's quantity/unit per line. Its own recipes.scss
// import already covers `.ingredient-autocomplete`; this file's explicit
// import below only adds `.disliked-ingredients-field__*` (see recipes.scss
// — colocated there since it's the same "reference ingredient picker"
// visual family, even though this field lives in the profile feature).
import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete";
import "../recipes/recipes.scss";
import "./profile-forms.scss";
/**
* Search-and-add + removable-chips field for the current user's personal
* "disliked ingredients" list a taste preference, distinct from
* `AllergySelect`'s medical allergy list. Used on `/parametres/preferences`
* (`PreferencesPage`); crossed against a recipe's own ingredients on its
* detail panel (`RecipeDetailPanel`) to surface just the relevant ones.
*/
export function DislikedIngredientsField({
ingredients,
value,
onChange,
}: {
ingredients: IngredientView[];
value: number[];
onChange: (ingredientIds: number[]) => void;
}) {
const { t } = useTranslation();
const selected = ingredients.filter((ingredient) => value.includes(ingredient.id));
function add(ingredient: IngredientView) {
onChange([...value, ingredient.id]);
}
function remove(ingredientId: number) {
onChange(value.filter((id) => id !== ingredientId));
}
return (
// `<fieldset>`/`<legend>` (not a bare `<label>`, which only associates
// with a single control) — this labels the composite widget (chips +
// search), same reasoning as AllergySelect.
<fieldset className="disliked-ingredients-field">
<legend>{t("preferences.form.dislikedIngredientsLabel")}</legend>
{selected.length > 0 && (
<ul className="disliked-ingredients-field__chips">
{selected.map((ingredient) => (
<li key={ingredient.id} className="disliked-ingredients-field__chip">
<span aria-hidden="true">{ingredient.icon}</span>
{ingredient.name}
<button
type="button"
onClick={() => remove(ingredient.id)}
title={t("preferences.form.removeDislikedIngredient")}
>
</button>
</li>
))}
</ul>
)}
<IngredientAutocomplete ingredients={ingredients} excludeIds={value} onSelect={add} />
</fieldset>
);
}

View file

@ -0,0 +1,25 @@
import type { AllergyView } from "@batch-cooking/shared";
import "./recipes.scss";
/**
* A row of allergen pills used both on {@link RecipeCard} (catalog list)
* and the recipe detail page, for a single ingredient's allergens as well
* as a recipe's aggregated set (`RecipeSummaryView.allergens`/
* `RecipeView.allergens`, already deduplicated server-side). Renders
* nothing for an empty list rather than an empty wrapper, so callers can
* mount it unconditionally.
*/
export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
if (allergens.length === 0) {
return null;
}
return (
<ul className="allergen-badges">
{allergens.map((allergen) => (
<li key={allergen.id} className="allergen-badge">
{allergen.name}
</li>
))}
</ul>
);
}

View file

@ -0,0 +1,25 @@
import type { DietView } from "@batch-cooking/shared";
import "./recipes.scss";
/**
* A row of diet-regime pills the "associated regime" reminder tagged on a
* recipe (`RecipeSummaryView.diets`/`RecipeView.diets`, manually chosen by
* the author, see `RecipeFormPage`). Uses `--color-tag` (turmeric,
* "category/classification tags" per `_theme.scss`) never
* `--color-allergen`, to stay visually distinct from a safety warning.
* Renders nothing for an empty list, same convention as `AllergenBadges`.
*/
export function DietBadges({ diets }: { diets: DietView[] }) {
if (diets.length === 0) {
return null;
}
return (
<ul className="diet-badges">
{diets.map((diet) => (
<li key={diet.id} className="diet-badge">
{diet.name}
</li>
))}
</ul>
);
}

View file

@ -0,0 +1,41 @@
import type { DietView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./recipes.scss";
/**
* Multi-select (checkbox grid, same pattern as `AllergySelect`) for the
* regime(s) a recipe is tagged as suiting manually chosen by whoever
* creates/edits it (see `RecipeFormPage`), a reminder shown in the catalog
* table and detail panel (`DietBadges`), not computed from ingredients.
*/
export function DietTagSelect({
diets,
value,
onChange,
}: {
diets: DietView[];
value: number[];
onChange: (dietIds: number[]) => void;
}) {
const { t } = useTranslation();
function toggle(id: number) {
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
}
return (
<fieldset className="diet-tag-select">
<legend>{t("recipes.form.dietsLabel")}</legend>
{diets.map((diet) => {
const checked = value.includes(diet.id);
return (
<label key={diet.id} className={checked ? "is-selected" : undefined}>
<input type="checkbox" checked={checked} onChange={() => toggle(diet.id)} />
<span className="check-mark" aria-hidden="true" />
{diet.name}
</label>
);
})}
</fieldset>
);
}

View file

@ -0,0 +1,58 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { FavoriteIcon } from "../../layouts/nav-icons";
import "./recipes.scss";
/**
* Star toggle for a recipe's favorite status the overlay button on
* {@link RecipeDetailPanel}'s photo header. Optimistic: flips immediately,
* calls the API in the background, and reverts if it fails (favoriting is
* rare enough, and low-stakes enough, that a rollback-on-error is simpler
* and just as correct as a pending state).
*
* `onToggled` lets the owning page (`RecipesPage`) keep the catalog table's
* row (the fav-mark next to the name, and the `favoris` tab's membership)
* in sync this component only owns the button's own optimistic look.
*/
export function FavoriteStarButton({
recipeId,
isFavorite,
onToggled,
}: {
recipeId: number;
isFavorite: boolean;
onToggled: (isFavorite: boolean) => void;
}) {
const { t } = useTranslation();
const [isSaving, setIsSaving] = useState(false);
async function handleClick() {
const next = !isFavorite;
onToggled(next);
setIsSaving(true);
try {
if (next) {
await apiClient.addFavoriteRecipe(recipeId);
} else {
await apiClient.removeFavoriteRecipe(recipeId);
}
} catch {
onToggled(!next);
} finally {
setIsSaving(false);
}
}
return (
<button
type="button"
className={`favorite-star-button${isFavorite ? " is-favorite" : ""}`}
onClick={handleClick}
disabled={isSaving}
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
>
<FavoriteIcon />
</button>
);
}

View file

@ -0,0 +1,71 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import "./recipes.scss";
/** Caps how many matches are shown at once — the reference list is small, but an unbounded dropdown would still be unwieldy for a broad query like a single letter. */
const MAX_SUGGESTIONS = 8;
/**
* Text field over the static reference ingredient list (`GET
* /reference/ingredients`) receives the full list as a prop rather than
* fetching it itself, same rationale as `AllergySelect`/`DietSelect`, and
* filters it client-side as the user types: it's small, non-administrable
* reference data, no dedicated search endpoint needed. `excludeIds` (the
* ingredients already on the recipe) keeps the same one from being added
* twice.
*/
export function IngredientAutocomplete({
ingredients,
excludeIds,
onSelect,
}: {
ingredients: IngredientView[];
excludeIds: number[];
onSelect: (ingredient: IngredientView) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const suggestions =
normalizedQuery.length === 0
? []
: ingredients
.filter(
(ingredient) =>
!excludeIds.includes(ingredient.id) &&
ingredient.name.toLowerCase().includes(normalizedQuery),
)
.slice(0, MAX_SUGGESTIONS);
function handleSelect(ingredient: IngredientView) {
onSelect(ingredient);
setQuery("");
}
return (
<div className="ingredient-autocomplete">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("recipes.form.addIngredientPlaceholder")}
/>
{suggestions.length > 0 && (
<ul className="ingredient-autocomplete__suggestions">
{suggestions.map((ingredient) => (
<li key={ingredient.id}>
<button type="button" onClick={() => handleSelect(ingredient)}>
<span aria-hidden="true">{ingredient.icon}</span>
<span className="ingredient-autocomplete__name">{ingredient.name}</span>
<AllergenBadges allergens={ingredient.allergens} />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientAutocomplete`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
export function IngredientRow({
ingredient,
quantity,
unit,
onQuantityChange,
onUnitChange,
onRemove,
}: {
ingredient: IngredientView;
quantity: string;
unit: string;
onQuantityChange: (quantity: string) => void;
onUnitChange: (unit: string) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<li className="ingredient-row">
<span className="ingredient-row__icon" aria-hidden="true">
{ingredient.icon}
</span>
<span className="ingredient-row__name">{ingredient.name}</span>
<input
type="number"
min="0"
step="any"
className="ingredient-row__quantity"
value={quantity}
onChange={(e) => onQuantityChange(e.target.value)}
aria-label={t("recipes.form.quantityLabel")}
/>
<input
type="text"
className="ingredient-row__unit"
value={unit}
onChange={(e) => onUnitChange(e.target.value)}
placeholder={t("recipes.form.unitPlaceholder")}
aria-label={t("recipes.form.unitLabel")}
/>
<AllergenBadges allergens={ingredient.allergens} />
<button
type="button"
className="ingredient-row__remove"
onClick={onRemove}
title={t("recipes.form.removeIngredient")}
>
</button>
</li>
);
}

View file

@ -0,0 +1,190 @@
import { ErrorCode, type RecipeView } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./AllergenBadges";
import { FavoriteStarButton } from "./FavoriteStarButton";
import "./recipes.scss";
/** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */
export type RecipeDetailState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; recipe: RecipeView }
| { status: "not-found" }
| { status: "error" };
/**
* Right-hand panel of the catalog's master-detail layout (`RecipesPage`)
* header (photo + favorite star), name + allergen/disliked-ingredient
* badges, description, ordered steps. `dislikedIngredientIds` is the
* *viewer's* personal taste-preference list (`GET
* /profile/disliked-ingredients`) crossed here against this recipe's own
* ingredients to surface just the ones relevant to it, not the viewer's
* whole list.
*/
export function RecipeDetailPanel({
state,
dislikedIngredientIds,
onFavoriteToggled,
onDeleted,
}: {
state: RecipeDetailState;
dislikedIngredientIds: number[];
onFavoriteToggled: (recipeId: number, isFavorite: boolean) => void;
onDeleted: (recipeId: number) => void;
}) {
const { t } = useTranslation();
if (state.status === "empty") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.loading")}</p>
</aside>
);
}
if (state.status === "not-found") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.notFound")}
</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("common.loadError")}
</p>
</aside>
);
}
const { recipe } = state;
const dislikedIngredients = recipe.ingredients
.map((line) => line.ingredient)
.filter((ingredient) => dislikedIngredientIds.includes(ingredient.id));
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
</div>
<FavoriteStarButton
recipeId={recipe.id}
isFavorite={recipe.isFavorite}
onToggled={(isFavorite) => onFavoriteToggled(recipe.id, isFavorite)}
/>
</div>
<div className="recipe-detail-panel__title-row">
<h2>{recipe.name}</h2>
<div className="recipe-detail-panel__title-badges">
<AllergenBadges allergens={recipe.allergens} />
{dislikedIngredients.length > 0 && (
<ul className="disliked-badges">
{dislikedIngredients.map((ingredient) => (
<li key={ingredient.id} className="disliked-badge">
🚫 {ingredient.name}
</li>
))}
</ul>
)}
</div>
</div>
<div className="recipe-detail-panel__actions">
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
{t("recipes.editButton")}
</Link>
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted(recipe.id)} />
</div>
{recipe.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{recipe.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.stepsTitle")}</h3>
<ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.picture && <img src={step.picture} alt="" />}
<p>{step.description}</p>
</li>
))}
</ol>
</section>
</aside>
);
}
/** Delete action with an inline two-step confirmation, same pattern as `HouseholdSettingsPage`'s danger zone. */
function DeleteRecipeButton({
recipeId,
onDeleted,
}: {
recipeId: number;
onDeleted: () => void;
}) {
const { t } = useTranslation();
const [isConfirming, setIsConfirming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setIsDeleting(true);
setError(null);
try {
await apiClient.deleteRecipe(recipeId);
onDeleted();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsDeleting(false);
}
}
if (!isConfirming) {
return (
<button
type="button"
className="recipe-detail-panel__danger-button"
onClick={() => setIsConfirming(true)}
>
{t("recipes.deleteButton")}
</button>
);
}
return (
<span className="recipe-detail-panel__delete-confirm">
<button
type="button"
className="recipe-detail-panel__danger-button"
onClick={handleDelete}
disabled={isDeleting}
>
{t("recipes.confirmDeleteButton")}
</button>
<button type="button" onClick={() => setIsConfirming(false)} disabled={isDeleting}>
{t("recipes.cancelDeleteButton")}
</button>
{error && <p className="field-error">{error}</p>}
</span>
);
}

View file

@ -0,0 +1,84 @@
import type { RecipeSummaryView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import "./recipes.scss";
/**
* Left-aligned catalog table photo / name / allergens-intolerances /
* associated regime, one row per recipe. Replaces the earlier card grid
* (`RecipeCard`, removed): clicking a row selects it (`onSelect`) rather
* than navigating to a separate page the detail renders alongside, in
* `RecipeDetailPanel` (see `RecipesPage`'s master-detail layout).
*/
export function RecipeTable({
recipes,
selectedId,
onSelect,
}: {
recipes: RecipeSummaryView[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
const { t } = useTranslation();
return (
<div className="recipe-table-wrap">
<table className="recipe-table">
<thead>
<tr>
<th />
<th>{t("recipes.table.name")}</th>
<th>{t("recipes.table.allergens")}</th>
<th>{t("recipes.table.diets")}</th>
</tr>
</thead>
<tbody>
{recipes.map((recipe) => (
<tr
key={recipe.id}
className={recipe.id === selectedId ? "selected" : undefined}
onClick={() => onSelect(recipe.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(recipe.id);
}
}}
tabIndex={0}
aria-current={recipe.id === selectedId ? "true" : undefined}
>
<td>
<span className="recipe-table__photo" aria-hidden="true">
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
</span>
</td>
<td className="recipe-table__name">
{recipe.name}
{recipe.isFavorite && (
<span className="recipe-table__fav-mark" aria-hidden="true">
</span>
)}
</td>
<td>
{recipe.allergens.length > 0 ? (
<AllergenBadges allergens={recipe.allergens} />
) : (
<span className="recipe-table__muted"></span>
)}
</td>
<td>
{recipe.diets.length > 0 ? (
<DietBadges diets={recipe.diets} />
) : (
<span className="recipe-table__muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,54 @@
import type { RecipeTab } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
import "./recipes.scss";
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
const TABS: Array<{ value: RecipeTab; Icon: () => JSX.Element }> = [
{ value: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon },
{ value: "publique", Icon: PublicIcon },
];
/**
* Catalog tab bar Favoris / Perso / Foyer / Publique, plus a disabled
* placeholder for external sources (not built yet, see the plan's "hors
* scope" note) so the eventual nav slot is visible without being
* functional. No "toutes" tab: every recipe a viewer can see falls under
* exactly one of perso/foyer/publique (its own visibility) see
* `recipe.service.ts`'s `listRecipes`.
*/
export function RecipeTabs({
active,
onChange,
}: {
active: RecipeTab;
onChange: (tab: RecipeTab) => void;
}) {
const { t } = useTranslation();
return (
<div className="recipe-tabs">
{TABS.map(({ value, Icon }) => (
<button
key={value}
type="button"
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
onClick={() => onChange(value)}
>
<Icon />
{t(`recipes.tabs.${value}`)}
</button>
))}
<button
type="button"
className="recipe-tabs__tab placeholder"
disabled
title={t("recipes.tabs.sourcesSoonHint")}
>
{t("recipes.tabs.sourcesSoon")}
</button>
</div>
);
}

View file

@ -0,0 +1,101 @@
import { useTranslation } from "react-i18next";
import "./recipes.scss";
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
export interface StepDraft {
key: string;
description: string;
picture: string;
}
/**
* Ordered step editor reordering is just moving array elements (up/down
* buttons), no drag-and-drop library needed for a first version. `order` is
* never tracked explicitly here: the array's position *is* the order, sent
* to the API as-is on submit.
*/
export function StepListEditor({
steps,
onChange,
}: {
steps: StepDraft[];
onChange: (steps: StepDraft[]) => void;
}) {
const { t } = useTranslation();
function addStep() {
onChange([...steps, { key: crypto.randomUUID(), description: "", picture: "" }]);
}
function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) {
onChange(steps.map((step) => (step.key === key ? { ...step, ...patch } : step)));
}
function removeStep(key: string) {
onChange(steps.filter((step) => step.key !== key));
}
function moveStep(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
const moved = next.splice(index, 1)[0];
if (!moved) return;
next.splice(target, 0, moved);
onChange(next);
}
return (
<div className="step-list-editor">
<ol className="step-list-editor__list">
{steps.map((step, index) => (
<li key={step.key} className="step-list-editor__item">
<div className="step-list-editor__reorder">
<button
type="button"
onClick={() => moveStep(index, -1)}
disabled={index === 0}
title={t("recipes.form.moveStepUp")}
>
</button>
<button
type="button"
onClick={() => moveStep(index, 1)}
disabled={index === steps.length - 1}
title={t("recipes.form.moveStepDown")}
>
</button>
</div>
<div className="step-list-editor__fields">
<textarea
value={step.description}
onChange={(e) => updateStep(step.key, { description: e.target.value })}
placeholder={t("recipes.form.stepDescriptionPlaceholder")}
rows={2}
/>
<input
type="url"
value={step.picture}
onChange={(e) => updateStep(step.key, { picture: e.target.value })}
placeholder={t("recipes.form.stepPicturePlaceholder")}
/>
</div>
<button
type="button"
className="step-list-editor__remove"
onClick={() => removeStep(step.key)}
title={t("recipes.form.removeStep")}
>
</button>
</li>
))}
</ol>
<button type="button" className="step-list-editor__add" onClick={addStep}>
{t("recipes.form.addStep")}
</button>
</div>
);
}

View file

@ -0,0 +1,888 @@
// =============================================================================
// Recipe catalog shared by RecipesPage, RecipeFormPage and every component
// under features/recipes/. Colocated here (rather than split per-component)
// since the catalog is one cohesive visual unit and most rules are small
// enough that splitting them would just add files to jump between same
// choice as settings-pages.scss for the settings pages.
//
// The master-detail layout (tabs + table + detail panel) was ported from a
// reviewed standalone HTML mockup onto the app's real design tokens — no
// light/dark duplication needed here, every `var(--color-*)` below already
// resolves per-theme globally (see styles/_theme.scss).
// =============================================================================
// --- Allergen / diet / disliked-ingredient badges ---------------------------
// Three visually distinct pill languages, each with its own meaning never
// mixed, so a glance tells them apart without reading the label:
// - allergens: --color-allergen, solid fill (a food-safety warning)
// - diets: --color-tag (turmeric "category/classification tag" per
// _theme.scss), solid fill (a neutral reminder, not a warning)
// - disliked ingredients: neutral outline, dashed border (a personal
// taste preference deliberately *not* on the allergen/warning scale)
.allergen-badges,
.diet-badges,
.disliked-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin: var(--space-sm) 0 0;
padding: 0;
list-style: none;
}
.allergen-badge,
.diet-badge,
.disliked-badge {
padding: 0.15rem 0.6rem;
font-size: var(--font-size-xs);
font-weight: 600;
border-radius: var(--radius-pill);
}
.allergen-badge {
color: var(--color-allergen-ink);
background: var(--color-allergen);
}
.diet-badge {
color: var(--color-tag-ink);
background: var(--color-tag);
}
.disliked-badge {
color: var(--color-text-muted);
background: none;
border: 1px dashed var(--color-border);
}
// --- Catalog page -------------------------------------------------------------
// `.app-content` (AppLayout.scss) already stretches to the full viewport
// height same reasoning as `planning-page.scss`. `.recipes-page` fills
// that box as a column so `__catalog` can grow to fill whatever's left
// under the header/tabs, instead of being only as tall as its content.
.recipes-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
&__search {
flex: 1 1 16rem;
max-width: 24rem;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
&__new-button {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
text-decoration: none;
&:hover {
background: var(--color-primary-hover);
}
}
&__status {
margin-top: var(--space-xl);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
// The master-detail row: table (flexible) + detail panel (never under
// 35% of the *viewport* width `minmax(35vw, 38vw)`, not a fraction of
// this grid, per the reviewed mockup), both stretched to the same full
// height so the detail panel never just shrink-wraps to its own content.
&__catalog {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(35vw, 38vw);
gap: var(--space-lg);
}
@media (max-width: 1024px) {
&__catalog {
grid-template-columns: 1fr;
height: auto;
}
}
}
// --- Tabs ------------------------------------------------------------------
.recipe-tabs {
flex-shrink: 0;
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md);
&__tab {
appearance: none;
display: flex;
align-items: center;
gap: 0.4rem;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-display);
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2.5px solid transparent;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
svg {
width: 1.05rem;
height: 1.05rem;
flex: none;
}
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
}
&.placeholder {
color: var(--color-border);
cursor: not-allowed;
&:hover {
color: var(--color-border);
}
}
}
}
// --- Catalog table -----------------------------------------------------------
.recipe-table-wrap {
height: 100%;
min-height: 0;
overflow-y: auto;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.recipe-table {
width: 100%;
border-collapse: collapse;
text-align: left;
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
font-family: var(--font-display);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
tbody tr {
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: var(--color-surface-alt);
}
&.selected {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
td:first-child {
box-shadow: inset 3px 0 0 var(--color-primary);
}
}
&:last-child td {
border-bottom: none;
}
}
&__photo {
width: 2.6rem;
height: 2.6rem;
border-radius: var(--radius-base);
background: var(--color-surface-alt);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
font-weight: 600;
}
&__fav-mark {
color: var(--color-tag);
margin-left: 0.3rem;
font-size: var(--font-size-sm);
}
&__muted {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
// --- Recipe detail panel -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height
// (`overflow-y: auto`) the photo/star/name stay visible while reading.
.recipe-detail-panel {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
&__status {
padding: var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
&__header {
flex-shrink: 0;
position: relative;
}
&__photo {
height: 9rem;
width: 100%;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
var(--color-surface-alt)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-md) 0;
h2 {
font-size: var(--font-size-lg);
}
}
&__title-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
.allergen-badges,
.disliked-badges {
justify-content: flex-end;
margin-top: 0;
}
}
&__actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
}
&__danger-button {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
background: var(--color-error);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__delete-confirm {
display: flex;
align-items: center;
gap: var(--space-sm);
button:not(.recipe-detail-panel__danger-button) {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}
}
&__section {
flex-shrink: 0;
padding: var(--space-md);
& + & {
border-top: 1px solid var(--color-border);
}
h3 {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
}
// The description gets its own, more generous section the only "free
// text" on the panel, it needs more room to breathe than a badge or a
// short step.
&__section--description {
padding: var(--space-lg) var(--space-md);
}
&__description {
color: var(--color-text);
font-size: var(--font-size-base);
line-height: 1.7;
margin: 0;
}
&__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-md);
counter-reset: step;
li {
counter-increment: step;
&::before {
content: counter(step) ". ";
font-weight: 700;
color: var(--color-primary);
}
img {
display: block;
max-width: 100%;
border-radius: var(--radius-base);
margin-bottom: var(--space-xs);
}
p {
display: inline;
margin: 0;
}
}
}
}
// --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
border: none;
cursor: pointer;
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
box-shadow: var(--shadow-sm);
color: var(--color-border);
display: flex;
align-items: center;
justify-content: center;
transition:
transform 0.12s ease,
color 0.12s ease;
svg {
width: 1.2rem;
height: 1.2rem;
}
&:hover:not(:disabled) {
transform: scale(1.08);
}
&:disabled {
cursor: not-allowed;
}
// Overrides the icon's own `fill="none"` (a presentation attribute, lower
// CSS priority than this rule) solid star once favorited, outline
// otherwise.
&.is-favorite {
color: var(--color-tag);
svg {
fill: currentColor;
}
}
}
// --- Recipe form (create/edit) ----------------------------------------------
// Per-field validation and whole-form error messages same small rules as
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
// since this page only imports recipes.scss (see global.scss's doc comment
// on colocated, per-scope styles).
.field-error {
color: var(--color-error);
font-size: var(--font-size-xs);
margin: 0;
}
.form-error {
color: var(--color-error);
font-size: var(--font-size-sm);
}
.recipe-form {
display: flex;
flex-direction: column;
max-width: 40rem;
gap: var(--space-xs);
label {
font-size: var(--font-size-sm);
font-weight: 600;
margin-top: var(--space-sm);
}
input,
textarea,
select {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
resize: vertical;
}
&__section {
margin-top: var(--space-xl);
h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
}
&__ingredient-list {
list-style: none;
margin: 0 0 var(--space-sm);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__actions {
margin-top: var(--space-xl);
}
button[type="submit"] {
padding: 0.6rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
border: none;
border-radius: var(--radius-base);
background: var(--color-primary);
color: #fff;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
// --- Diet tag multi-select (recipe form) -------------------------------------
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
// global.scss's `label:has(> input[type="checkbox"])` rule already
// supplies the selected/unselected look, this only arranges the rows.
.diet-tag-select {
border: none;
padding: 0;
margin: var(--space-xs) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
legend {
width: 100%;
padding: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
label {
margin-top: 0;
font-weight: 400;
}
}
// --- Ingredient row (selected ingredient in the form) -----------------------
.ingredient-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
&__name {
font-weight: 600;
margin-right: auto;
}
&__quantity {
width: 5rem;
}
&__unit {
width: 6rem;
}
&__remove {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
&:hover {
color: var(--color-error);
border-color: var(--color-error);
}
}
}
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) -----
.ingredient-autocomplete {
position: relative;
> input {
width: 100%;
max-width: 24rem;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
&__suggestions {
position: absolute;
z-index: 1;
top: 100%;
left: 0;
width: 100%;
max-width: 24rem;
max-height: 16rem;
overflow-y: auto;
margin: var(--space-xs) 0 0;
padding: var(--space-xs);
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
button {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
text-align: left;
border: none;
border-radius: var(--radius-base);
background: none;
color: var(--color-text);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
&__name {
margin-right: auto;
}
}
// --- Step list editor -----------------------------------------------------
.step-list-editor {
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
counter-reset: step;
}
&__item {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
counter-increment: step;
&::before {
content: counter(step);
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin-top: var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 600;
color: #fff;
background: var(--color-primary);
border-radius: 50%;
}
}
&__reorder {
display: flex;
flex-direction: column;
gap: 0.15rem;
button {
padding: 0.1rem 0.4rem;
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
&__fields {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-xs);
textarea,
input {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-background);
color: var(--color-text);
resize: vertical;
}
}
&__remove {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
&:hover {
color: var(--color-error);
border-color: var(--color-error);
}
}
&__add {
margin-top: var(--space-sm);
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border: 1px dashed var(--color-border);
border-radius: var(--radius-base);
background: none;
color: var(--color-primary);
&:hover {
border-color: var(--color-primary);
}
}
}
// --- Disliked-ingredients field (preferences page) ---------------------------
// A search-and-add + removable-chips field, same idea as the recipe form's
// ingredient picker but without quantity/unit see
// `features/profile/DislikedIngredientsField.tsx`.
.disliked-ingredients-field {
border: none;
padding: 0;
margin: var(--space-md) 0 0;
legend {
padding: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin: 0 0 var(--space-sm);
padding: 0;
list-style: none;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.3rem 0.2rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
button {
display: flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
padding: 0;
font-size: var(--font-size-xs);
line-height: 1;
color: var(--color-text-muted);
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
&:hover {
background: var(--color-border);
color: var(--color-text);
}
}
}
}

View file

@ -108,3 +108,23 @@ export function ChevronLeftIcon() {
</Icon>
);
}
/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
export function FavoriteIcon() {
return (
<Icon>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</Icon>
);
}
/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
export function PublicIcon() {
return (
<Icon>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</Icon>
);
}

View file

@ -11,11 +11,15 @@
"NOT_AUTHENTICATED": "Vous devez être connecté",
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
"NOT_RECIPE_AUTHOR": "Seul l'auteur de la recette peut faire ça",
"NOT_FOUND": "Ressource introuvable",
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {
@ -119,7 +123,63 @@
},
"recipes": {
"title": "Recettes",
"comingSoon": "Cette section arrive bientôt."
"searchPlaceholder": "Rechercher une recette…",
"newButton": "Nouvelle recette",
"loading": "Chargement…",
"empty": "Aucune recette pour le moment.",
"notFound": "Cette recette n'existe pas.",
"editButton": "Modifier",
"deleteButton": "Supprimer",
"confirmDeleteButton": "Confirmer la suppression",
"cancelDeleteButton": "Annuler",
"ingredientsTitle": "Ingrédients",
"stepsTitle": "Préparation",
"tabs": {
"favoris": "Favoris",
"perso": "Perso",
"foyer": "Foyer",
"publique": "Publique",
"sourcesSoon": "Sources (bientôt)",
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
},
"table": {
"name": "Nom",
"allergens": "Allergènes / intolérances",
"diets": "Régime associé"
},
"detail": {
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris"
},
"form": {
"newTitle": "Nouvelle recette",
"editTitle": "Modifier la recette",
"nameLabel": "Nom de la recette",
"descriptionLabel": "Description",
"pictureLabel": "Photo (URL)",
"visibilityLabel": "Visible par",
"visibility": {
"PERSONAL": "Moi uniquement",
"HOUSE": "Mon foyer",
"PUBLIC": "Tout le monde"
},
"dietsLabel": "Régime(s) associé(s)",
"addIngredientPlaceholder": "Ajouter un ingrédient…",
"quantityLabel": "Quantité",
"unitLabel": "Unité",
"unitPlaceholder": "g, ml, unité…",
"removeIngredient": "Retirer cet ingrédient",
"stepDescriptionPlaceholder": "Décrivez cette étape…",
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
"moveStepUp": "Monter cette étape",
"moveStepDown": "Descendre cette étape",
"removeStep": "Supprimer cette étape",
"addStep": "Ajouter une étape",
"submit": "Enregistrer",
"submitting": "Enregistrement…",
"genericError": "Le formulaire contient des erreurs"
}
},
"shoppingList": {
"title": "Liste de courses",
@ -147,7 +207,9 @@
"dietLabel": "Régime alimentaire",
"dietNone": "Aucun régime particulier",
"allergiesLabel": "Allergies",
"intolerancesLabel": "Intolérances"
"intolerancesLabel": "Intolérances",
"dislikedIngredientsLabel": "Aliments que vous n'aimez pas",
"removeDislikedIngredient": "Retirer cet aliment"
}
},
"userPreferences": {

View file

@ -0,0 +1,275 @@
import {
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
type RecipeVisibility,
createRecipeSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { DietTagSelect } from "../features/recipes/DietTagSelect";
import { IngredientAutocomplete } from "../features/recipes/IngredientAutocomplete";
import { IngredientRow } from "../features/recipes/IngredientRow";
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
import "../features/recipes/recipes.scss";
import { errorMessageService } from "../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. */
interface IngredientLine {
key: string;
ingredient: IngredientView;
quantity: string;
unit: string;
}
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
type LoadState = "loading" | "loaded" | "error";
/**
* Create/edit form for one recipe routed at `/recettes/nouvelle` and
* `/recettes/:id/modifier`. Same component for both: edit mode is just
* "there's an `:id` param", which also drives preloading the existing
* recipe's fields. Saving always sends the recipe's *whole* content (name,
* ingredients, steps) there's no partial-field save here, matching the
* API's `PATCH /recipes/:id` contract (see `recipe.service.ts`).
*/
export function RecipeFormPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const recipeId = id !== undefined ? Number(id) : null;
const isEditing = recipeId !== null;
const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [picture, setPicture] = useState("");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setLoadState("loading");
Promise.all([
apiClient.getIngredients(),
apiClient.getDiets(),
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
])
.then(([ingredients, diets, recipe]) => {
if (cancelled) return;
setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
if (recipe) {
setName(recipe.name);
setDescription(recipe.description ?? "");
setPicture(recipe.picture ?? "");
setVisibility(recipe.visibility);
setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines(
recipe.ingredients.map((line) => ({
key: crypto.randomUUID(),
ingredient: line.ingredient,
quantity: String(line.quantity),
unit: line.unit,
})),
);
setSteps(
recipe.steps.map((step) => ({
key: crypto.randomUUID(),
description: step.description,
picture: step.picture ?? "",
})),
);
}
setLoadState("loaded");
})
.catch(() => {
if (!cancelled) setLoadState("error");
});
return () => {
cancelled = true;
};
}, [recipeId]);
function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [
...lines,
{ key: crypto.randomUUID(), ingredient, quantity: "", unit: "" },
]);
}
function updateIngredientLine(
key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unit">>,
) {
setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
);
}
function removeIngredientLine(key: string) {
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
}
// Gates the submit button — the schema (checked again on submit, see
// `handleSubmit`) is the source of truth, this is just instant feedback
// that doesn't need a round trip through zod on every keystroke.
const canSubmit =
name.trim().length > 0 &&
ingredientLines.length > 0 &&
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
steps.length > 0 &&
steps.every((step) => step.description.trim().length > 0);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
const payload: CreateRecipeInput = {
name: name.trim(),
description: description.trim() || null,
picture: picture.trim() || null,
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
ingredientId: line.ingredient.id,
quantity: Number(line.quantity),
unit: line.unit.trim(),
})),
steps: steps.map((step) => ({
description: step.description.trim(),
picture: step.picture.trim() || null,
})),
};
const result = createRecipeSchema.safeParse(payload);
if (!result.success) {
setFormError(result.error.issues[0]?.message ?? t("recipes.form.genericError"));
return;
}
setIsSubmitting(true);
try {
const saved =
recipeId !== null
? await apiClient.updateRecipe(recipeId, result.data)
: await apiClient.createRecipe(result.data);
navigate(`/recettes/${saved.id}`);
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
if (loadState === "loading") {
return (
<div className="recipe-form">
<p className="recipes-page__status">{t("recipes.loading")}</p>
</div>
);
}
if (loadState === "error") {
return (
<div className="recipe-form">
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
</div>
);
}
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
<h1>{isEditing ? t("recipes.form.editTitle") : t("recipes.form.newTitle")}</h1>
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
<textarea
id="recipe-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
<input
id="recipe-picture"
type="url"
value={picture}
onChange={(e) => setPicture(e.target.value)}
placeholder="https://…"
/>
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
<select
id="recipe-visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`recipes.form.visibility.${option}`)}
</option>
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">
{ingredientLines.map((line) => (
<IngredientRow
key={line.key}
ingredient={line.ingredient}
quantity={line.quantity}
unit={line.unit}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unit) => updateIngredientLine(line.key, { unit })}
onRemove={() => removeIngredientLine(line.key)}
/>
))}
</ul>
<IngredientAutocomplete
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
/>
</section>
<section className="recipe-form__section">
<h2>{t("recipes.stepsTitle")}</h2>
<StepListEditor steps={steps} onChange={setSteps} />
</section>
{formError && <p className="form-error">{formError}</p>}
<div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}>
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
</button>
</div>
</form>
);
}

View file

@ -1,8 +1,174 @@
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ComingSoonPage } from "./ComingSoonPage";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs } from "../features/recipes/RecipeTabs";
import "../features/recipes/recipes.scss";
/** Recipes section — routed at `/recettes`. No backend yet (see README's "Planning" section), stub for now. */
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
const SEARCH_DEBOUNCE_MS = 300;
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
type RecipeListState =
| { status: "loading" }
| { status: "loaded"; recipes: RecipeSummaryView[] }
| { status: "error" };
/**
* Recipe catalog routed at both `/recettes` and `/recettes/:id` (the same
* component either way, see `App.tsx`): a tab bar + table on the left stay
* mounted at all times, only the right-hand detail panel changes with the
* `:id` param a master-detail layout, not a navigation to a separate
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
* `RecipeDetailPage`).
*/
export function RecipesPage() {
const { t } = useTranslation();
return <ComingSoonPage title={t("recipes.title")} description={t("recipes.comingSoon")} />;
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const selectedId = id !== undefined ? Number(id) : null;
const [activeTab, setActiveTab] = useState<RecipeTab>("publique");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
let cancelled = false;
setListState({ status: "loading" });
apiClient
.listRecipes(activeTab, debouncedSearch.trim() || undefined)
.then((recipes) => {
if (!cancelled) setListState({ status: "loaded", recipes });
})
.catch(() => {
if (!cancelled) setListState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedSearch]);
useEffect(() => {
if (selectedId === null) {
setDetailState({ status: "empty" });
return;
}
let cancelled = false;
setDetailState({ status: "loading" });
apiClient
.getRecipe(selectedId)
.then((recipe) => {
if (!cancelled) setDetailState({ status: "loaded", recipe });
})
.catch((err) => {
if (cancelled) return;
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
setDetailState({ status: "not-found" });
} else {
setDetailState({ status: "error" });
}
});
return () => {
cancelled = true;
};
}, [selectedId]);
// The viewer's personal "disliked" list only changes from the
// preferences page, never from here — loaded once, not re-fetched on
// every tab/selection change.
useEffect(() => {
apiClient
.getDislikedIngredientIds()
.then(setDislikedIngredientIds)
.catch(() => setDislikedIngredientIds([]));
}, []);
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
setDetailState((prev) =>
prev.status === "loaded" && prev.recipe.id === recipeId
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
: prev,
);
setListState((prev) => {
if (prev.status !== "loaded") return prev;
const recipes = prev.recipes
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
return { status: "loaded", recipes };
});
}
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
function handleDeleted(recipeId: number) {
navigate("/recettes");
setListState((prev) =>
prev.status === "loaded"
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
: prev,
);
}
return (
<div className="recipes-page">
<div className="recipes-page__header">
<h1>{t("recipes.title")}</h1>
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")}
</Link>
</div>
<RecipeTabs active={activeTab} onChange={setActiveTab} />
<div className="recipes-page__catalog">
{listState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("recipes.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={selectedId}
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
/>
)}
<RecipeDetailPanel
state={detailState}
dislikedIngredientIds={dislikedIngredientIds}
onFavoriteToggled={handleFavoriteToggled}
onDeleted={handleDeleted}
/>
</div>
</div>
);
}

View file

@ -1,10 +1,16 @@
import { type AllergyView, type DietView, ErrorCode } from "@batch-cooking/shared";
import {
type AllergyView,
type DietView,
ErrorCode,
type IngredientView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { AllergySelect } from "../../features/profile/AllergySelect";
import { DietSelect } from "../../features/profile/DietSelect";
import { DislikedIngredientsField } from "../../features/profile/DislikedIngredientsField";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
@ -44,6 +50,12 @@ export function PreferencesPage() {
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(undefined);
const [ingredients, setIngredients] = useState<IngredientView[]>([]);
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
const [dislikedSaveState, setDislikedSaveState] = useState<SaveState>("idle");
const [dislikedSaveError, setDislikedSaveError] = useState<string | null>(null);
const dislikedTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
@ -55,15 +67,28 @@ export function PreferencesPage() {
apiClient.getDiets(),
apiClient.getAllergies(),
apiClient.getAllergyIds(),
apiClient.getIngredients(),
apiClient.getDislikedIngredientIds(),
apiClient.me(),
])
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => {
if (cancelled) return;
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setDietId(profile.dietId);
})
.then(
([
dietsResult,
allergiesResult,
allergyIdsResult,
ingredientsResult,
dislikedResult,
profile,
]) => {
if (cancelled) return;
setDiets(dietsResult);
setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult);
setIngredients(ingredientsResult);
setDislikedIngredientIds(dislikedResult);
setDietId(profile.dietId);
},
)
.catch(() => {
if (!cancelled) setLoadError(true);
})
@ -80,6 +105,7 @@ export function PreferencesPage() {
useEffect(() => {
return () => {
window.clearTimeout(allergiesTimeout.current);
window.clearTimeout(dislikedTimeout.current);
};
}, []);
@ -116,6 +142,22 @@ export function PreferencesPage() {
}, ALLERGIES_DEBOUNCE_MS);
}
function handleDislikedIngredientIdsChange(newIds: number[]) {
setDislikedIngredientIds(newIds);
window.clearTimeout(dislikedTimeout.current);
setDislikedSaveState("saving");
dislikedTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateDislikedIngredientIds(newIds);
setDislikedSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setDislikedSaveError(errorMessageService.getLabel(code));
setDislikedSaveState("error");
}
}, ALLERGIES_DEBOUNCE_MS);
}
if (isLoading) {
return (
<div className="settings-page">
@ -160,6 +202,15 @@ export function PreferencesPage() {
/>
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div>
<div className="settings-page__section">
<DislikedIngredientsField
ingredients={ingredients}
value={dislikedIngredientIds}
onChange={handleDislikedIngredientIdsChange}
/>
<SaveStatus state={dislikedSaveState} error={dislikedSaveError} t={t} />
</div>
</div>
);
}

View file

@ -36,8 +36,12 @@ export enum ErrorCode {
NOT_AUTHENTICATED = 4011,
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
ALREADY_HAS_HOUSE = 4020,
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
RECIPE_IN_USE = 4021,
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030,
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
NOT_RECIPE_AUTHOR = 4031,
/** No route/resource matches the request. */
NOT_FOUND = 4040,
/** The profile making the request has no household yet (`houseId` is `null`). */
@ -48,6 +52,10 @@ export enum ErrorCode {
ALLERGY_NOT_FOUND = 4043,
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
INVITE_CODE_NOT_FOUND = 4044,
/** `GET /recipes/:id`, `PATCH /recipes/:id` or `DELETE /recipes/:id` given an id that doesn't match any recipe. */
RECIPE_NOT_FOUND = 4045,
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
INGREDIENT_NOT_FOUND = 4046,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000,
}

View file

@ -10,9 +10,11 @@ export * from "./schemas/household.js";
export * from "./schemas/planning.js";
export * from "./schemas/preferences.js";
export * from "./schemas/profile.js";
export * from "./schemas/recipe.js";
export * from "./tools/assert-is-never.js";
export * from "./types/household.js";
export * from "./types/planning.js";
export * from "./types/preferences.js";
export * from "./types/recipe.js";
export * from "./types/reference.js";
export * from "./types/user-profile.js";

View file

@ -15,3 +15,15 @@ export const updateAllergiesSchema = z.object({
});
/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>;
/**
* Payload accepted by `PATCH /profile/disliked-ingredients`. Replaces the
* profile's full "disliked" set a personal taste preference, not a
* medical restriction (see `updateAllergiesSchema` above for that distinct
* list) an empty array clears it.
*/
export const updateDislikedIngredientsSchema = z.object({
dislikedIngredientIds: z.array(z.number().int().positive()),
});
/** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */
export type UpdateDislikedIngredientsInput = z.infer<typeof updateDislikedIngredientsSchema>;

View file

@ -0,0 +1,74 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `ingredientId` must reference an existing reference `Ingredient` (see
* `GET /reference/ingredients`) there is no way to create one from here,
* ingredients are static reference data. An unknown id is rejected
* service-side with `INGREDIENT_NOT_FOUND`, not here this schema only
* checks shape.
*/
const recipeIngredientInputSchema = z.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive"),
unit: z.string().trim().min(1, "L'unité est requise").max(20),
});
/**
* One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `order` is deliberately not part of this shape it's derived server-side
* from the step's position in the `steps` array, so the client (the
* step reorder UI) never has to keep an explicit order field in sync.
*/
const recipeStepInputSchema = z.object({
description: z.string().trim().min(1, "La description de l'étape est requise").max(2000),
picture: z.string().trim().url("URL invalide").nullable().optional(),
});
/**
* Who can read the recipe being created/edited mirrors `RecipeVisibility`
* in schema.prisma. Defaults to `PERSONAL` (visible to its author only)
* the author explicitly opens it up to `HOUSE`/`PUBLIC` if they want to
* share it, rather than the other way around.
*/
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);
/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
export const createRecipeSchema = z.object({
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
description: z.string().trim().max(2000).nullable().optional(),
picture: z.string().trim().url("URL invalide").nullable().optional(),
visibility: recipeVisibilitySchema.default("PERSONAL"),
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
dietIds: z.array(z.number().int().positive()),
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
});
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;
/** `PATCH /recipes/:id` shares the exact same shape as creation — see {@link createRecipeSchema}. */
export const updateRecipeSchema = createRecipeSchema;
/** Inferred TS type for {@link updateRecipeSchema}'s validated output. */
export type UpdateRecipeInput = z.infer<typeof updateRecipeSchema>;
/**
* Which catalog tab `GET /recipes` should filter for see
* `recipe.service.ts`'s `listRecipes` for what each value actually
* queries. No "toutes" value on purpose: every recipe visible to a viewer
* falls under exactly one of `perso`/`foyer`/`publique` (its own
* visibility), `favoris` is an orthogonal, cross-cutting filter on top.
*/
export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]);
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
export type RecipeTab = z.infer<typeof recipeTabSchema>;
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring. */
export const listRecipesSchema = z.object({
tab: recipeTabSchema,
search: z.string().trim().min(1).optional(),
});
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;

View file

@ -0,0 +1,64 @@
import type { AllergyView, DietView, IngredientView } from "./reference.js";
/**
* Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma.
* Declared by hand (not derived from `@prisma/client`) same reasoning as
* `AllergenKind`: `apps/web` never depends on the Prisma client. Controls
* only visibility, never editing a recipe can only ever be edited/deleted
* by its author, whatever this is set to.
*/
export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
/**
* One ingredient line within a recipe, as returned in {@link RecipeView}
* the ingredient resolved to its full reference data (name, icon,
* allergens), plus the quantity/unit specific to this recipe (carried by
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself).
*/
export interface RecipeIngredientView {
ingredient: IngredientView;
quantity: number;
unit: string;
}
/**
* A single preparation step within a recipe, in `order`. `tech_step` is
* deliberately not surfaced here it's tied to the (not yet built) recipe
* import pipeline, out of scope for the manually-authored catalog.
*/
export interface StepView {
id: number;
description: string;
picture: string | null;
order: number;
}
/**
* A recipe as it appears in the catalog table (`GET /recipes`) enough to
* render a row without fetching every recipe's full detail. `allergens` is
* the union of every ingredient's allergens, deduplicated by allergy id
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
* kept consistent so the table's badges match the detail panel's.
*
* `isFavorite` and `diets` are resolved for the *requesting* user/recipe
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma), while
* `diets` is a property of the recipe itself (manually tagged by its
* author, not viewer-specific).
*/
export interface RecipeSummaryView {
id: number;
name: string;
description: string | null;
picture: string | null;
authorId: number;
visibility: RecipeVisibility;
allergens: AllergyView[];
diets: DietView[];
isFavorite: boolean;
}
/** Full recipe detail, as returned by `GET /recipes/:id`. */
export interface RecipeView extends RecipeSummaryView {
ingredients: RecipeIngredientView[];
steps: StepView[];
}

View file

@ -31,3 +31,23 @@ export interface AllergyView {
name: string;
kind: AllergenKind;
}
/**
* A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/
* reference-seed-data.ts`), same "static, non-administrable" status as
* {@link DietView}/{@link AllergyView}: no create/update/delete endpoint
* exists for it, only the seed populates it.
*
* `allergens` is resolved server-side from the `IngredientAllergy` join
* table empty for an ingredient that carries none of the 14 EU-regulated
* allergens. Used by the recipe catalog (`apps/web`'s recipe form and detail
* page) to pick ingredients and to surface which allergens a recipe
* contains, aggregated across its ingredients.
*/
export interface IngredientView {
id: number;
name: string;
icon: string | null;
allergens: AllergyView[];
}