Merge pull request #19 from kyuno053/feat/recipe-catalog-v2

feat(recipes): catalogue v2 — visibilité, favoris, régimes et catalogue d'ingrédients exhaustif
This commit is contained in:
kyuno053 2026-08-18 14:13:47 +02:00 committed by GitHub
commit c38097f522
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 6315 additions and 46 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 houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
const houseId: number = houseRes.body.id; 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({ const planning = await prisma.planning.create({
data: { data: {
houseId, 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

@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
-- AlterTable
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';

View file

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ingredient_diet" (
"ingredient_id" INTEGER NOT NULL,
"diet_id" INTEGER NOT NULL,
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
);
-- AddForeignKey
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,38 @@
-- Rewritten after this migration failed on a database that already had
-- seeded ingredient rows: the original version (auto-generated by `prisma
-- migrate diff`) cast every existing `category` value directly from the
-- old 18-value enum to the new 7-value one, which fails for every row
-- since none of the old values exist in the new enum. This version instead
-- adds the new columns with a safe default (never casting existing data),
-- then swaps them in — the same "add with a default, correct for real on
-- the next seed run" pattern already used for `IngredientCategory`'s and
-- `IngredientSubcategory`'s own `@default(...)` (see their doc comments in
-- schema.prisma). `seedReferenceData()` runs right after `migrate deploy`
-- on every container start (see apps/api/Dockerfile) and corrects every
-- row's real category/subcategory immediately.
--
-- `DROP TYPE IF EXISTS "IngredientSubcategory"` guards against a previous
-- failed attempt at this exact migration: that CREATE TYPE statement runs
-- outside the AlterEnum transaction below and so persists even though the
-- rest of that failed attempt rolled back — retrying without this guard
-- would hit "type already exists".
-- CreateEnum
DROP TYPE IF EXISTS "IngredientSubcategory";
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
-- CreateEnum
DROP TYPE IF EXISTS "IngredientCategory_new";
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
-- AlterTable: add the new columns at their defaults — no cast of existing
-- `category` values, so this succeeds regardless of what the table
-- currently holds.
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'EPICERIE_SECHE';
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES';
-- Swap the old `category` column (old 18-value enum) out for the new one.
ALTER TABLE "ingredients" DROP COLUMN "category";
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
DROP TYPE "IngredientCategory";
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";

View file

@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR');
-- AlterTable
ALTER TABLE "ingredients" DROP COLUMN "icon",
ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR';

View file

@ -27,6 +27,9 @@ model House {
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
members UserProfile[] @relation("HouseMember") members UserProfile[] @relation("HouseMember")
plannings Planning[] plannings Planning[]
/// Recipes whose author belonged to this household when they created
/// them — see `Recipe.authorHouseId`.
authoredRecipes Recipe[]
@@map("house") @@map("house")
} }
@ -40,6 +43,8 @@ model Diet {
name String @unique name String @unique
users UserProfile[] users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet") @@map("diet")
} }
@ -72,6 +77,7 @@ model Allergy {
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
users UserProfileAllergy[] users UserProfileAllergy[]
ingredients IngredientAllergy[]
@@map("allergy") @@map("allergy")
} }
@ -93,6 +99,14 @@ model UserProfile {
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
allergies UserProfileAllergy[] 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 /// Households this profile administers. In practice at most one — a
/// profile can only ever belong to (and thus admin) a single household at /// 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 /// a time — but Prisma models the admin side of a one-to-many FK as a
@ -103,6 +117,22 @@ model UserProfile {
@@map("user_profiles") @@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, /// Not in the original spec doc — personalization settings (theme for now,
/// meant to grow), one row per profile, created on demand (see /// meant to grow), one row per profile, created on demand (see
/// `preferences.service.ts`) rather than at signup — same "absent means the /// `preferences.service.ts`) rather than at signup — same "absent means the
@ -184,35 +214,271 @@ model Source {
@@map("sources") @@map("sources")
} }
/// 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 { model Recipe {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
sourceId Int? @map("source_id") sourceId Int? @map("source_id")
description String? description String?
picture 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) source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
ingredients RecipeIngredient[] ingredients RecipeIngredient[]
steps Step[] steps Step[]
planningItems PlanningItem[] planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
/// Ingredients for which this recipe is offered as a make-it-yourself alternative. /// Ingredients for which this recipe is offered as a make-it-yourself alternative.
alternateFor Ingredient[] @relation("IngredientAlternateRecipe") alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
@@map("recipe") @@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.
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
/// the ingredient picker (apps/web) can offer category browsing, not just
/// free-text search: with 400+ reference ingredients, search alone doesn't
/// scale to actually *finding* one. Reworked from an earlier, less
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
/// meant an ingredient's category depended on which one you thought of
/// first) into how a French grocery store is actually laid out: 7 aisles,
/// each with a couple of {@link IngredientSubcategory} racks for finer
/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
/// is the single source of truth for which ingredient belongs to which
/// (category, subcategory) pair, these enums just give it type-safe
/// columns to live in. `@default(EPICERIE_SECHE)` exists only so this
/// column can be added `NOT NULL` to a table that may already have rows —
/// the seed script corrects every row's real category on the very next
/// run, this default is never the intended value for a real ingredient.
enum IngredientCategory {
/// 🥦 Légumes, fruits, herbes fraîches.
PRODUITS_FRAIS
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
BOUCHERIE_POISSONNERIE
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
/// produits secs/en conserve qui ne rentre dans aucune autre case
/// (algues séchées, champignons séchés…).
EPICERIE_SECHE
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
BOULANGERIE
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
/// tofu…).
CREMERIE_FROMAGE
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
/// cuisine…).
CONDIMENTS_EPICES
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
/// (levures, fécules, gélatine), sucres.
AIDES_CULINAIRES
}
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
/// that enum's doc comment for why this two-level scheme replaced a flat
/// list. Each value belongs to exactly one category by construction (see
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
/// database level — Postgres enums can't express that relationship, same
/// tradeoff already accepted for `IngredientCategory` itself).
/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientSubcategory {
// --- Produits frais ------------------------------------------------------
LEGUMES
FRUITS
HERBES_FRAICHES
// --- Boucherie & poissonnerie ---------------------------------------------
VIANDES
VOLAILLES
POISSONS
CRUSTACES_FRUITS_DE_MER
// --- Épicerie sèche --------------------------------------------------------
FECULENTS
LEGUMINEUSES
GRAINES_FRUITS_SECS
/// Catch-all for dried/tinned pantry items that don't fit the three
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
/// shoots/water chestnuts…
AUTRES
// --- Boulangerie -------------------------------------------------------
PAINS
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
/// distinct from `PAINS` (already-baked bread).
PATES_A_CUIRE
// --- Crémerie & fromage --------------------------------------------------
PRODUITS_LAITIERS
OEUFS
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
ALTERNATIVES
// --- Condiments & épices -------------------------------------------------
EPICES
SAUCES
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
/// season rather than form the base of a dish.
ASSAISONNEMENTS
// --- Aides culinaires ----------------------------------------------------
/// Flours, stocks/broths, canned tomato bases, water — the literal base
/// a recipe is built on.
BASES
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
/// gelatin.
EPAISSISSANTS
SUCRES
}
/// Generic pictogram *type* for an ingredient — not in the original spec
/// doc. Started as a free-text emoji column (one character per ingredient,
/// 437 different ones), which the product decision recorded in chat
/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG
/// icons (unrealistic), ingredients share a small vocabulary of ~20
/// generic shapes grouped by *what kind of thing* they are — a vegetable,
/// a bottle of oil, a wedge of cheese — regardless of which specific
/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps
/// each value to its actual SVG (matching the app's hand-drawn line-icon
/// style, never emoji — see that file for the full reasoning and the
/// exact `reference-seed-data.ts` assignment per ingredient).
/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientIcon {
VEGETABLE
FRUIT
HERB
MEAT
POULTRY
FISH
SHELLFISH
GRAIN
LEGUME
NUT_SEED
BREAD
DOUGH
MILK
CHEESE
EGG
SPROUT
SPICE
JAR
BOTTLE
DRINK
STOCK_POT
SUGAR
}
model Ingredient { model Ingredient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
icon String? icon IngredientIcon @default(JAR)
category IngredientCategory @default(EPICERIE_SECHE)
subcategory IngredientSubcategory @default(AUTRES)
alternateRecipeId Int? @map("alternate_recipe") 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[] recipes RecipeIngredient[]
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
@@map("ingredients") @@map("ingredients")
} }
/// Explicit join table for the ingredients <-> diet regime association —
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
/// flag e.g. an ingredient as vegan without the user having to open its
/// packaging. Seeded by category in `reference-seed-data.ts` (most
/// ingredients in a category share the same compatible regimes, with
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
/// (already fully derivable from whether `IngredientAllergy` links this
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
/// for the same fact would only risk drifting out of sync with it).
model IngredientDiet {
ingredientId Int @map("ingredient_id")
dietId Int @map("diet_id")
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([ingredientId, dietId])
@@map("ingredient_diet")
}
/// 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 /// recipe <-> ingredients association. The spec documents this as a plain
/// many-to-many, but a shopping list / batch-cooking calculation needs a /// many-to-many, but a shopping list / batch-cooking calculation needs a
/// quantity per recipe, so this join table carries quantity + unit /// 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 { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.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"; import { referenceRouter } from "./modules/reference/reference.routes.js";
/** /**
@ -31,6 +32,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter); server.mountRouter("/reference", referenceRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see

View file

@ -1,4 +1,10 @@
import type { AllergenKind, PrismaClient } from "@prisma/client"; import type {
AllergenKind,
IngredientCategory,
IngredientIcon,
IngredientSubcategory,
PrismaClient,
} from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays // Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive. // nullable, this is not meant to be exhaustive.
@ -28,6 +34,848 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
{ name: "Mollusques", kind: "ALLERGY" }, { name: "Mollusques", kind: "ALLERGY" },
]; ];
interface IngredientSeed {
name: string;
/**
* Generic pictogram type, overriding its group's `defaultIcon` below
* only needed for the exceptions within a subcategory (a wedge of cheese
* inside an otherwise-milk "produits laitiers" group, a stockpot inside
* an otherwise-flour "bases" group). See `IngredientIcon` in
* schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx`
* for the actual pictograms this was a free-text emoji field until the
* product decision recorded in chat replaced it with this small, shared
* vocabulary.
*/
icon?: IngredientIcon;
allergenNames: string[];
/**
* Diet regimes this ingredient is compatible with, overriding its group's
* `defaultDiets` below only needed for the exceptions within a
* subcategory (a fish-based stock inside an otherwise-vegan "bases"
* group, a butter-based dough inside an otherwise-vegan "pâtes à
* cuire"). References `DIETS` by name, same as `allergenNames`
* references `ALLERGENS`. Deliberately never includes `"Omnivore"`
* (trivial, every ingredient qualifies) or `"Sans gluten"` (derived from
* `allergenNames` instead see `IngredientDiet` in schema.prisma for
* why).
*/
dietNames?: string[];
}
// 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.
//
// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/
// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy
// ("rayons") reworked from an earlier, less intuitive scheme that mixed
// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones
// ("légumes") — an ingredient's category used to depend on which angle you
// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses
// this to offer category browsing, not just free-text search — with 400+
// ingredients, search alone doesn't scale to actually *finding* something,
// and a single flat list of 7 aisles alone wouldn't either (some aisles
// would be 100+ items deep). Each group's key is the single source of
// truth for that mapping; `INGREDIENTS` below just flattens it back to one
// array for the seeding loop.
//
// `defaultDiets`/`defaultIcon` are what every item in the group shares
// unless it sets its own `dietNames`/`icon` — most groups are homogeneous
// on both counts (a vegetable is always vegan and always looks like a
// vegetable; a cut of meat never is and never does), so this avoids
// repeating the same values on hundreds of items; only a group's
// exceptions (a fish-based stock inside "bases", a cheese inside "produits
// laitiers"…) need a per-item override.
const INGREDIENT_GROUPS: Array<{
category: IngredientCategory;
subcategory: IngredientSubcategory;
defaultDiets: string[];
defaultIcon: IngredientIcon;
items: IngredientSeed[];
}> = [
// =========================================================================
// Produits frais
// =========================================================================
{
category: "PRODUITS_FRAIS",
subcategory: "LEGUMES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "VEGETABLE",
items: [
{ name: "Tomate", allergenNames: [] },
{ name: "Oignon", allergenNames: [] },
{ name: "Échalote", allergenNames: [] },
{ name: "Ail", allergenNames: [] },
{ name: "Carotte", allergenNames: [] },
{ name: "Courgette", allergenNames: [] },
{ name: "Concombre", allergenNames: [] },
{ name: "Cornichons", allergenNames: [] },
{ name: "Poivron", allergenNames: [] },
{ name: "Champignon", allergenNames: [] },
{ name: "Cèpes", allergenNames: [] },
{ name: "Aubergine", allergenNames: [] },
{ name: "Brocoli", allergenNames: [] },
{ name: "Chou-fleur", allergenNames: [] },
{ name: "Chou blanc", allergenNames: [] },
{ name: "Chou rouge", allergenNames: [] },
{ name: "Chou de Bruxelles", allergenNames: [] },
{ name: "Épinard", allergenNames: [] },
{ name: "Blette", allergenNames: [] },
{ name: "Salade", allergenNames: [] },
{ name: "Roquette", allergenNames: [] },
{ name: "Cresson", allergenNames: [] },
{ name: "Poireau", allergenNames: [] },
{ name: "Céleri", allergenNames: ["Céleri"] },
{ name: "Radis", allergenNames: [] },
{ name: "Betterave", allergenNames: [] },
{ name: "Navet", allergenNames: [] },
{ name: "Panais", allergenNames: [] },
{ name: "Haricot vert", allergenNames: [] },
{ name: "Petit pois", allergenNames: [] },
{ name: "Maïs", allergenNames: [] },
{ name: "Artichaut", allergenNames: [] },
{ name: "Fenouil", allergenNames: [] },
{ name: "Endive", allergenNames: [] },
{ name: "Potiron", allergenNames: [] },
{ name: "Butternut", allergenNames: [] },
{ name: "Asperge", allergenNames: [] },
{ name: "Avocat", allergenNames: [] },
{ name: "Pomme de terre", allergenNames: [] },
{ name: "Patate douce", allergenNames: [] },
{ name: "Tomates cerises", allergenNames: [] },
{ name: "Pak-choï", allergenNames: [] },
{ name: "Germes de soja", allergenNames: ["Soja"] },
{ name: "Shiitake", allergenNames: [] },
{ name: "Daikon", allergenNames: [] },
{ name: "Piment vert frais", allergenNames: [] },
],
},
{
category: "PRODUITS_FRAIS",
subcategory: "FRUITS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "FRUIT",
items: [
{ name: "Citron", allergenNames: [] },
{ name: "Citron vert", allergenNames: [] },
{ name: "Pomme", allergenNames: [] },
{ name: "Poire", allergenNames: [] },
{ name: "Banane", allergenNames: [] },
{ name: "Orange", allergenNames: [] },
{ name: "Clémentine", allergenNames: [] },
{ name: "Pamplemousse", allergenNames: [] },
{ name: "Fraise", allergenNames: [] },
{ name: "Framboise", allergenNames: [] },
{ name: "Myrtille", allergenNames: [] },
{ name: "Mûre", allergenNames: [] },
{ name: "Cerise", allergenNames: [] },
{ name: "Abricot", allergenNames: [] },
{ name: "Pêche", allergenNames: [] },
{ name: "Prune", allergenNames: [] },
{ name: "Raisin", allergenNames: [] },
{ name: "Melon", allergenNames: [] },
{ name: "Pastèque", allergenNames: [] },
{ name: "Ananas", allergenNames: [] },
{ name: "Mangue", allergenNames: [] },
{ name: "Kiwi", allergenNames: [] },
{ name: "Figue", allergenNames: [] },
{ name: "Datte", allergenNames: [] },
{ name: "Litchi", allergenNames: [] },
{ name: "Grenade", allergenNames: [] },
{ name: "Rhubarbe", allergenNames: [] },
{ name: "Coing", allergenNames: [] },
],
},
{
category: "PRODUITS_FRAIS",
subcategory: "HERBES_FRAICHES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "HERB",
items: [
{ name: "Basilic", allergenNames: [] },
{ name: "Persil", allergenNames: [] },
{ name: "Thym", allergenNames: [] },
{ name: "Romarin", allergenNames: [] },
{ name: "Laurier", allergenNames: [] },
{ name: "Ciboulette", allergenNames: [] },
{ name: "Coriandre fraîche", allergenNames: [] },
{ name: "Menthe", allergenNames: [] },
{ name: "Origan", allergenNames: [] },
{ name: "Aneth", allergenNames: [] },
{ name: "Estragon", allergenNames: [] },
{ name: "Sarriette", allergenNames: [] },
{ name: "Marjolaine", allergenNames: [] },
{ name: "Sauge", allergenNames: [] },
{ name: "Cerfeuil", allergenNames: [] },
{ name: "Gingembre", allergenNames: [] },
{ name: "Citronnelle", allergenNames: [] },
{ name: "Combava", allergenNames: [] },
],
},
// =========================================================================
// Boucherie & poissonnerie
// =========================================================================
{
category: "BOUCHERIE_POISSONNERIE",
subcategory: "VIANDES",
defaultDiets: [],
defaultIcon: "MEAT",
items: [
{ name: "Lapin", allergenNames: [] },
{ name: "Bœuf haché", allergenNames: [] },
{ name: "Steak de bœuf", allergenNames: [] },
{ name: "Rôti de bœuf", allergenNames: [] },
{ name: "Escalope de veau", allergenNames: [] },
{ name: "Filet mignon de porc", allergenNames: [] },
{ name: "Côte de porc", allergenNames: [] },
{ name: "Agneau", allergenNames: [] },
{ name: "Gigot d'agneau", allergenNames: [] },
{ name: "Lardons", allergenNames: [] },
{ name: "Bacon", allergenNames: [] },
{ name: "Jambon blanc", allergenNames: [] },
{ name: "Jambon cru", allergenNames: [] },
{ name: "Saucisse", allergenNames: [] },
{ name: "Chorizo", allergenNames: [] },
{ name: "Merguez", allergenNames: [] },
{ name: "Prosciutto", allergenNames: [] },
{ name: "Pancetta", allergenNames: [] },
{ name: "Mortadelle", allergenNames: [] },
{ name: "Salami", allergenNames: [] },
],
},
{
category: "BOUCHERIE_POISSONNERIE",
subcategory: "VOLAILLES",
defaultDiets: [],
defaultIcon: "POULTRY",
items: [
{ name: "Poulet", allergenNames: [] },
{ name: "Dinde", allergenNames: [] },
{ name: "Canard", allergenNames: [] },
{ name: "Magret de canard", allergenNames: [] },
],
},
{
category: "BOUCHERIE_POISSONNERIE",
subcategory: "POISSONS",
defaultDiets: ["Pescétarien"],
defaultIcon: "FISH",
items: [
{ name: "Saumon", allergenNames: ["Poissons"] },
{ name: "Thon", allergenNames: ["Poissons"] },
{ name: "Cabillaud", allergenNames: ["Poissons"] },
{ name: "Truite", allergenNames: ["Poissons"] },
{ name: "Sardine", allergenNames: ["Poissons"] },
{ name: "Anchois", allergenNames: ["Poissons"] },
{ name: "Merlan", allergenNames: ["Poissons"] },
{ name: "Surimi", allergenNames: ["Poissons"] },
{ name: "Bar (loup de mer)", allergenNames: ["Poissons"] },
{ name: "Dorade", allergenNames: ["Poissons"] },
{ name: "Sole", allergenNames: ["Poissons"] },
{ name: "Turbot", allergenNames: ["Poissons"] },
{ name: "Merlu", allergenNames: ["Poissons"] },
{ name: "Colin", allergenNames: ["Poissons"] },
{ name: "Lieu noir", allergenNames: ["Poissons"] },
{ name: "Églefin", allergenNames: ["Poissons"] },
{ name: "Maquereau", allergenNames: ["Poissons"] },
{ name: "Hareng", allergenNames: ["Poissons"] },
{ name: "Rouget", allergenNames: ["Poissons"] },
{ name: "Raie", allergenNames: ["Poissons"] },
{ name: "Lotte", allergenNames: ["Poissons"] },
{ name: "Flétan", allergenNames: ["Poissons"] },
{ name: "Espadon", allergenNames: ["Poissons"] },
{ name: "Carpe", allergenNames: ["Poissons"] },
{ name: "Brochet", allergenNames: ["Poissons"] },
{ name: "Perche", allergenNames: ["Poissons"] },
{ name: "Tilapia", allergenNames: ["Poissons"] },
{ name: "Panga", allergenNames: ["Poissons"] },
{ name: "Saumon fumé", allergenNames: ["Poissons"] },
{ name: "Poisson séché", allergenNames: ["Poissons"] },
],
},
{
category: "BOUCHERIE_POISSONNERIE",
subcategory: "CRUSTACES_FRUITS_DE_MER",
defaultDiets: ["Pescétarien"],
defaultIcon: "SHELLFISH",
items: [
{ name: "Crevettes", allergenNames: ["Crustacés"] },
{ name: "Langoustines", allergenNames: ["Crustacés"] },
{ name: "Homard", allergenNames: ["Crustacés"] },
{ name: "Crabe", allergenNames: ["Crustacés"] },
{ name: "Langouste", allergenNames: ["Crustacés"] },
{ name: "Moules", allergenNames: ["Mollusques"] },
{ name: "Huîtres", allergenNames: ["Mollusques"] },
{ name: "Saint-Jacques", allergenNames: ["Mollusques"] },
{ name: "Calamar", allergenNames: ["Mollusques"] },
{ name: "Poulpe", allergenNames: ["Mollusques"] },
{ name: "Palourdes", allergenNames: ["Mollusques"] },
{ name: "Bulots", allergenNames: ["Mollusques"] },
],
},
// =========================================================================
// Épicerie sèche
// =========================================================================
{
category: "EPICERIE_SECHE",
subcategory: "FECULENTS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "GRAIN",
items: [
{ name: "Semoule", allergenNames: ["Gluten"] },
{ name: "Couscous", allergenNames: ["Gluten"] },
{ name: "Boulgour", allergenNames: ["Gluten"] },
{ name: "Polenta", allergenNames: [] },
{ name: "Quinoa", allergenNames: [] },
{ name: "Pâtes", allergenNames: ["Gluten"] },
{ name: "Pâtes complètes", allergenNames: ["Gluten"] },
{ name: "Riz", allergenNames: [] },
{ name: "Riz basmati", allergenNames: [] },
{ name: "Riz complet", allergenNames: [] },
{ name: "Flocons d'avoine", allergenNames: ["Gluten"] },
{ name: "Spaghetti", allergenNames: ["Gluten"] },
{ name: "Penne", allergenNames: ["Gluten"] },
{ name: "Tagliatelles", allergenNames: ["Gluten"] },
{ name: "Lasagnes (feuilles)", allergenNames: ["Gluten"] },
{ name: "Gnocchi", allergenNames: ["Gluten"] },
{ name: "Riz arborio", allergenNames: [] },
{ name: "Nouilles de riz", allergenNames: [] },
{ name: "Nouilles udon", allergenNames: ["Gluten"] },
{ name: "Nouilles soba", allergenNames: ["Gluten"] },
{ name: "Nouilles chinoises", allergenNames: ["Gluten"] },
{ name: "Vermicelles de riz", allergenNames: [] },
{ name: "Vermicelles de soja", allergenNames: [] },
{ name: "Riz gluant", allergenNames: [] },
{ name: "Riz à sushi", allergenNames: [] },
{ name: "Riz jasmin", allergenNames: [] },
],
},
{
category: "EPICERIE_SECHE",
subcategory: "LEGUMINEUSES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "LEGUME",
items: [
{ name: "Lentilles vertes", allergenNames: [] },
{ name: "Lentilles corail", allergenNames: [] },
{ name: "Pois chiches", allergenNames: [] },
{ name: "Haricots blancs", allergenNames: [] },
{ name: "Haricots rouges", allergenNames: [] },
{ name: "Haricots noirs", allergenNames: [] },
{ name: "Pois cassés", allergenNames: [] },
{ name: "Fèves", allergenNames: [] },
{ name: "Edamame", allergenNames: ["Soja"] },
{ name: "Haricots pinto", allergenNames: [] },
],
},
{
category: "EPICERIE_SECHE",
subcategory: "GRAINES_FRUITS_SECS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "NUT_SEED",
items: [
{ name: "Cacahuètes", allergenNames: ["Arachides"] },
{ name: "Amandes", allergenNames: ["Fruits à coque"] },
{ name: "Noix", allergenNames: ["Fruits à coque"] },
{ name: "Noisettes", allergenNames: ["Fruits à coque"] },
{ name: "Noix de cajou", allergenNames: ["Fruits à coque"] },
{ name: "Pistaches", allergenNames: ["Fruits à coque"] },
{ name: "Noix de pécan", allergenNames: ["Fruits à coque"] },
{ name: "Poudre d'amande", allergenNames: ["Fruits à coque"] },
{ name: "Pignons de pin", allergenNames: [] },
{ name: "Graines de tournesol", allergenNames: [] },
{ name: "Graines de courge", allergenNames: [] },
{ name: "Noix de coco râpée", allergenNames: [] },
{ name: "Raisins secs", allergenNames: ["Sulfites"] },
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
{ name: "Abricots secs", allergenNames: ["Sulfites"] },
{ name: "Graines de sésame", allergenNames: ["Graines de sésame"] },
],
},
{
category: "EPICERIE_SECHE",
subcategory: "AUTRES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "JAR",
items: [
{ name: "Champignons noirs", allergenNames: [] },
{ name: "Algue nori", allergenNames: [] },
{ name: "Algue wakamé", allergenNames: [] },
{ name: "Algue kombu", allergenNames: [] },
{ name: "Pousses de bambou", allergenNames: [] },
{ name: "Châtaignes d'eau", allergenNames: [] },
],
},
// =========================================================================
// Boulangerie
// =========================================================================
{
category: "BOULANGERIE",
subcategory: "PAINS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "BREAD",
items: [
{ name: "Pain", allergenNames: ["Gluten"] },
{ name: "Pain de mie", allergenNames: ["Gluten"] },
{ name: "Pain complet", allergenNames: ["Gluten"] },
{ name: "Baguette", allergenNames: ["Gluten"] },
{ name: "Pain de seigle", allergenNames: ["Gluten"] },
{ name: "Chapelure", allergenNames: ["Gluten"] },
{
name: "Pain à burger",
allergenNames: ["Gluten", "Lait", "Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Pain brioché",
allergenNames: ["Gluten", "Lait", "Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pain à hot-dog", allergenNames: ["Gluten"] },
{ name: "Pain pita", allergenNames: ["Gluten"] },
{ name: "Pain bagel", allergenNames: ["Gluten"] },
{ name: "Naan", allergenNames: ["Gluten"] },
{ name: "Pain wrap", allergenNames: ["Gluten"] },
{
name: "Pain viennois",
allergenNames: ["Gluten", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pain de campagne", allergenNames: ["Gluten"] },
{ name: "Pain aux céréales", allergenNames: ["Gluten"] },
{ name: "Petit pain", allergenNames: ["Gluten"] },
{ name: "Pain suédois", allergenNames: ["Gluten"] },
{ name: "Pain sans gluten", allergenNames: [] },
{ name: "Biscotte", allergenNames: ["Gluten"] },
{ name: "Croûtons", allergenNames: ["Gluten"] },
{ name: "Focaccia", allergenNames: ["Gluten"] },
{ name: "Ciabatta", allergenNames: ["Gluten"] },
{ name: "Tortilla de maïs", allergenNames: [] },
{ name: "Tortilla de blé", allergenNames: ["Gluten"] },
],
},
{
category: "BOULANGERIE",
subcategory: "PATES_A_CUIRE",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "DOUGH",
items: [
{
name: "Pâte feuilletée",
allergenNames: ["Gluten", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Pâte brisée",
allergenNames: ["Gluten", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pâte à pizza", allergenNames: ["Gluten"] },
{
name: "Pâte à tarte sablée",
allergenNames: ["Gluten", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
],
},
// =========================================================================
// Crémerie & fromage
// =========================================================================
{
category: "CREMERIE_FROMAGE",
subcategory: "PRODUITS_LAITIERS",
defaultDiets: ["Végétarien", "Pescétarien"],
defaultIcon: "MILK",
items: [
{ name: "Lait", allergenNames: ["Lait"] },
{ name: "Beurre", allergenNames: ["Lait"] },
{ name: "Crème fraîche", allergenNames: ["Lait"] },
{ name: "Crème liquide", allergenNames: ["Lait"] },
{ name: "Fromage", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Emmental", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Gruyère", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Parmesan", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Mozzarella", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Chèvre (fromage)", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Feta", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Comté", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Fromage blanc", allergenNames: ["Lait"] },
{ name: "Mascarpone", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Yaourt", allergenNames: ["Lait"] },
{ name: "Burrata", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Ricotta", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Pecorino", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Gorgonzola", icon: "CHEESE", allergenNames: ["Lait"] },
{ name: "Cheddar", icon: "CHEESE", allergenNames: ["Lait"] },
],
},
{
category: "CREMERIE_FROMAGE",
subcategory: "OEUFS",
defaultDiets: ["Végétarien", "Pescétarien"],
defaultIcon: "EGG",
items: [{ name: "Œuf", allergenNames: ["Œufs"] }],
},
{
category: "CREMERIE_FROMAGE",
subcategory: "ALTERNATIVES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "SPROUT",
items: [
{ name: "Lait de coco", icon: "MILK", allergenNames: [] },
{ name: "Crème de coco", icon: "MILK", allergenNames: [] },
{ name: "Lait d'amande", icon: "MILK", allergenNames: ["Fruits à coque"] },
{ name: "Lait d'avoine", icon: "MILK", allergenNames: ["Gluten"] },
{ name: "Tofu", allergenNames: ["Soja"] },
{ name: "Tofu soyeux", allergenNames: ["Soja"] },
],
},
// =========================================================================
// Condiments & épices
// =========================================================================
{
category: "CONDIMENTS_EPICES",
subcategory: "EPICES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "SPICE",
items: [
{ name: "Herbes de Provence", allergenNames: [] },
{ name: "Poivre noir", allergenNames: [] },
{ name: "Paprika", allergenNames: [] },
{ name: "Piment d'Espelette", allergenNames: [] },
{ name: "Piment de Cayenne", allergenNames: [] },
{ name: "Cumin", allergenNames: [] },
{ name: "Curry (poudre)", allergenNames: [] },
{ name: "Curcuma", allergenNames: [] },
{ name: "Cannelle", allergenNames: [] },
{ name: "Muscade", allergenNames: [] },
{ name: "Safran", allergenNames: [] },
{ name: "Clou de girofle", allergenNames: [] },
{ name: "Vanille (gousse)", allergenNames: [] },
{ name: "Poivre blanc", allergenNames: [] },
{ name: "Poivre rose", allergenNames: [] },
{ name: "Poivre du Sichuan", allergenNames: [] },
{ name: "Paprika fumé", allergenNames: [] },
{ name: "Piment oiseau", allergenNames: [] },
{ name: "Baies de genièvre", allergenNames: [] },
{ name: "Anis étoilé (badiane)", allergenNames: [] },
{ name: "Anis vert", allergenNames: [] },
{ name: "Graines de fenouil", allergenNames: [] },
{ name: "Sumac", allergenNames: [] },
{ name: "Nigelle", allergenNames: [] },
{ name: "Quatre épices", allergenNames: [] },
{ name: "Colombo (poudre)", allergenNames: [] },
{ name: "Baharat", allergenNames: [] },
{ name: "Raifort", allergenNames: [] },
{ name: "Sel aux herbes", allergenNames: [] },
{ name: "Sel de céleri", allergenNames: ["Céleri"] },
{ name: "Fleur de sel", allergenNames: [] },
{ name: "Sel", allergenNames: [] },
{ name: "Cinq épices", allergenNames: [] },
{ name: "Garam masala", allergenNames: [] },
{ name: "Graines de coriandre", allergenNames: [] },
{ name: "Cardamome", allergenNames: [] },
{ name: "Fenugrec", allergenNames: [] },
{ name: "Piment jalapeño", allergenNames: [] },
{ name: "Piment chipotle", allergenNames: [] },
{ name: "Piment poblano", allergenNames: [] },
{ name: "Piment habanero", allergenNames: [] },
{ name: "Ras el hanout", allergenNames: [] },
{ name: "Za'atar", allergenNames: ["Graines de sésame"] },
],
},
{
category: "CONDIMENTS_EPICES",
subcategory: "SAUCES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "JAR",
items: [
{ name: "Sauce soja", allergenNames: ["Soja"] },
{ name: "Moutarde", allergenNames: ["Moutarde"] },
{
name: "Mayonnaise",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Ketchup", allergenNames: [] },
{ name: "Tabasco", allergenNames: [] },
{
name: "Sauce Worcestershire",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{
name: "Sauce nuoc-mâm",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{ name: "Wasabi", allergenNames: [] },
{ name: "Harissa", allergenNames: [] },
{ name: "Pâte de curry", allergenNames: [] },
{ name: "Beurre de cacahuète", allergenNames: ["Arachides"] },
{ name: "Moutarde de Dijon", allergenNames: ["Moutarde"] },
{ name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] },
{ name: "Sauce barbecue", allergenNames: [] },
{
name: "Sauce tartare",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce cocktail",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce béarnaise",
allergenNames: ["Œufs", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce hollandaise",
allergenNames: ["Œufs", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce béchamel",
allergenNames: ["Lait", "Gluten"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Sauce teriyaki", allergenNames: ["Soja"] },
{
name: "Sauce ponzu",
allergenNames: ["Soja", "Poissons"],
dietNames: ["Pescétarien"],
},
{ name: "Chimichurri", allergenNames: [] },
{
name: "Pesto rouge (tomates séchées)",
allergenNames: ["Lait", "Fruits à coque"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Pesto",
allergenNames: ["Lait", "Fruits à coque"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce huître",
allergenNames: ["Mollusques"],
dietNames: ["Pescétarien"],
},
{ name: "Sauce hoisin", allergenNames: ["Soja"] },
{ name: "Sauce sriracha", allergenNames: [] },
{ name: "Sauce sweet chili", allergenNames: [] },
{ name: "Miso", allergenNames: ["Soja"] },
{
name: "Pâte de crevettes",
allergenNames: ["Crustacés"],
dietNames: ["Pescétarien"],
},
{ name: "Pâte de curry rouge (thaï)", allergenNames: [] },
{ name: "Pâte de curry vert (thaï)", allergenNames: [] },
{ name: "Tahini", allergenNames: ["Graines de sésame"] },
],
},
{
category: "CONDIMENTS_EPICES",
subcategory: "ASSAISONNEMENTS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "BOTTLE",
items: [
{ name: "Huile d'olive", allergenNames: [] },
{ name: "Huile de tournesol", allergenNames: [] },
{ name: "Huile de colza", allergenNames: [] },
{ name: "Huile de coco", allergenNames: [] },
{ name: "Huile de sésame", allergenNames: ["Graines de sésame"] },
{ name: "Vinaigre de cidre", allergenNames: [] },
{ name: "Vinaigre blanc", allergenNames: [] },
{ name: "Vinaigre balsamique", allergenNames: ["Sulfites"] },
{ name: "Câpres", icon: "JAR", allergenNames: [] },
{ name: "Olives", icon: "JAR", allergenNames: [] },
{ name: "Vin blanc (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
{ name: "Vin rouge (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de vin rouge", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de vin blanc", allergenNames: ["Sulfites"] },
{ name: "Vinaigre de xérès", allergenNames: ["Sulfites"] },
{ name: "Huile de noix", allergenNames: ["Fruits à coque"] },
{ name: "Huile de noisette", allergenNames: ["Fruits à coque"] },
{ name: "Huile d'arachide", allergenNames: ["Arachides"] },
{ name: "Huile pimentée", allergenNames: [] },
{ name: "Vinaigre de riz", allergenNames: [] },
{ name: "Mirin", icon: "DRINK", allergenNames: [] },
{ name: "Saké (cuisine)", icon: "DRINK", allergenNames: [] },
{ name: "Jus de citron", icon: "DRINK", allergenNames: [] },
{ name: "Jus de citron vert", icon: "DRINK", allergenNames: [] },
{ name: "Jus d'orange", icon: "DRINK", allergenNames: [] },
{ name: "Jus de pomme", icon: "DRINK", allergenNames: [] },
{ name: "Jus de raisin", icon: "DRINK", allergenNames: [] },
{ name: "Jus de tomate", icon: "DRINK", allergenNames: [] },
{ name: "Jus de cranberry", icon: "DRINK", allergenNames: [] },
{ name: "Café", icon: "DRINK", allergenNames: [] },
{ name: "Thé", icon: "DRINK", allergenNames: [] },
{ name: "Bière (cuisine)", icon: "DRINK", allergenNames: ["Gluten"] },
{ name: "Cidre (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
{
name: "Champagne / vin pétillant (cuisine)",
icon: "DRINK",
allergenNames: ["Sulfites"],
},
{ name: "Porto (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
{ name: "Vin jaune (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
{ name: "Cognac", icon: "DRINK", allergenNames: [] },
{ name: "Rhum", icon: "DRINK", allergenNames: [] },
{ name: "Whisky", icon: "DRINK", allergenNames: [] },
{ name: "Vodka", icon: "DRINK", allergenNames: [] },
],
},
// =========================================================================
// Aides culinaires
// =========================================================================
{
category: "AIDES_CULINAIRES",
subcategory: "BASES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "GRAIN",
items: [
{ name: "Farine de blé", allergenNames: ["Gluten"] },
{ name: "Farine complète", allergenNames: ["Gluten"] },
{ name: "Farine de maïs", allergenNames: [] },
{ name: "Farine de sarrasin", allergenNames: [] },
{ name: "Farine de riz", allergenNames: [] },
{ name: "Bouillon cube légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] },
{
name: "Bouillon cube volaille",
icon: "STOCK_POT",
allergenNames: ["Céleri"],
dietNames: [],
},
{ name: "Concentré de tomate", icon: "JAR", allergenNames: [] },
{ name: "Coulis de tomate", icon: "JAR", allergenNames: [] },
{ name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] },
{ name: "Tomates séchées", icon: "JAR", allergenNames: [] },
{ name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
{ name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
{
name: "Bouillon cube bœuf",
icon: "STOCK_POT",
allergenNames: ["Céleri"],
dietNames: [],
},
{
name: "Bouillon cube poisson",
icon: "STOCK_POT",
allergenNames: ["Poissons", "Céleri"],
dietNames: ["Pescétarien"],
},
{ name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] },
{
name: "Bouillon de volaille",
icon: "STOCK_POT",
allergenNames: ["Céleri"],
dietNames: [],
},
{ name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] },
{ name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] },
{
name: "Dashi (bouillon japonais)",
icon: "STOCK_POT",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{
name: "Bisque de crustacés",
icon: "STOCK_POT",
allergenNames: ["Crustacés"],
dietNames: ["Pescétarien"],
},
{ name: "Farine de tapioca", allergenNames: [] },
{ name: "Masa harina", allergenNames: [] },
{ name: "Eau", icon: "DRINK", allergenNames: [] },
{ name: "Eau gazeuse", icon: "DRINK", allergenNames: [] },
{ name: "Eau de fleur d'oranger", icon: "DRINK", allergenNames: [] },
{ name: "Eau de rose", icon: "DRINK", allergenNames: [] },
{
name: "Fumet de poisson",
icon: "STOCK_POT",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
],
},
{
category: "AIDES_CULINAIRES",
subcategory: "EPAISSISSANTS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "JAR",
items: [
{ name: "Levure boulangère", allergenNames: [] },
{ name: "Levure chimique", allergenNames: [] },
{ name: "Maïzena", allergenNames: [] },
{ name: "Farine de lupin", allergenNames: ["Lupin"] },
// Animal collagen (bones/skin, usually pork or beef) — not
// vegetarian/vegan, and not reliably fish-derived either, so no
// pescetarian flag.
{ name: "Gélatine", allergenNames: [], dietNames: [] },
{ name: "Bicarbonate de soude", allergenNames: [] },
{ name: "Fécule de pomme de terre", allergenNames: [] },
],
},
{
category: "AIDES_CULINAIRES",
subcategory: "SUCRES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
defaultIcon: "SUGAR",
items: [
{ name: "Sucre", allergenNames: [] },
{ name: "Miel", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] },
{ name: "Sirop d'érable", allergenNames: [] },
{ name: "Sucre roux", allergenNames: [] },
{ name: "Sucre glace", allergenNames: [] },
{ name: "Cassonade", allergenNames: [] },
{ name: "Chocolat noir", allergenNames: [] },
{
name: "Chocolat au lait",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Chocolat blanc",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pépites de chocolat", allergenNames: [] },
{ name: "Cacao en poudre", allergenNames: [] },
{ name: "Extrait de vanille", allergenNames: [] },
{ name: "Sucre de palme", allergenNames: [] },
{ name: "Sirop de sucre de canne", allergenNames: [] },
],
},
];
const INGREDIENTS: Array<
Omit<IngredientSeed, "icon" | "dietNames"> & {
category: IngredientCategory;
subcategory: IngredientSubcategory;
icon: IngredientIcon;
dietNames: string[];
}
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
items.map((item) => ({
...item,
category,
subcategory,
icon: item.icon ?? defaultIcon,
dietNames: item.dietNames ?? defaultDiets,
})),
);
/** /**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data upserts by * (safe to call against a database that already has this data upserts by
@ -58,4 +906,90 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
await prisma.allergy.create({ data: { categoryId: category.id } }); 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`/`category`/`subcategory` only for the rows where any
// of them 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 rows 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, category: true, subcategory: 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, category, subcategory }) => ({
name,
icon,
category,
subcategory,
})),
});
}
const changed = INGREDIENTS.filter((i) => {
const existing = existingByName.get(i.name);
return (
existing &&
(existing.icon !== i.icon ||
existing.category !== i.category ||
existing.subcategory !== i.subcategory)
);
});
for (const { name, icon, category, subcategory } of changed) {
await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } });
}
// 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 });
}
// Same bulk-insert approach as the allergy links above, resolved against
// `dietNames` (item override, falling back to its group's `defaultDiets`
// in the `INGREDIENTS` flatten step) instead of `allergenNames`.
const diets = await prisma.diet.findMany();
const dietIdByName = new Map(diets.map((d) => [d.name, d.id]));
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
for (const { name, dietNames } of INGREDIENTS) {
const ingredientId = ingredientIdByName.get(name);
if (ingredientId === undefined) continue;
for (const dietName of dietNames) {
const dietId = dietIdByName.get(dietName);
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
}
}
if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
}
} }

View file

@ -1,8 +1,18 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; 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 { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; 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. */ /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
export const profileRouter = Router(); export const profileRouter = Router();
@ -37,3 +47,26 @@ profileRouter.patch(
res.status(200).json(allergyIds); 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; 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,421 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type UpdateRecipeInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
function recipeInclude(viewerId: number) {
return {
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
},
},
steps: { orderBy: { order: "asc" } },
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return {
id: ingredient.id,
name: ingredient.name,
icon: ingredient.icon,
category: ingredient.category,
subcategory: ingredient.subcategory,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
};
}
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 { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express"; 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 * public (no `requireAuth`) this is static reference data, not
* per-household state, and the signup wizard (household/regime/allergen * per-household state, and the signup wizard (household/regime/allergen
* steps) needs to read it before an account and therefore a session * 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(); export const referenceRouter = Router();
@ -24,3 +26,10 @@ referenceRouter.get(
res.status(200).json(await getAllergies()); 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"; import { prisma } from "../../db/prisma.js";
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ /** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
@ -23,3 +23,33 @@ export async function getAllergies(): Promise<AllergyView[]> {
kind: allergy.category.kind, kind: allergy.category.kind,
})); }));
} }
/**
* All reference ingredients, alphabetically, each resolved to its allergens
* (see `IngredientAllergy` in schema.prisma) and compatible diet regimes
* (see `IngredientDiet`) same aplattening approach as {@link getAllergies}.
* Ingredients with no linked allergen/diet come back with `allergens: []`/
* `diets: []`.
*/
export async function getIngredients(): Promise<IngredientView[]> {
const ingredients = await prisma.ingredient.findMany({
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
orderBy: { name: "asc" },
});
return ingredients.map((ingredient) => ({
id: ingredient.id,
name: ingredient.name,
icon: ingredient.icon,
category: ingredient.category,
subcategory: ingredient.subcategory,
allergens: ingredient.allergies.map(({ allergy }) => ({
id: allergy.id,
name: allergy.category.name,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
}));
}

View file

@ -97,7 +97,9 @@ describe("Planning", () => {
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id; 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({ const planning = await prisma.planning.create({
data: { data: {
houseId, houseId,
@ -145,7 +147,9 @@ describe("Planning", () => {
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id; 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 nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {

View file

@ -125,4 +125,64 @@ describe("Profile", () => {
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND); 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,31 @@ describe("Reference data", () => {
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); 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",
"category",
"subcategory",
"allergens",
"diets",
]);
});
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

@ -28,6 +28,14 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
], ],
}); });
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
// The page also loads the reference ingredient list + the profile's
// disliked-ingredients selection for `DislikedIngredientsField` — added
// alongside `getDiets`/`getAllergies` in the same `Promise.all` (see
// PreferencesPage.tsx), so both need mocking here too or that `Promise.all`
// rejects and the whole page renders its error state instead of the form,
// taking `#diet`/the allergy checkboxes down with it.
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
}); });
it("loads the current regime, and shows allergies/intolerances as two groups", () => { it("loads the current regime, and shows allergies/intolerances as two groups", () => {

View file

@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout"; import { AppLayout } from "./layouts/AppLayout";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage"; import { PlanningPage } from "./pages/PlanningPage";
import { RecipeFormPage } from "./pages/RecipeFormPage";
import { RecipesPage } from "./pages/RecipesPage"; import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage"; import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage"; import { SignupPage } from "./pages/SignupPage";
@ -47,7 +48,14 @@ export function App() {
} }
> >
<Route path="/" element={<PlanningPage />} /> <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" 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="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} /> <Route path="/parametres/compte" element={<AccountSettingsPage />} />
<Route path="/parametres/preferences" element={<PreferencesPage />} /> <Route path="/parametres/preferences" element={<PreferencesPage />} />

View file

@ -1,15 +1,21 @@
import { import {
type AllergyView, type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type CreateRecipeInput,
type DietView, type DietView,
ErrorCode, ErrorCode,
type HouseView, type HouseView,
type IngredientView,
type LoginInput, type LoginInput,
type PlanningView, type PlanningView,
type PreferencesView, type PreferencesView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type SafeUserProfile, type SafeUserProfile,
type SignupInput, type SignupInput,
type ThemePreference, type ThemePreference,
type UpdateRecipeInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
/** /**
@ -132,6 +138,48 @@ export class ApiClient {
return this.request("/reference/allergies"); 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. */ /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> { public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current"); 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. */ /** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
public getPreferences(): Promise<PreferencesView> { public getPreferences(): Promise<PreferencesView> {
return this.request("/preferences"); return this.request("/preferences");

View file

@ -0,0 +1,72 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { IngredientPicker } from "../recipes/IngredientPicker";
// Reuses `IngredientPicker` verbatim (built for the recipe form's
// ingredient picker, features/recipes/) rather than a second search+browse
// field — same reference ingredient list, same category/search browsing,
// just without the recipe form's quantity/unit per line. Its own
// recipes.scss import already covers `.ingredient-picker`; 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 { IngredientTypeIcon } from "../recipes/ingredient-icons";
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">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
{ingredient.name}
<button
type="button"
onClick={() => remove(ingredient.id)}
title={t("preferences.form.removeDislikedIngredient")}
>
</button>
</li>
))}
</ul>
)}
<IngredientPicker 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,198 @@
import {
INGREDIENT_CATEGORIES,
INGREDIENT_CATEGORY_SUBCATEGORIES,
type IngredientCategory,
type IngredientSubcategory,
type IngredientView,
} from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SettingsIcon } from "../../layouts/nav-icons";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
import "./recipes.scss";
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
const ALL = "ALL" as const;
/**
* Browsable ingredient picker a two-level category/subcategory drill-down
* plus search plus a card grid, replacing the earlier `IngredientAutocomplete`
* (a plain type-to-filter dropdown). With 400+ reference ingredients, search
* alone doesn't scale to actually *finding* one, and a single flat list of
* 7 aisles wouldn't either (some "Condiments & épices" are 100+ items
* deep) so picking a category reveals a second row of subcategory chips
* scoped to it (reset whenever the category changes, since a subcategory
* from the previous one wouldn't apply). Search still narrows within (or
* across) whatever's selected for when the name is already known.
*
* Receives `ingredients` as a prop rather than fetching them itself same
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
* ingredients a recipe's ingredient list, or a profile's disliked list)
* keeps the same one from being added twice.
*
* The settings menu trailing the search input shows/hides the allergen/diet
* badge rows on every card a display preference local to this picker (not
* persisted), for whoever finds two rows of badges per card too noisy while
* just browsing/searching by name. A labeled checkbox menu rather than two
* bare icon-only toggle buttons those turned out too ambiguous on their
* own (unclear what each icon meant without a label attached).
*/
export function IngredientPicker({
ingredients,
excludeIds,
onSelect,
}: {
ingredients: IngredientView[];
excludeIds: number[];
onSelect: (ingredient: IngredientView) => void;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
const [subcategory, setSubcategory] = useState<IngredientSubcategory | typeof ALL>(ALL);
const [query, setQuery] = useState("");
// Purely a display preference for this picker's own card grid — doesn't
// touch which ingredients `visible` includes, only whether their
// allergen/diet badges render. Defaults to shown (the previous, only
// behavior); collapsing them is an opt-in for whoever finds two rows of
// badges per card too noisy while just browsing/searching by name.
const [showAllergens, setShowAllergens] = useState(true);
const [showDiets, setShowDiets] = useState(true);
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
function selectCategory(next: IngredientCategory | typeof ALL) {
setCategory(next);
setSubcategory(ALL);
}
const normalizedQuery = query.trim().toLowerCase();
const visible = ingredients.filter((ingredient) => {
if (excludeIds.includes(ingredient.id)) return false;
if (category !== ALL) {
if (ingredient.category !== category) return false;
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
}
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
return false;
}
return true;
});
function handleSelect(ingredient: IngredientView) {
onSelect(ingredient);
setQuery("");
}
return (
<div className="ingredient-picker">
<div className="ingredient-picker__search">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
/>
<div className="ingredient-picker__display-options">
<button
type="button"
className={`ingredient-picker__display-toggle${isDisplayMenuOpen ? " active" : ""}`}
onClick={() => setIsDisplayMenuOpen((v) => !v)}
aria-expanded={isDisplayMenuOpen}
title={t("recipes.form.displayOptions")}
>
<SettingsIcon />
</button>
{isDisplayMenuOpen && (
<div className="ingredient-picker__display-menu">
<label className={showDiets ? "is-selected" : ""}>
<input
type="checkbox"
checked={showDiets}
onChange={(e) => setShowDiets(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
{t("recipes.form.showDietsLabel")}
</label>
<label className={showAllergens ? "is-selected" : ""}>
<input
type="checkbox"
checked={showAllergens}
onChange={(e) => setShowAllergens(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
{t("recipes.form.showAllergensLabel")}
</label>
</div>
)}
</div>
</div>
<div className="ingredient-picker__categories">
<button
type="button"
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
onClick={() => selectCategory(ALL)}
>
{t("recipes.form.allCategories")}
</button>
{INGREDIENT_CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={`ingredient-picker__category${category === c ? " active" : ""}`}
onClick={() => selectCategory(c)}
>
<CategoryIcon category={c} />
{t(`recipes.form.category.${c}`)}
</button>
))}
</div>
{category !== ALL && (
<div className="ingredient-picker__subcategories">
<button
type="button"
className={`ingredient-picker__subcategory${subcategory === ALL ? " active" : ""}`}
onClick={() => setSubcategory(ALL)}
>
{t("recipes.form.allSubcategories")}
</button>
{INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => (
<button
key={s}
type="button"
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
onClick={() => setSubcategory(s)}
>
<SubcategoryIcon subcategory={s} />
{t(`recipes.form.subcategory.${s}`)}
</button>
))}
</div>
)}
{visible.length === 0 ? (
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
) : (
<div className="ingredient-picker__grid">
{visible.map((ingredient) => (
<button
key={ingredient.id}
type="button"
className="ingredient-picker__card"
onClick={() => handleSelect(ingredient)}
>
<span className="ingredient-picker__card-icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-picker__card-name">{ingredient.name}</span>
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
{showDiets && <DietBadges diets={ingredient.diets} />}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import { IngredientTypeIcon } from "./ingredient-icons";
import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) 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">
<IngredientTypeIcon icon={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} />
<DietBadges diets={ingredient.diets} />
<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,360 @@
import type {
IngredientCategory,
IngredientIcon as IngredientIconType,
IngredientSubcategory,
} from "@batch-cooking/shared";
import type { ReactNode } from "react";
// Generic pictograms for ingredients — one per `IngredientIcon` value
// (schema.prisma), reused across the category chips, subcategory chips and
// ingredient cards in `IngredientPicker`/`IngredientRow`. Replaces the
// earlier per-ingredient emoji (437 different characters, one per
// ingredient) with a small, hand-drawn vocabulary grouped by *what kind of
// thing* an ingredient is (a vegetable, a bottle of oil, a wedge of
// cheese…) rather than a literal picture of each one — 437 unique,
// professional icons isn't a realistic hand-drawn set, but ~20 generic
// shapes reused across them is. Same 24×24, stroke-only house style as
// `layouts/nav-icons.tsx` (kept separate from that file since these are
// ingredient-domain, not app-nav).
function Icon({ children }: { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
/** Carrot — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */
export function VegetableIcon() {
return (
<Icon>
<path d="M12 3c1.5 1 2 2.5 1.3 4M15 2c1 1.2 1 2.6 0 4" />
<path d="M13.3 7c2.8 0 4.7 2.9 4.1 5.6L15.8 20a2 2 0 0 1-3.9.2L9.2 12.6C8.3 9.9 10.3 7 13.3 7z" />
</Icon>
);
}
/** Apple — `PRODUITS_FRAIS`/`FRUITS`. */
export function FruitIcon() {
return (
<Icon>
<path d="M12 8c-.5-1.3-.2-3 1-4" />
<path d="M12 8c-4 0-6.5 3-6.5 7a6.5 6.5 0 0 0 13 0C18.5 11 16 8 12 8z" />
</Icon>
);
}
/** A leaf sprig — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */
export function HerbIcon() {
return (
<Icon>
<path d="M12 21V7" />
<path d="M12 8c0-3 2-5 5-5 0 3-2 5-5 5z" />
<path d="M12 13c0-3-2-5-5-5 0 3 2 5 5 5z" />
<path d="M12 18c0-2.5 1.7-4 4-4 0 2.5-1.7 4-4 4z" />
</Icon>
);
}
/** A cut of meat — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */
export function MeatIcon() {
return (
<Icon>
<path d="M8 16 17 7a3 3 0 1 1 4 4l-9 9a5 5 0 0 1-8-2 5 5 0 0 1 4-4z" />
<circle cx="9" cy="15" r="1.2" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Drumstick — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */
export function PoultryIcon() {
return (
<Icon>
<path d="M13 4c2.5 0 4.5 2 4.5 4.5 0 2.8-2.3 4.8-4 6.4l-3.2 3.2a2.3 2.3 0 0 1-3.3-3.3l3.2-3.2C11.7 10 13.7 7.7 13 4z" />
<path d="M6.5 21 5 22.5" />
<path d="M9 18.5 7.5 20" />
</Icon>
);
}
/** Fish — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */
export function FishIcon() {
return (
<Icon>
<path d="M3 12c3-4 8-6 13-4 2.5 1 5 3 5 4s-2.5 3-5 4c-5 2-10 0-13-4z" />
<path d="M16 9v6" />
<circle cx="6.5" cy="11" r=".6" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Shrimp — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */
export function ShellfishIcon() {
return (
<Icon>
<path d="M6 6c6-2 12 1 13 7-4 2-9 2-12-1C5 10 4.5 8 6 6z" />
<path d="M6 6 4 4M7 8 5 7M8 11l-2.2.6" />
</Icon>
);
}
/** Wheat ear — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */
export function GrainIcon() {
return (
<Icon>
<path d="M12 22V6" />
<path d="M12 7c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 7c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 11c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 11c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 15c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 15c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
<path d="M12 6c0-1.5.8-2.8 2-3.5" />
</Icon>
);
}
/** Bean pod — `EPICERIE_SECHE`/`LEGUMINEUSES`. */
export function LegumeIcon() {
return (
<Icon>
<path d="M5 13c0-5 3.5-9 9-9 1 3-1 5-1 5s3 0 5 3c0 5-4 9-9 9-3 0-4-4-4-8z" />
<circle cx="9.5" cy="14.5" r="1" fill="currentColor" stroke="none" />
<circle cx="13" cy="11" r="1" fill="currentColor" stroke="none" />
</Icon>
);
}
/** Acorn — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */
export function NutSeedIcon() {
return (
<Icon>
<path d="M8 9c0-3 1.8-5 4-5s4 2 4 5" />
<path d="M8 9c-1.3.5-2 1.7-2 3 0 4 2.7 8 6 8s6-4 6-8c0-1.3-.7-2.5-2-3z" />
</Icon>
);
}
/** A loaf, scored on top — `BOULANGERIE`/`PAINS`. */
export function BreadIcon() {
return (
<Icon>
<path d="M4 12c0-4.4 3.6-8 8-8s8 3.6 8 8v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path d="M9 8l1.5 3M14 7.5 15.5 11" />
</Icon>
);
}
/** Rolling pin — raw, uncooked pastry (`BOULANGERIE`/`PATES_A_CUIRE`). */
export function DoughIcon() {
return (
<Icon>
<rect x="6" y="10" width="12" height="4" rx="2" />
<circle cx="4" cy="12" r="2" />
<circle cx="20" cy="12" r="2" />
</Icon>
);
}
/** A glass of milk — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */
export function MilkIcon() {
return (
<Icon>
<path d="M9 2h6l1 4-1 2v11a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V8L8 6z" />
<path d="M8 12h8" />
</Icon>
);
}
/** A wedge of cheese with holes — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */
export function CheeseIcon() {
return (
<Icon>
<path d="M3 18 20 8l1 3-3 9H4z" />
<circle cx="10" cy="15" r="1" fill="currentColor" stroke="none" />
<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />
<circle cx="14" cy="17" r="1" fill="currentColor" stroke="none" />
</Icon>
);
}
/** An egg — `CREMERIE_FROMAGE`/`OEUFS`. */
export function EggIcon() {
return (
<Icon>
<path d="M12 3C8 8 6 12.5 6 15.5a6 6 0 0 0 12 0C18 12.5 16 8 12 3z" />
</Icon>
);
}
/** A seedling — plant-based dairy/meat alternatives (`CREMERIE_FROMAGE`/`ALTERNATIVES`). */
export function SproutIcon() {
return (
<Icon>
<path d="M12 21v-9" />
<path d="M12 12C7 12 5 9 5 5c5 0 7 2 7 7z" />
<path d="M12 12c4 0 6-2.5 6-6-4 0-6 1.5-6 6z" />
</Icon>
);
}
/** A shaker — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */
export function SpiceIcon() {
return (
<Icon>
<rect x="8" y="8" width="8" height="13" rx="3" />
<path d="M10 8V5a2 2 0 0 1 4 0v3" />
<path d="M11 12h.01M13 12h.01M10.5 15h.01M13.5 15h.01M12 18h.01" />
</Icon>
);
}
/** A condiment jar — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */
export function JarIcon() {
return (
<Icon>
<rect x="6" y="9" width="12" height="12" rx="2" />
<path d="M9 9V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
<path d="M6 13h12" />
</Icon>
);
}
/** A bottle — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */
export function BottleIcon() {
return (
<Icon>
<path d="M10 2h4v3.5l1.5 2V20a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V7.5L10 5.5z" />
<path d="M9.5 11h5" />
</Icon>
);
}
/** A glass — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */
export function DrinkIcon() {
return (
<Icon>
<path d="M6 3h12l-1.5 16.5A2 2 0 0 1 14.5 21h-5a2 2 0 0 1-2-1.5z" />
<path d="M7 9h10" />
</Icon>
);
}
/** A stockpot — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */
export function StockPotIcon() {
return (
<Icon>
<rect x="4" y="10" width="16" height="10" rx="2" />
<path d="M2 12h2M20 12h2" />
<path d="M9 10V7a3 3 0 0 1 6 0v3" />
</Icon>
);
}
/** A sugar cube — `AIDES_CULINAIRES`/`SUCRES`. */
export function SugarIcon() {
return (
<Icon>
<path d="M4 8 12 4l8 4-8 4z" />
<path d="M4 8v8l8 4 8-4V8" />
<path d="M12 12v8" />
</Icon>
);
}
/** Every icon component, keyed by {@link IngredientIconType} — the single lookup `IngredientPicker`/`IngredientRow`/category chips render through. */
export const INGREDIENT_ICON_COMPONENTS: Record<IngredientIconType, () => JSX.Element> = {
VEGETABLE: VegetableIcon,
FRUIT: FruitIcon,
HERB: HerbIcon,
MEAT: MeatIcon,
POULTRY: PoultryIcon,
FISH: FishIcon,
SHELLFISH: ShellfishIcon,
GRAIN: GrainIcon,
LEGUME: LegumeIcon,
NUT_SEED: NutSeedIcon,
BREAD: BreadIcon,
DOUGH: DoughIcon,
MILK: MilkIcon,
CHEESE: CheeseIcon,
EGG: EggIcon,
SPROUT: SproutIcon,
SPICE: SpiceIcon,
JAR: JarIcon,
BOTTLE: BottleIcon,
DRINK: DrinkIcon,
STOCK_POT: StockPotIcon,
SUGAR: SugarIcon,
};
/** Renders the generic pictogram for one {@link IngredientIconType} — the actual component `IngredientPicker`'s cards and `IngredientRow` use, rather than importing {@link INGREDIENT_ICON_COMPONENTS} directly everywhere. */
export function IngredientTypeIcon({ icon }: { icon: IngredientIconType }) {
const Component = INGREDIENT_ICON_COMPONENTS[icon];
return <Component />;
}
/**
* One representative {@link IngredientIconType} per {@link IngredientCategory}
* aisle, for `IngredientPicker`'s top-level category chips a category
* mixes several icon types (e.g. "Aides culinaires" has flour, stock, and
* sugar items), so this is a deliberate, illustrative pick rather than a
* derived fact.
*/
export const CATEGORY_ICON: Record<IngredientCategory, IngredientIconType> = {
PRODUITS_FRAIS: "VEGETABLE",
BOUCHERIE_POISSONNERIE: "MEAT",
EPICERIE_SECHE: "GRAIN",
BOULANGERIE: "BREAD",
CREMERIE_FROMAGE: "CHEESE",
CONDIMENTS_EPICES: "SPICE",
AIDES_CULINAIRES: "STOCK_POT",
};
/** Renders {@link CATEGORY_ICON}'s pictogram for one category — used by the category chip row. */
export function CategoryIcon({ category }: { category: IngredientCategory }) {
return <IngredientTypeIcon icon={CATEGORY_ICON[category]} />;
}
/**
* One representative {@link IngredientIconType} per {@link IngredientSubcategory}
* rack, for `IngredientPicker`'s second-tier subcategory chips. Most map
* 1:1 onto their subcategory's dominant shape; a couple of heterogeneous
* subcategories (`ASSAISONNEMENTS` mixes oils with juices and coffee,
* `BASES` mixes flour with stock and canned tomato) get one illustrative
* pick rather than a derived fact, same reasoning as {@link CATEGORY_ICON}.
*/
export const SUBCATEGORY_ICON: Record<IngredientSubcategory, IngredientIconType> = {
LEGUMES: "VEGETABLE",
FRUITS: "FRUIT",
HERBES_FRAICHES: "HERB",
VIANDES: "MEAT",
VOLAILLES: "POULTRY",
POISSONS: "FISH",
CRUSTACES_FRUITS_DE_MER: "SHELLFISH",
FECULENTS: "GRAIN",
LEGUMINEUSES: "LEGUME",
GRAINES_FRUITS_SECS: "NUT_SEED",
AUTRES: "JAR",
PAINS: "BREAD",
PATES_A_CUIRE: "DOUGH",
PRODUITS_LAITIERS: "MILK",
OEUFS: "EGG",
ALTERNATIVES: "SPROUT",
EPICES: "SPICE",
SAUCES: "JAR",
ASSAISONNEMENTS: "BOTTLE",
BASES: "STOCK_POT",
EPAISSISSANTS: "JAR",
SUCRES: "SUGAR",
};
/** Renders {@link SUBCATEGORY_ICON}'s pictogram for one subcategory — used by the subcategory chip row. */
export function SubcategoryIcon({ subcategory }: { subcategory: IngredientSubcategory }) {
return <IngredientTypeIcon icon={SUBCATEGORY_ICON[subcategory]} />;
}

File diff suppressed because it is too large Load diff

View file

@ -305,6 +305,17 @@
// viewport (relevant early given this app is meant to be embedded via // viewport (relevant early given this app is meant to be embedded via
// Capacitor later, see the root README) collapse it into a horizontal // Capacitor later, see the root README) collapse it into a horizontal
// top bar instead of a side rail. // top bar instead of a side rail.
//
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
// legible and tappable at any width it's the app's main navigation, not
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
// (measured on a 375px viewport) invisible labels, no real tap target.
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
// look as the desktop rail's `.collapsed` state), freeing width for the
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
// than shrinking if it still doesn't fit.
@media (max-width: 640px) { @media (max-width: 640px) {
.app-layout { .app-layout {
flex-direction: column; flex-direction: column;
@ -326,15 +337,75 @@
padding: 0; padding: 0;
} }
// Nothing to collapse into on a horizontal bar there's no rail.
&__collapse-toggle {
display: none;
}
&__nav { &__nav {
flex: 1 1 auto;
min-width: 0;
flex-direction: row; flex-direction: row;
overflow-x: auto; overflow-x: auto;
a {
flex-shrink: 0;
}
} }
&__settings, &__settings,
&__footer { &__footer {
position: relative;
flex: none;
padding-top: 0; padding-top: 0;
border-top: none; border-top: none;
} }
&__settings-toggle,
&__account-toggle {
padding-left: var(--space-xs);
padding-right: var(--space-xs);
}
&__settings-toggle-left .label,
&__account-toggle .label,
&__settings-toggle .chevron {
display: none;
}
// Both reveals become floating panels anchored under their icon-only
// toggle on the desktop rail one is inline (settings, stacks fine in
// a column) and the other already floats (account); neither can stay
// in normal flow on this horizontal bar without breaking the row.
// Selector order matters: `&__settings-nav` also carries the plain
// `&__nav` class (for the link styling), so this must come after it to
// win on `flex-direction`/`overflow-x`.
&__settings-nav {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
flex-direction: column;
width: 14rem;
padding: var(--space-xs);
overflow-x: visible;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
z-index: 10;
}
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
// rule above) because the footer sits at the bottom of a tall rail
// on this horizontal top bar the footer is near `y: 0`, so "upward"
// pushed the menu entirely off-screen above the viewport. Flip it to
// open downward here instead.
&__account-menu {
top: calc(100% + var(--space-xs));
bottom: auto;
left: auto;
right: 0;
width: 12rem;
}
} }
} }

View file

@ -108,3 +108,23 @@ export function ChevronLeftIcon() {
</Icon> </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é", "NOT_AUTHENTICATED": "Vous devez être connecté",
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer", "ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça", "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", "NOT_FOUND": "Ressource introuvable",
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer", "HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas", "DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés 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", "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" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
"auth": { "auth": {
@ -119,7 +123,102 @@
}, },
"recipes": { "recipes": {
"title": "Recettes", "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)",
"searchIngredientPlaceholder": "Rechercher un ingrédient…",
"displayOptions": "Options d'affichage",
"showDietsLabel": "Régimes alimentaires",
"showAllergensLabel": "Allergènes",
"allCategories": "Tout",
"allSubcategories": "Tout",
"noIngredientFound": "Aucun ingrédient trouvé.",
"category": {
"PRODUITS_FRAIS": "Produits frais",
"BOUCHERIE_POISSONNERIE": "Boucherie & poissonnerie",
"EPICERIE_SECHE": "Épicerie sèche",
"BOULANGERIE": "Boulangerie",
"CREMERIE_FROMAGE": "Crémerie & fromage",
"CONDIMENTS_EPICES": "Condiments & épices",
"AIDES_CULINAIRES": "Aides culinaires"
},
"subcategory": {
"LEGUMES": "Légumes",
"FRUITS": "Fruits",
"HERBES_FRAICHES": "Herbes fraîches",
"VIANDES": "Viandes",
"VOLAILLES": "Volailles",
"POISSONS": "Poissons",
"CRUSTACES_FRUITS_DE_MER": "Crustacés & fruits de mer",
"FECULENTS": "Féculents",
"LEGUMINEUSES": "Légumineuses",
"GRAINES_FRUITS_SECS": "Graines & fruits secs",
"AUTRES": "Autres",
"PAINS": "Pains",
"PATES_A_CUIRE": "Pâtes à cuire",
"PRODUITS_LAITIERS": "Produits laitiers",
"OEUFS": "Œufs",
"ALTERNATIVES": "Alternatives végétales",
"EPICES": "Épices",
"SAUCES": "Sauces",
"ASSAISONNEMENTS": "Assaisonnements",
"BASES": "Bases",
"EPAISSISSANTS": "Épaississants",
"SUCRES": "Sucres"
},
"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": { "shoppingList": {
"title": "Liste de courses", "title": "Liste de courses",
@ -147,7 +246,9 @@
"dietLabel": "Régime alimentaire", "dietLabel": "Régime alimentaire",
"dietNone": "Aucun régime particulier", "dietNone": "Aucun régime particulier",
"allergiesLabel": "Allergies", "allergiesLabel": "Allergies",
"intolerancesLabel": "Intolérances" "intolerancesLabel": "Intolérances",
"dislikedIngredientsLabel": "Aliments que vous n'aimez pas",
"removeDislikedIngredient": "Retirer cet aliment"
} }
}, },
"userPreferences": { "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 { IngredientPicker } from "../features/recipes/IngredientPicker";
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>
<IngredientPicker
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 { 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() { export function RecipesPage() {
const { t } = useTranslation(); 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 { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
import { AllergySelect } from "../../features/profile/AllergySelect"; import { AllergySelect } from "../../features/profile/AllergySelect";
import { DietSelect } from "../../features/profile/DietSelect"; import { DietSelect } from "../../features/profile/DietSelect";
import { DislikedIngredientsField } from "../../features/profile/DislikedIngredientsField";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss"; import "./settings-pages.scss";
@ -44,6 +50,12 @@ export function PreferencesPage() {
const [allergySaveError, setAllergySaveError] = useState<string | null>(null); const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
const allergiesTimeout = useRef<number | undefined>(undefined); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be // `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
@ -55,15 +67,28 @@ export function PreferencesPage() {
apiClient.getDiets(), apiClient.getDiets(),
apiClient.getAllergies(), apiClient.getAllergies(),
apiClient.getAllergyIds(), apiClient.getAllergyIds(),
apiClient.getIngredients(),
apiClient.getDislikedIngredientIds(),
apiClient.me(), apiClient.me(),
]) ])
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => { .then(
([
dietsResult,
allergiesResult,
allergyIdsResult,
ingredientsResult,
dislikedResult,
profile,
]) => {
if (cancelled) return; if (cancelled) return;
setDiets(dietsResult); setDiets(dietsResult);
setAllergies(allergiesResult); setAllergies(allergiesResult);
setAllergyIds(allergyIdsResult); setAllergyIds(allergyIdsResult);
setIngredients(ingredientsResult);
setDislikedIngredientIds(dislikedResult);
setDietId(profile.dietId); setDietId(profile.dietId);
}) },
)
.catch(() => { .catch(() => {
if (!cancelled) setLoadError(true); if (!cancelled) setLoadError(true);
}) })
@ -80,6 +105,7 @@ export function PreferencesPage() {
useEffect(() => { useEffect(() => {
return () => { return () => {
window.clearTimeout(allergiesTimeout.current); window.clearTimeout(allergiesTimeout.current);
window.clearTimeout(dislikedTimeout.current);
}; };
}, []); }, []);
@ -116,6 +142,22 @@ export function PreferencesPage() {
}, ALLERGIES_DEBOUNCE_MS); }, 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) { if (isLoading) {
return ( return (
<div className="settings-page"> <div className="settings-page">
@ -160,6 +202,15 @@ export function PreferencesPage() {
/> />
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} /> <SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
</div> </div>
<div className="settings-page__section">
<DislikedIngredientsField
ingredients={ingredients}
value={dislikedIngredientIds}
onChange={handleDislikedIngredientIdsChange}
/>
<SaveStatus state={dislikedSaveState} error={dislikedSaveError} t={t} />
</div>
</div> </div>
); );
} }

View file

@ -36,8 +36,12 @@ export enum ErrorCode {
NOT_AUTHENTICATED = 4011, NOT_AUTHENTICATED = 4011,
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */ /** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
ALREADY_HAS_HOUSE = 4020, 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. */ /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030, 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. */ /** No route/resource matches the request. */
NOT_FOUND = 4040, NOT_FOUND = 4040,
/** The profile making the request has no household yet (`houseId` is `null`). */ /** The profile making the request has no household yet (`houseId` is `null`). */
@ -48,6 +52,10 @@ export enum ErrorCode {
ALLERGY_NOT_FOUND = 4043, ALLERGY_NOT_FOUND = 4043,
/** `POST /house/join`'s `inviteCode` doesn't match any household. */ /** `POST /house/join`'s `inviteCode` doesn't match any household. */
INVITE_CODE_NOT_FOUND = 4044, 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. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -10,9 +10,11 @@ export * from "./schemas/household.js";
export * from "./schemas/planning.js"; export * from "./schemas/planning.js";
export * from "./schemas/preferences.js"; export * from "./schemas/preferences.js";
export * from "./schemas/profile.js"; export * from "./schemas/profile.js";
export * from "./schemas/recipe.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";
export * from "./types/recipe.js";
export * from "./types/reference.js"; export * from "./types/reference.js";
export * from "./types/user-profile.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. */ /** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>; 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,146 @@ export interface AllergyView {
name: string; name: string;
kind: AllergenKind; kind: AllergenKind;
} }
/**
* Supermarket-aisle grouping ("rayons") mirrors `IngredientCategory` in
* schema.prisma, declared by hand for the same reason as
* {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
* `IngredientPicker`) offer category browsing, not just free-text search:
* with 400+ reference ingredients, search alone doesn't scale to actually
* *finding* one. See {@link INGREDIENT_SUBCATEGORIES} for the finer-grained
* rack within each aisle.
*/
export const INGREDIENT_CATEGORIES = [
"PRODUITS_FRAIS",
"BOUCHERIE_POISSONNERIE",
"EPICERIE_SECHE",
"BOULANGERIE",
"CREMERIE_FROMAGE",
"CONDIMENTS_EPICES",
"AIDES_CULINAIRES",
] as const;
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
/**
* Finer-grained rack within one {@link IngredientCategory} aisle mirrors
* `IngredientSubcategory` in schema.prisma. See
* {@link INGREDIENT_CATEGORY_SUBCATEGORIES} for which of these belongs to
* which category, and in what display order.
*/
export const INGREDIENT_SUBCATEGORIES = [
"LEGUMES",
"FRUITS",
"HERBES_FRAICHES",
"VIANDES",
"VOLAILLES",
"POISSONS",
"CRUSTACES_FRUITS_DE_MER",
"FECULENTS",
"LEGUMINEUSES",
"GRAINES_FRUITS_SECS",
"AUTRES",
"PAINS",
"PATES_A_CUIRE",
"PRODUITS_LAITIERS",
"OEUFS",
"ALTERNATIVES",
"EPICES",
"SAUCES",
"ASSAISONNEMENTS",
"BASES",
"EPAISSISSANTS",
"SUCRES",
] as const;
/** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */
export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number];
/**
* Which {@link INGREDIENT_SUBCATEGORIES} belong to which
* {@link INGREDIENT_CATEGORIES} aisle, in the order the picker should list
* them the two-level drill-down `IngredientPicker` (`apps/web`) needs
* this to know which subcategory chips to offer once a category is picked.
* Mirrors `apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`,
* which is the actual source of truth for which *ingredient* belongs to
* which pair this is just the categorysubcategory shape of that data,
* duplicated here since the seed file isn't reachable from `apps/web`.
*/
export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
IngredientCategory,
readonly IngredientSubcategory[]
> = {
PRODUITS_FRAIS: ["LEGUMES", "FRUITS", "HERBES_FRAICHES"],
BOUCHERIE_POISSONNERIE: ["VIANDES", "VOLAILLES", "POISSONS", "CRUSTACES_FRUITS_DE_MER"],
EPICERIE_SECHE: ["FECULENTS", "LEGUMINEUSES", "GRAINES_FRUITS_SECS", "AUTRES"],
BOULANGERIE: ["PAINS", "PATES_A_CUIRE"],
CREMERIE_FROMAGE: ["PRODUITS_LAITIERS", "OEUFS", "ALTERNATIVES"],
CONDIMENTS_EPICES: ["EPICES", "SAUCES", "ASSAISONNEMENTS"],
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"],
};
/**
* Generic pictogram *type* for an ingredient mirrors `IngredientIcon` in
* schema.prisma, declared by hand for the same reason as
* {@link AllergenKind}. Was a free-text emoji per ingredient (437 distinct
* characters); replaced with this small, shared vocabulary of ~20 generic
* shapes ("a vegetable", "a bottle", "a wedge of cheese") so
* `apps/web`'s `features/recipes/ingredient-icons.tsx` can render a real
* SVG line icon per ingredient instead see that file for the actual
* pictograms and the full reasoning.
*/
export const INGREDIENT_ICONS = [
"VEGETABLE",
"FRUIT",
"HERB",
"MEAT",
"POULTRY",
"FISH",
"SHELLFISH",
"GRAIN",
"LEGUME",
"NUT_SEED",
"BREAD",
"DOUGH",
"MILK",
"CHEESE",
"EGG",
"SPROUT",
"SPICE",
"JAR",
"BOTTLE",
"DRINK",
"STOCK_POT",
"SUGAR",
] as const;
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
/**
* 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. `diets` is resolved from `IngredientDiet` the same way the
* regimes this ingredient is compatible with (e.g. `Végétarien`, `Végan`),
* so the picker can flag it without the user opening its packaging. Omits
* `Omnivore` (every ingredient qualifies, so it's never stored) and
* `Sans gluten` (already derivable from whether `allergens` contains
* `Gluten` see `IngredientDiet` in schema.prisma). Used by the recipe
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
* to surface which allergens/regimes a recipe contains, aggregated across
* its ingredients.
*/
export interface IngredientView {
id: number;
name: string;
icon: IngredientIcon;
category: IngredientCategory;
subcategory: IngredientSubcategory;
allergens: AllergyView[];
diets: DietView[];
}