From acab18ac4aed08fa72198a882f658b1840d28cde Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 18 Aug 2026 09:29:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20catalogue=20v2=20-=20visibilit?= =?UTF-8?q?=C3=A9,=20favoris,=20r=C3=A9gimes=20et=20catalogue=20d'ingr?= =?UTF-8?q?=C3=A9dients=20exhaustif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipe catalog v2: - Recipe gagne visibility (PERSONAL/HOUSE/PUBLIC), authorId, authorHouseId - Favoris par utilisateur (RecipeFavorite), régimes associés (RecipeDiet) - Aliments "pas aimés" par utilisateur (UserProfileDislikedIngredient), distinct des allergies médicales - API: GET /recipes?tab=favoris|perso|foyer|publique avec contrôle d'accès complet, POST/DELETE /recipes/:id/favorite, édition/suppression réservées à l'auteur (403 NOT_RECIPE_AUTHOR), GET/PATCH /profile/disliked-ingredients - Frontend: vue maître-détail (onglets + tableau + panneau détail), formulaire enrichi (visibilité, régimes), section préférences pour les aliments pas aimés Catalogue d'ingrédients de référence: - Extension du seed de 39 à ~430 ingrédients (viandes, poissons/fruits de mer, légumes, fruits, féculents, condiments/sauces, épices/herbes, pains à sandwich, cuisines italienne/asiatique/mexicaine/maghrébine, liquides et boissons de cuisine, bouillons/fonds) - Chaque ingrédient lié à ses allergènes UE (IngredientAllergy) — les 14 allergènes réglementaires restent tous couverts - Seeding optimisé en requêtes groupées (createMany/diff ciblé) plutôt qu'un upsert par ligne, pour garder resetDatabase() rapide en test Tests: 102 tests Mocha + 32 scénarios BDD, tous verts. Co-Authored-By: Claude Sonnet 5 --- apps/api/features/recipe.feature | 59 ++ .../step-definitions/planning.steps.ts | 4 +- .../features/step-definitions/recipe.steps.ts | 159 ++++ .../migration.sql | 17 + .../migration.sql | 56 ++ apps/api/prisma/schema.prisma | 147 ++- apps/api/src/app.ts | 2 + apps/api/src/db/reference-seed-data.ts | 542 +++++++++++ .../api/src/modules/profile/profile.routes.ts | 37 +- .../src/modules/profile/profile.service.ts | 46 + apps/api/src/modules/recipe/recipe.routes.ts | 104 ++ apps/api/src/modules/recipe/recipe.service.ts | 415 ++++++++ .../src/modules/reference/reference.routes.ts | 15 +- .../modules/reference/reference.service.ts | 27 +- apps/api/test/planning.test.ts | 8 +- apps/api/test/profile.test.ts | 60 ++ apps/api/test/recipe.test.ts | 526 +++++++++++ apps/api/test/reference.test.ts | 19 + apps/web/src/App.tsx | 8 + apps/web/src/api/client.ts | 61 ++ .../profile/DislikedIngredientsField.tsx | 68 ++ .../src/features/recipes/AllergenBadges.tsx | 25 + apps/web/src/features/recipes/DietBadges.tsx | 25 + .../src/features/recipes/DietTagSelect.tsx | 41 + .../features/recipes/FavoriteStarButton.tsx | 58 ++ .../recipes/IngredientAutocomplete.tsx | 71 ++ .../src/features/recipes/IngredientRow.tsx | 58 ++ .../features/recipes/RecipeDetailPanel.tsx | 190 ++++ apps/web/src/features/recipes/RecipeTable.tsx | 84 ++ apps/web/src/features/recipes/RecipeTabs.tsx | 54 ++ .../src/features/recipes/StepListEditor.tsx | 101 ++ apps/web/src/features/recipes/recipes.scss | 888 ++++++++++++++++++ apps/web/src/layouts/nav-icons.tsx | 20 + apps/web/src/locales/fr/translation.json | 66 +- apps/web/src/pages/RecipeFormPage.tsx | 275 ++++++ apps/web/src/pages/RecipesPage.tsx | 172 +++- .../src/pages/settings/PreferencesPage.tsx | 67 +- packages/shared/src/errors/error-codes.ts | 8 + packages/shared/src/index.ts | 2 + packages/shared/src/schemas/profile.ts | 12 + packages/shared/src/schemas/recipe.ts | 74 ++ packages/shared/src/types/recipe.ts | 64 ++ packages/shared/src/types/reference.ts | 20 + 43 files changed, 4714 insertions(+), 41 deletions(-) create mode 100644 apps/api/features/recipe.feature create mode 100644 apps/api/features/step-definitions/recipe.steps.ts create mode 100644 apps/api/prisma/migrations/20260817220730_ingredient_allergies/migration.sql create mode 100644 apps/api/prisma/migrations/20260818054828_recipe_visibility_favorites_diets_dislikes/migration.sql create mode 100644 apps/api/src/modules/recipe/recipe.routes.ts create mode 100644 apps/api/src/modules/recipe/recipe.service.ts create mode 100644 apps/api/test/recipe.test.ts create mode 100644 apps/web/src/features/profile/DislikedIngredientsField.tsx create mode 100644 apps/web/src/features/recipes/AllergenBadges.tsx create mode 100644 apps/web/src/features/recipes/DietBadges.tsx create mode 100644 apps/web/src/features/recipes/DietTagSelect.tsx create mode 100644 apps/web/src/features/recipes/FavoriteStarButton.tsx create mode 100644 apps/web/src/features/recipes/IngredientAutocomplete.tsx create mode 100644 apps/web/src/features/recipes/IngredientRow.tsx create mode 100644 apps/web/src/features/recipes/RecipeDetailPanel.tsx create mode 100644 apps/web/src/features/recipes/RecipeTable.tsx create mode 100644 apps/web/src/features/recipes/RecipeTabs.tsx create mode 100644 apps/web/src/features/recipes/StepListEditor.tsx create mode 100644 apps/web/src/features/recipes/recipes.scss create mode 100644 apps/web/src/pages/RecipeFormPage.tsx create mode 100644 packages/shared/src/schemas/recipe.ts create mode 100644 packages/shared/src/types/recipe.ts diff --git a/apps/api/features/recipe.feature b/apps/api/features/recipe.feature new file mode 100644 index 0000000..508fb03 --- /dev/null +++ b/apps/api/features/recipe.feature @@ -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" diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts index 7a065dc..c71ec7f 100644 --- a/apps/api/features/step-definitions/planning.steps.ts +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -28,7 +28,9 @@ Given( const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" }); const houseId: number = houseRes.body.id; - const recipe = await prisma.recipe.create({ data: { name: recipeName } }); + const recipe = await prisma.recipe.create({ + data: { name: recipeName, authorId: houseRes.body.adminId }, + }); const planning = await prisma.planning.create({ data: { houseId, diff --git a/apps/api/features/step-definitions/recipe.steps.ts b/apps/api/features/step-definitions/recipe.steps.ts new file mode 100644 index 0000000..020f99f --- /dev/null +++ b/apps/api/features/step-definitions/recipe.steps.ts @@ -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 { + 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" }], + }); + }, +); diff --git a/apps/api/prisma/migrations/20260817220730_ingredient_allergies/migration.sql b/apps/api/prisma/migrations/20260817220730_ingredient_allergies/migration.sql new file mode 100644 index 0000000..ee03821 --- /dev/null +++ b/apps/api/prisma/migrations/20260817220730_ingredient_allergies/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/20260818054828_recipe_visibility_favorites_diets_dislikes/migration.sql b/apps/api/prisma/migrations/20260818054828_recipe_visibility_favorites_diets_dislikes/migration.sql new file mode 100644 index 0000000..d97d236 --- /dev/null +++ b/apps/api/prisma/migrations/20260818054828_recipe_visibility_favorites_diets_dislikes/migration.sql @@ -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; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f398ab4..8c4e87d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -24,9 +24,12 @@ model House { /// member — see `house.service.ts`'s generator for the charset/length. inviteCode String @unique @map("invite_code") - admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) - members UserProfile[] @relation("HouseMember") - plannings Planning[] + admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) + members UserProfile[] @relation("HouseMember") + plannings Planning[] + /// Recipes whose author belonged to this household when they created + /// them — see `Recipe.authorHouseId`. + authoredRecipes Recipe[] @@map("house") } @@ -39,7 +42,8 @@ model Diet { id Int @id @default(autoincrement()) name String @unique - users UserProfile[] + users UserProfile[] + recipes RecipeDiet[] @@map("diet") } @@ -70,8 +74,9 @@ model Allergy { id Int @id @default(autoincrement()) categoryId Int @map("cat_id") - category Category @relation(fields: [categoryId], references: [id]) - users UserProfileAllergy[] + category Category @relation(fields: [categoryId], references: [id]) + users UserProfileAllergy[] + ingredients IngredientAllergy[] @@map("allergy") } @@ -90,19 +95,43 @@ model UserProfile { houseId Int? @map("house_id") dietId Int? @map("diet_id") - house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) - diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) - allergies UserProfileAllergy[] + house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) + diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) + allergies UserProfileAllergy[] + /// Ingredients this profile personally dislikes — a taste preference, not + /// a medical constraint (see {@link UserProfileDislikedIngredient} and + /// `allergies` above for the distinct medical list). + dislikedIngredients UserProfileDislikedIngredient[] + /// Recipes authored by this profile — see `Recipe.authorId`. + authoredRecipes Recipe[] + /// Recipes this profile has favorited — see {@link RecipeFavorite}. + favoriteRecipes RecipeFavorite[] /// Households this profile administers. In practice at most one — a /// profile can only ever belong to (and thus admin) a single household at /// a time — but Prisma models the admin side of a one-to-many FK as a /// list regardless of that real-world cardinality. - administeredHouses House[] @relation("HouseAdmin") - preferences UserPreference? + administeredHouses House[] @relation("HouseAdmin") + preferences UserPreference? @@map("user_profiles") } +/// Explicit join table for the user_profiles <-> ingredient "disliked" +/// association — same shape as `UserProfileAllergy`, but a personal taste +/// preference rather than a medical restriction: not surfaced as a safety +/// warning, just a reminder on a recipe's detail view (see +/// `RecipeView`/`RecipeDetailPanel`, apps/web). +model UserProfileDislikedIngredient { + userProfileId Int @map("user_profile_id") + ingredientId Int @map("ingredient_id") + + userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + + @@id([userProfileId, ingredientId]) + @@map("user_profile_disliked_ingredient") +} + /// Not in the original spec doc — personalization settings (theme for now, /// meant to grow), one row per profile, created on demand (see /// `preferences.service.ts`) rather than at signup — same "absent means the @@ -184,35 +213,115 @@ model Source { @@map("sources") } -model Recipe { - id Int @id @default(autoincrement()) - name String - sourceId Int? @map("source_id") - description String? - picture String? +/// Not in the original spec doc — who can *read* a recipe. Controls only +/// visibility, never editing: a recipe can only ever be edited/deleted by +/// its `author`, whatever this is set to (see `recipe.service.ts`). +enum RecipeVisibility { + /// Visible to its author only. + PERSONAL + /// Visible to `authorHouseId`'s members (a snapshot of the author's + /// household *at creation time* — see `Recipe.authorHouseId`). + HOUSE + /// Visible to every signed-in user — the "shared catalog" behavior the + /// very first version of this feature shipped with. + PUBLIC +} +model Recipe { + id Int @id @default(autoincrement()) + name String + sourceId Int? @map("source_id") + description String? + picture String? + /// Creator — not in the original spec doc, required once recipes carry a + /// visibility level (`PERSONAL`/`HOUSE` need someone to scope against). + authorId Int @map("author_id") + /// The author's household *at the time this recipe was created* — a + /// snapshot (same idea as `Planning.houseId`), not a live lookup: it + /// doesn't follow the author if they later change household. `null` if + /// the author had no household yet. + authorHouseId Int? @map("author_house_id") + visibility RecipeVisibility @default(PERSONAL) + + author UserProfile @relation(fields: [authorId], references: [id]) + authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull) ingredients RecipeIngredient[] steps Step[] planningItems PlanningItem[] + favoritedBy RecipeFavorite[] + diets RecipeDiet[] /// Ingredients for which this recipe is offered as a make-it-yourself alternative. alternateFor Ingredient[] @relation("IngredientAlternateRecipe") @@map("recipe") } +/// Explicit join table for the user_profiles <-> recipe "favorited" +/// association — same shape as `UserProfileAllergy`. Per-user, not +/// per-household: two members of the same household can favorite different +/// recipes independently. +model RecipeFavorite { + userProfileId Int @map("user_profile_id") + recipeId Int @map("recipe_id") + + userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + + @@id([userProfileId, recipeId]) + @@map("recipe_favorite") +} + +/// Explicit join table for the recipe <-> diet "associated regime" tags +/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a +/// manual reminder set by whoever creates/edits the recipe, not computed +/// from its ingredients. +model RecipeDiet { + recipeId Int @map("recipe_id") + dietId Int @map("diet_id") + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) + + @@id([recipeId, dietId]) + @@map("recipe_diet") +} + +/// `name` is `@unique` — not in the original spec doc, added so the seed +/// script (reference-seed-data.ts) can `upsert` by name and stay +/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. +/// Ingredients are reference data (like Diet/Allergy): seeded, never +/// created/edited/deleted through the API. model Ingredient { id Int @id @default(autoincrement()) - name String + name String @unique icon String? alternateRecipeId Int? @map("alternate_recipe") - alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) + alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) recipes RecipeIngredient[] + allergies IngredientAllergy[] + /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. + dislikedBy UserProfileDislikedIngredient[] @@map("ingredients") } +/// Explicit join table for the ingredients <-> allergy association — not in +/// the original spec doc, added so the recipe catalog can surface which +/// allergens an ingredient (and by extension a recipe) carries. Same shape +/// as `UserProfileAllergy`. +model IngredientAllergy { + ingredientId Int @map("ingredient_id") + allergyId Int @map("allergy_id") + + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) + + @@id([ingredientId, allergyId]) + @@map("ingredient_allergy") +} + /// recipe <-> ingredients association. The spec documents this as a plain /// many-to-many, but a shopping list / batch-cooking calculation needs a /// quantity per recipe, so this join table carries quantity + unit diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8a28c99..a1161e3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,7 @@ import { houseRouter } from "./modules/house/house.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; +import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; /** @@ -31,6 +32,7 @@ export function createServer(): ExpressServer { server.mountRouter("/planning", planningRouter); server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/profile", profileRouter); + server.mountRouter("/recipes", recipeRouter); server.mountRouter("/reference", referenceRouter); // Serves the built frontend (production Docker image only — see diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 4d4eeaf..7e4dd7c 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -28,6 +28,492 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ { name: "Mollusques", kind: "ALLERGY" }, ]; +// A broad pantry list — the goal is to cover the large majority of what a +// home cook reaches for (viandes, poissons, légumes, fruits, féculents, +// condiments, épices...), not just enough to exercise the recipe catalog in +// tests. Ingredients are reference data (see `Ingredient` in schema.prisma: +// `name` is `@unique`, there's no create/update/delete endpoint), so this is +// meant to already be comprehensive at first deploy rather than grown +// piecemeal as recipes need more of it. `allergenNames` reference +// `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is +// covered by at least one ingredient here. `icon` is left unset (rather than +// forcing a misleading emoji) for the handful of items with no good match in +// the standard emoji set (e.g. `Radis`, `Asperge`). +const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[] }> = [ + // --- Céréales, farines & féculents --------------------------------------- + { name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Farine de maïs", icon: "🌽", allergenNames: [] }, + { name: "Farine de sarrasin", icon: "🌾", allergenNames: [] }, + { name: "Farine de riz", icon: "🍚", allergenNames: [] }, + { name: "Semoule", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Couscous", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Boulgour", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Polenta", icon: "🌽", allergenNames: [] }, + { name: "Quinoa", icon: "🌾", allergenNames: [] }, + { name: "Pâtes", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Pâtes complètes", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Riz", icon: "🍚", allergenNames: [] }, + { name: "Riz basmati", icon: "🍚", allergenNames: [] }, + { name: "Riz complet", icon: "🍚", allergenNames: [] }, + { name: "Flocons d'avoine", icon: "🌾", allergenNames: ["Gluten"] }, + { name: "Pomme de terre", icon: "🥔", allergenNames: [] }, + { name: "Patate douce", icon: "🍠", allergenNames: [] }, + { name: "Pain", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain de mie", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain complet", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Baguette", icon: "🥖", allergenNames: ["Gluten"] }, + { name: "Pain de seigle", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Chapelure", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Levure boulangère", icon: "🫙", allergenNames: [] }, + { name: "Levure chimique", icon: "🫙", allergenNames: [] }, + { name: "Maïzena", icon: "🫙", allergenNames: [] }, + + // --- Légumineuses --------------------------------------------------------- + { name: "Lentilles vertes", icon: "🫘", allergenNames: [] }, + { name: "Lentilles corail", icon: "🫘", allergenNames: [] }, + { name: "Pois chiches", icon: "🫘", allergenNames: [] }, + { name: "Haricots blancs", icon: "🫘", allergenNames: [] }, + { name: "Haricots rouges", icon: "🫘", allergenNames: [] }, + { name: "Haricots noirs", icon: "🫘", allergenNames: [] }, + { name: "Pois cassés", icon: "🫘", allergenNames: [] }, + { name: "Fèves", icon: "🫘", allergenNames: [] }, + { name: "Edamame", icon: "🫛", allergenNames: ["Soja"] }, + + // --- Viandes & volailles --------------------------------------------------- + { name: "Poulet", icon: "🍗", allergenNames: [] }, + { name: "Dinde", icon: "🍗", allergenNames: [] }, + { name: "Canard", icon: "🦆", allergenNames: [] }, + { name: "Magret de canard", icon: "🦆", allergenNames: [] }, + { name: "Lapin", icon: "🐇", allergenNames: [] }, + { name: "Bœuf haché", icon: "🥩", allergenNames: [] }, + { name: "Steak de bœuf", icon: "🥩", allergenNames: [] }, + { name: "Rôti de bœuf", icon: "🥩", allergenNames: [] }, + { name: "Escalope de veau", icon: "🥩", allergenNames: [] }, + { name: "Filet mignon de porc", icon: "🥩", allergenNames: [] }, + { name: "Côte de porc", icon: "🥩", allergenNames: [] }, + { name: "Agneau", icon: "🍖", allergenNames: [] }, + { name: "Gigot d'agneau", icon: "🍖", allergenNames: [] }, + { name: "Lardons", icon: "🥓", allergenNames: [] }, + { name: "Bacon", icon: "🥓", allergenNames: [] }, + { name: "Jambon blanc", icon: "🍖", allergenNames: [] }, + { name: "Jambon cru", icon: "🍖", allergenNames: [] }, + { name: "Saucisse", icon: "🌭", allergenNames: [] }, + { name: "Chorizo", icon: "🌭", allergenNames: [] }, + { name: "Merguez", icon: "🌭", allergenNames: [] }, + + // --- Poissons & fruits de mer ----------------------------------------------- + { name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Thon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Truite", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Sardine", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Anchois", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Merlan", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Surimi", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Bar (loup de mer)", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Dorade", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Sole", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Turbot", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Merlu", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Colin", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Lieu noir", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Églefin", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Maquereau", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Hareng", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Rouget", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Raie", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Lotte", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Flétan", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Espadon", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Carpe", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Brochet", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Perche", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Tilapia", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Panga", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Saumon fumé", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Crevettes", icon: "🍤", allergenNames: ["Crustacés"] }, + { name: "Langoustines", icon: "🍤", allergenNames: ["Crustacés"] }, + { name: "Homard", icon: "🦞", allergenNames: ["Crustacés"] }, + { name: "Crabe", icon: "🦀", allergenNames: ["Crustacés"] }, + { name: "Langouste", icon: "🦞", allergenNames: ["Crustacés"] }, + { name: "Moules", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Huîtres", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Saint-Jacques", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Calamar", icon: "🦑", allergenNames: ["Mollusques"] }, + { name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] }, + { name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] }, + { name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] }, + + // --- Produits laitiers & œufs ----------------------------------------------- + { name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] }, + { name: "Lait", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Beurre", icon: "🧈", allergenNames: ["Lait"] }, + { name: "Crème fraîche", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Crème liquide", icon: "🥛", allergenNames: ["Lait"] }, + { name: "Fromage", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Emmental", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Gruyère", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Parmesan", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Mozzarella", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Chèvre (fromage)", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Feta", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Comté", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] }, + { name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] }, + { name: "Lait de coco", icon: "🥥", allergenNames: [] }, + { name: "Crème de coco", icon: "🥥", allergenNames: [] }, + { name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] }, + { name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] }, + + // --- Légumes ----------------------------------------------------------------- + { name: "Tomate", icon: "🍅", allergenNames: [] }, + { name: "Oignon", icon: "🧅", allergenNames: [] }, + { name: "Échalote", icon: "🧅", allergenNames: [] }, + { name: "Ail", icon: "🧄", allergenNames: [] }, + { name: "Carotte", icon: "🥕", allergenNames: [] }, + { name: "Courgette", icon: "🥒", allergenNames: [] }, + { name: "Concombre", icon: "🥒", allergenNames: [] }, + { name: "Cornichons", icon: "🥒", allergenNames: [] }, + { name: "Poivron", icon: "🫑", allergenNames: [] }, + { name: "Champignon", icon: "🍄", allergenNames: [] }, + { name: "Cèpes", icon: "🍄", allergenNames: [] }, + { name: "Aubergine", icon: "🍆", allergenNames: [] }, + { name: "Brocoli", icon: "🥦", allergenNames: [] }, + { name: "Chou-fleur", icon: "🥦", allergenNames: [] }, + { name: "Chou blanc", icon: "🥬", allergenNames: [] }, + { name: "Chou rouge", icon: "🥬", allergenNames: [] }, + { name: "Chou de Bruxelles", icon: "🥬", allergenNames: [] }, + { name: "Épinard", icon: "🥬", allergenNames: [] }, + { name: "Blette", icon: "🥬", allergenNames: [] }, + { name: "Salade", icon: "🥬", allergenNames: [] }, + { name: "Roquette", icon: "🥬", allergenNames: [] }, + { name: "Cresson", icon: "🥬", allergenNames: [] }, + { name: "Poireau", icon: "🥬", allergenNames: [] }, + { name: "Céleri", icon: "🥬", allergenNames: ["Céleri"] }, + { name: "Radis", allergenNames: [] }, + { name: "Betterave", allergenNames: [] }, + { name: "Navet", allergenNames: [] }, + { name: "Panais", allergenNames: [] }, + { name: "Haricot vert", icon: "🫛", allergenNames: [] }, + { name: "Petit pois", icon: "🫛", allergenNames: [] }, + { name: "Maïs", icon: "🌽", allergenNames: [] }, + { name: "Artichaut", allergenNames: [] }, + { name: "Fenouil", allergenNames: [] }, + { name: "Endive", allergenNames: [] }, + { name: "Potiron", icon: "🎃", allergenNames: [] }, + { name: "Butternut", icon: "🎃", allergenNames: [] }, + { name: "Asperge", allergenNames: [] }, + { name: "Avocat", icon: "🥑", allergenNames: [] }, + + // --- Fruits ------------------------------------------------------------------ + { name: "Citron", icon: "🍋", allergenNames: [] }, + { name: "Citron vert", icon: "🍋", allergenNames: [] }, + { name: "Pomme", icon: "🍎", allergenNames: [] }, + { name: "Poire", icon: "🍐", allergenNames: [] }, + { name: "Banane", icon: "🍌", allergenNames: [] }, + { name: "Orange", icon: "🍊", allergenNames: [] }, + { name: "Clémentine", icon: "🍊", allergenNames: [] }, + { name: "Pamplemousse", icon: "🍊", allergenNames: [] }, + { name: "Fraise", icon: "🍓", allergenNames: [] }, + { name: "Framboise", icon: "🍓", allergenNames: [] }, + { name: "Myrtille", icon: "🫐", allergenNames: [] }, + { name: "Mûre", icon: "🫐", allergenNames: [] }, + { name: "Cerise", icon: "🍒", allergenNames: [] }, + { name: "Abricot", icon: "🍑", allergenNames: [] }, + { name: "Pêche", icon: "🍑", allergenNames: [] }, + { name: "Prune", allergenNames: [] }, + { name: "Raisin", icon: "🍇", allergenNames: [] }, + { name: "Melon", icon: "🍈", allergenNames: [] }, + { name: "Pastèque", icon: "🍉", allergenNames: [] }, + { name: "Ananas", icon: "🍍", allergenNames: [] }, + { name: "Mangue", icon: "🥭", allergenNames: [] }, + { name: "Kiwi", icon: "🥝", allergenNames: [] }, + { name: "Figue", allergenNames: [] }, + { name: "Datte", allergenNames: [] }, + { name: "Litchi", allergenNames: [] }, + { name: "Grenade", allergenNames: [] }, + { name: "Rhubarbe", allergenNames: [] }, + { name: "Coing", allergenNames: [] }, + + // --- Fruits secs & oléagineux ------------------------------------------------ + { name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] }, + { name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Noisettes", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Noix de cajou", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Pistaches", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Noix de pécan", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Poudre d'amande", icon: "🌰", allergenNames: ["Fruits à coque"] }, + { name: "Pignons de pin", icon: "🌰", allergenNames: [] }, + { name: "Graines de tournesol", icon: "🌻", allergenNames: [] }, + { name: "Graines de courge", allergenNames: [] }, + { name: "Noix de coco râpée", icon: "🥥", allergenNames: [] }, + { name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] }, + { name: "Pruneaux", allergenNames: ["Sulfites"] }, + { name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] }, + + // --- Condiments, sauces & huiles ---------------------------------------------- + { name: "Sel", icon: "🧂", allergenNames: [] }, + { name: "Sucre", icon: "🍬", allergenNames: [] }, + { name: "Huile d'olive", icon: "🫒", allergenNames: [] }, + { name: "Huile de tournesol", icon: "🧴", allergenNames: [] }, + { name: "Huile de colza", icon: "🧴", allergenNames: [] }, + { name: "Huile de coco", icon: "🥥", allergenNames: [] }, + { name: "Huile de sésame", icon: "🧴", allergenNames: ["Graines de sésame"] }, + { name: "Vinaigre de cidre", icon: "🧴", allergenNames: [] }, + { name: "Vinaigre blanc", icon: "🧴", allergenNames: [] }, + { name: "Vinaigre balsamique", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] }, + { name: "Tofu", icon: "🧊", allergenNames: ["Soja"] }, + { name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] }, + { name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] }, + { name: "Ketchup", icon: "🫙", allergenNames: [] }, + { name: "Miel", icon: "🍯", allergenNames: [] }, + { name: "Sirop d'érable", icon: "🍁", allergenNames: [] }, + { name: "Câpres", allergenNames: [] }, + { name: "Olives", icon: "🫒", allergenNames: [] }, + { name: "Tabasco", icon: "🌶️", allergenNames: [] }, + { name: "Sauce Worcestershire", icon: "🫙", allergenNames: ["Poissons"] }, + { name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] }, + { name: "Wasabi", allergenNames: [] }, + { name: "Harissa", icon: "🌶️", allergenNames: [] }, + { name: "Pâte de curry", icon: "🍛", allergenNames: [] }, + { name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Concentré de tomate", icon: "🍅", allergenNames: [] }, + { name: "Coulis de tomate", icon: "🍅", allergenNames: [] }, + { name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] }, + { name: "Vin blanc (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vin rouge (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin rouge", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin blanc", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de xérès", icon: "🧴", allergenNames: ["Sulfites"] }, + { name: "Huile de noix", icon: "🧴", allergenNames: ["Fruits à coque"] }, + { name: "Huile de noisette", icon: "🧴", allergenNames: ["Fruits à coque"] }, + { name: "Huile d'arachide", icon: "🧴", allergenNames: ["Arachides"] }, + { name: "Huile pimentée", icon: "🌶️", allergenNames: [] }, + { name: "Beurre de cacahuète", icon: "🥜", allergenNames: ["Arachides"] }, + { name: "Farine de lupin", icon: "🌱", allergenNames: ["Lupin"] }, + { name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] }, + { name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] }, + { name: "Sauce barbecue", icon: "🫙", allergenNames: [] }, + { name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] }, + { name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] }, + { name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] }, + { name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] }, + { name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] }, + { name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] }, + { name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] }, + { name: "Chimichurri", icon: "🌿", allergenNames: [] }, + { name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] }, + { name: "Tomates séchées", icon: "🍅", allergenNames: [] }, + { name: "Fond de veau", icon: "🫙", allergenNames: [] }, + { name: "Fond de volaille", icon: "🫙", allergenNames: [] }, + { name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] }, + { name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] }, + { name: "Court-bouillon", icon: "🫙", allergenNames: [] }, + { name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] }, + { name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] }, + + // --- Épices & herbes ----------------------------------------------------------- + { name: "Basilic", icon: "🌿", allergenNames: [] }, + { name: "Persil", icon: "🌿", allergenNames: [] }, + { name: "Thym", icon: "🌿", allergenNames: [] }, + { name: "Romarin", icon: "🌿", allergenNames: [] }, + { name: "Laurier", icon: "🌿", allergenNames: [] }, + { name: "Ciboulette", icon: "🌿", allergenNames: [] }, + { name: "Coriandre fraîche", icon: "🌿", allergenNames: [] }, + { name: "Menthe", icon: "🌿", allergenNames: [] }, + { name: "Origan", icon: "🌿", allergenNames: [] }, + { name: "Aneth", icon: "🌿", allergenNames: [] }, + { name: "Estragon", icon: "🌿", allergenNames: [] }, + { name: "Herbes de Provence", icon: "🌿", allergenNames: [] }, + { name: "Poivre noir", allergenNames: [] }, + { name: "Paprika", icon: "🌶️", allergenNames: [] }, + { name: "Piment d'Espelette", icon: "🌶️", allergenNames: [] }, + { name: "Piment de Cayenne", icon: "🌶️", allergenNames: [] }, + { name: "Cumin", icon: "🌿", allergenNames: [] }, + { name: "Curry (poudre)", icon: "🌿", allergenNames: [] }, + { name: "Curcuma", icon: "🌿", allergenNames: [] }, + { name: "Cannelle", icon: "🌿", allergenNames: [] }, + { name: "Gingembre", icon: "🫚", allergenNames: [] }, + { name: "Muscade", icon: "🌿", allergenNames: [] }, + { name: "Safran", icon: "🌿", allergenNames: [] }, + { name: "Clou de girofle", icon: "🌿", allergenNames: [] }, + { name: "Vanille (gousse)", icon: "🌿", allergenNames: [] }, + { name: "Poivre blanc", allergenNames: [] }, + { name: "Poivre rose", allergenNames: [] }, + { name: "Poivre du Sichuan", allergenNames: [] }, + { name: "Paprika fumé", icon: "🌶️", allergenNames: [] }, + { name: "Piment oiseau", icon: "🌶️", allergenNames: [] }, + { name: "Baies de genièvre", allergenNames: [] }, + { name: "Anis étoilé (badiane)", icon: "🌿", allergenNames: [] }, + { name: "Anis vert", icon: "🌿", allergenNames: [] }, + { name: "Graines de fenouil", icon: "🌿", allergenNames: [] }, + { name: "Sarriette", icon: "🌿", allergenNames: [] }, + { name: "Marjolaine", icon: "🌿", allergenNames: [] }, + { name: "Sauge", icon: "🌿", allergenNames: [] }, + { name: "Cerfeuil", icon: "🌿", allergenNames: [] }, + { name: "Sumac", allergenNames: [] }, + { name: "Nigelle", allergenNames: [] }, + { name: "Quatre épices", icon: "🌿", allergenNames: [] }, + { name: "Colombo (poudre)", icon: "🌿", allergenNames: [] }, + { name: "Baharat", icon: "🌿", allergenNames: [] }, + { name: "Raifort", allergenNames: [] }, + { name: "Sel aux herbes", icon: "🧂", allergenNames: [] }, + { name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] }, + { name: "Fleur de sel", icon: "🧂", allergenNames: [] }, + + // --- Sucre & pâtisserie --------------------------------------------------------- + { name: "Sucre roux", icon: "🍬", allergenNames: [] }, + { name: "Sucre glace", icon: "🍬", allergenNames: [] }, + { name: "Cassonade", icon: "🍬", allergenNames: [] }, + { name: "Chocolat noir", icon: "🍫", allergenNames: [] }, + { name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] }, + { name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] }, + { name: "Pépites de chocolat", icon: "🍫", allergenNames: [] }, + { name: "Cacao en poudre", icon: "🍫", allergenNames: [] }, + { name: "Gélatine", icon: "🫙", allergenNames: [] }, + { name: "Extrait de vanille", icon: "🌿", allergenNames: [] }, + + // --- Cuisine italienne --------------------------------------------------------- + { name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Penne", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] }, + { name: "Riz arborio", icon: "🍚", allergenNames: [] }, + { name: "Burrata", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] }, + { name: "Prosciutto", icon: "🍖", allergenNames: [] }, + { name: "Pancetta", icon: "🥓", allergenNames: [] }, + { name: "Mortadelle", icon: "🍖", allergenNames: [] }, + { name: "Salami", icon: "🍖", allergenNames: [] }, + { name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] }, + { name: "Tomates cerises", icon: "🍅", allergenNames: [] }, + { name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] }, + + // --- Cuisine asiatique (chinoise, japonaise, thaïe, coréenne, indienne...) ------ + { name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] }, + { name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] }, + { name: "Sauce sriracha", icon: "🫙", allergenNames: [] }, + { name: "Sauce sweet chili", icon: "🫙", allergenNames: [] }, + { name: "Vinaigre de riz", icon: "🧴", allergenNames: [] }, + { name: "Mirin", icon: "🍶", allergenNames: [] }, + { name: "Saké (cuisine)", icon: "🍶", allergenNames: [] }, + { name: "Miso", icon: "🫙", allergenNames: ["Soja"] }, + { name: "Tofu soyeux", icon: "🧊", allergenNames: ["Soja"] }, + { name: "Graines de sésame", icon: "🌱", allergenNames: ["Graines de sésame"] }, + { name: "Nouilles de riz", icon: "🍜", allergenNames: [] }, + { name: "Nouilles udon", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Nouilles soba", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Nouilles chinoises", icon: "🍜", allergenNames: ["Gluten"] }, + { name: "Vermicelles de riz", icon: "🍜", allergenNames: [] }, + { name: "Vermicelles de soja", icon: "🍜", allergenNames: [] }, + { name: "Riz gluant", icon: "🍚", allergenNames: [] }, + { name: "Riz à sushi", icon: "🍚", allergenNames: [] }, + { name: "Riz jasmin", icon: "🍚", allergenNames: [] }, + { name: "Citronnelle", icon: "🌿", allergenNames: [] }, + { name: "Combava", icon: "🌿", allergenNames: [] }, + { name: "Pak-choï", icon: "🥬", allergenNames: [] }, + { name: "Germes de soja", icon: "🌱", allergenNames: ["Soja"] }, + { name: "Shiitake", icon: "🍄", allergenNames: [] }, + { name: "Champignons noirs", icon: "🍄", allergenNames: [] }, + { name: "Daikon", allergenNames: [] }, + { name: "Algue nori", icon: "🌿", allergenNames: [] }, + { name: "Algue wakamé", icon: "🌿", allergenNames: [] }, + { name: "Algue kombu", icon: "🌿", allergenNames: [] }, + { name: "Cinq épices", icon: "🌿", allergenNames: [] }, + { name: "Garam masala", icon: "🌿", allergenNames: [] }, + { name: "Graines de coriandre", icon: "🌿", allergenNames: [] }, + { name: "Cardamome", icon: "🌿", allergenNames: [] }, + { name: "Fenugrec", icon: "🌿", allergenNames: [] }, + { name: "Piment vert frais", icon: "🌶️", allergenNames: [] }, + { name: "Farine de tapioca", icon: "🌾", allergenNames: [] }, + { name: "Sucre de palme", icon: "🍬", allergenNames: [] }, + { name: "Pousses de bambou", allergenNames: [] }, + { name: "Châtaignes d'eau", allergenNames: [] }, + { name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] }, + { name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] }, + { name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] }, + { name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] }, + + // --- Cuisine mexicaine ----------------------------------------------------------- + { name: "Tortilla de maïs", icon: "🌮", allergenNames: [] }, + { name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] }, + { name: "Haricots pinto", icon: "🫘", allergenNames: [] }, + { name: "Piment jalapeño", icon: "🌶️", allergenNames: [] }, + { name: "Piment chipotle", icon: "🌶️", allergenNames: [] }, + { name: "Piment poblano", icon: "🌶️", allergenNames: [] }, + { name: "Piment habanero", icon: "🌶️", allergenNames: [] }, + { name: "Masa harina", icon: "🌽", allergenNames: [] }, + { name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] }, + + // --- Maghreb & Moyen-Orient -------------------------------------------------------- + { name: "Ras el hanout", icon: "🌿", allergenNames: [] }, + { name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] }, + { name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] }, + + // --- Pains & sandwichs --------------------------------------------------------------- + // `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are + // seeded further up (Céréales, farines & féculents) — this section covers + // the shapes specifically reached for to build a sandwich, plus a few + // international flatbreads not tied to one cuisine section above. + { name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] }, + { name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] }, + { name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] }, + { name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] }, + { name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] }, + { name: "Naan", icon: "🫓", allergenNames: ["Gluten"] }, + { name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] }, + { name: "Pain viennois", icon: "🍞", allergenNames: ["Gluten", "Lait"] }, + { name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain suédois", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Pain sans gluten", icon: "🍞", allergenNames: [] }, + { name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] }, + { name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] }, + + // --- Épicerie divers --------------------------------------------------------------- + { name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] }, + { name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] }, + + // --- Liquides & boissons de cuisine ------------------------------------------- + { name: "Eau", icon: "💧", allergenNames: [] }, + { name: "Eau gazeuse", icon: "💧", allergenNames: [] }, + { name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] }, + { name: "Eau de rose", icon: "💧", allergenNames: [] }, + { name: "Jus de citron", icon: "🧃", allergenNames: [] }, + { name: "Jus de citron vert", icon: "🧃", allergenNames: [] }, + { name: "Jus d'orange", icon: "🧃", allergenNames: [] }, + { name: "Jus de pomme", icon: "🧃", allergenNames: [] }, + { name: "Jus de raisin", icon: "🧃", allergenNames: [] }, + { name: "Jus de tomate", icon: "🧃", allergenNames: [] }, + { name: "Jus de cranberry", icon: "🧃", allergenNames: [] }, + { name: "Café", icon: "☕", allergenNames: [] }, + { name: "Thé", icon: "🍵", allergenNames: [] }, + { name: "Bière (cuisine)", icon: "🍺", allergenNames: ["Gluten"] }, + { name: "Cidre (cuisine)", allergenNames: ["Sulfites"] }, + { name: "Champagne / vin pétillant (cuisine)", icon: "🥂", allergenNames: ["Sulfites"] }, + { name: "Porto (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Vin jaune (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] }, + { name: "Cognac", icon: "🥃", allergenNames: [] }, + { name: "Rhum", icon: "🥃", allergenNames: [] }, + { name: "Whisky", icon: "🥃", allergenNames: [] }, + { name: "Vodka", icon: "🥃", allergenNames: [] }, + { name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] }, + { name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] }, +]; + /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * (safe to call against a database that already has this data — upserts by @@ -58,4 +544,60 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { await prisma.allergy.create({ data: { categoryId: category.id } }); } } + + // Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few + // hundred entries long, and `seedReferenceData` re-runs on every single + // test's `resetDatabase()` — a per-row round trip made the whole suite + // measurably slower). Bulk-create whatever's missing in one query, then + // reconcile `icon` only for the rows where it actually changed — on a + // freshly-truncated table (the common test-suite case) that's zero + // updates, on a real re-deploy it's however many icons were edited in + // code since the last deploy, never the full list. + const existingIngredients = await prisma.ingredient.findMany({ + where: { name: { in: INGREDIENTS.map((i) => i.name) } }, + select: { id: true, name: true, icon: true }, + }); + const existingByName = new Map(existingIngredients.map((i) => [i.name, i])); + + const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name)); + if (missingIngredients.length > 0) { + await prisma.ingredient.createMany({ + data: missingIngredients.map(({ name, icon }) => ({ name, icon })), + }); + } + + const changedIcons = INGREDIENTS.filter((i) => { + const existing = existingByName.get(i.name); + return existing && existing.icon !== (i.icon ?? null); + }); + for (const { name, icon } of changedIcons) { + await prisma.ingredient.update({ where: { name }, data: { icon } }); + } + + // Re-resolve every ingredient's id (existing + just-created) and every + // allergy's id (by its category name) once, then link them in a single + // bulk insert — same "re-derived every time, not upserted per link" + // reasoning as before for `IngredientAllergy` (it has no natural per-row + // identity to upsert against), just batched instead of looped. + const allIngredients = await prisma.ingredient.findMany({ + where: { name: { in: INGREDIENTS.map((i) => i.name) } }, + select: { id: true, name: true }, + }); + const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id])); + + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id])); + + const links: Array<{ ingredientId: number; allergyId: number }> = []; + for (const { name, allergenNames } of INGREDIENTS) { + const ingredientId = ingredientIdByName.get(name); + if (ingredientId === undefined) continue; + for (const allergenName of allergenNames) { + const allergyId = allergyIdByCategoryName.get(allergenName); + if (allergyId !== undefined) links.push({ ingredientId, allergyId }); + } + } + if (links.length > 0) { + await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true }); + } } diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 7732fba..209f1a7 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -1,8 +1,18 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared"; +import { + updateAllergiesSchema, + updateDietSchema, + updateDislikedIngredientsSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js"; +import { + getAllergyIds, + getDislikedIngredientIds, + updateAllergies, + updateDiet, + updateDislikedIngredients, +} from "./profile.service.js"; /** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */ export const profileRouter = Router(); @@ -37,3 +47,26 @@ profileRouter.patch( res.status(200).json(allergyIds); }), ); + +profileRouter.get( + "/disliked-ingredients", + requireAuth, + wrapAsyncHandler(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(async (req, res) => { + const input = updateDislikedIngredientsSchema.parse(req.body); + const ids = await updateDislikedIngredients( + res.locals.userProfile.id, + input.dislikedIngredientIds, + ); + res.status(200).json(ids); + }), +); diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts index 487a19c..39699bf 100644 --- a/apps/api/src/modules/profile/profile.service.ts +++ b/apps/api/src/modules/profile/profile.service.ts @@ -74,3 +74,49 @@ export async function updateAllergies( return allergyIds; } + +/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */ +export async function getDislikedIngredientIds(userProfileId: number): Promise { + 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 { + 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; +} diff --git a/apps/api/src/modules/recipe/recipe.routes.ts b/apps/api/src/modules/recipe/recipe.routes.ts new file mode 100644 index 0000000..989570b --- /dev/null +++ b/apps/api/src/modules/recipe/recipe.routes.ts @@ -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(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(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(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(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(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(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(async (req, res) => { + const id = parseRecipeId(req.params.id); + await removeFavorite(id, res.locals.userProfile.id); + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts new file mode 100644 index 0000000..cccca0e --- /dev/null +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -0,0 +1,415 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { + type AllergyView, + type CreateRecipeInput, + type DietView, + ErrorCode, + type IngredientView, + type RecipeSummaryView, + type RecipeTab, + type RecipeView, + type UpdateRecipeInput, +} from "@batch-cooking/shared"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "../../db/prisma.js"; + +/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ +function recipeInclude(viewerId: number) { + return { + ingredients: { + include: { + ingredient: { + include: { allergies: { include: { allergy: { include: { category: true } } } } }, + }, + }, + }, + steps: { orderBy: { order: "asc" } }, + diets: { include: { diet: true } }, + favoritedBy: { where: { userProfileId: viewerId } }, + } satisfies Prisma.RecipeInclude; +} + +type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; +type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"]; + +/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ +function toIngredientView(ingredient: IngredientWithAllergies): IngredientView { + return { + id: ingredient.id, + name: ingredient.name, + icon: ingredient.icon, + allergens: ingredient.allergies.map(({ allergy }) => ({ + id: allergy.id, + name: allergy.category.name, + kind: allergy.category.kind, + })), + }; +} + +function toDietView(diet: { id: number; name: string }): DietView { + return { id: diet.id, name: diet.name }; +} + +/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ +function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { + const byId = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(", ")}`); + } +} diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts index cd3e02a..f617c9e 100644 --- a/apps/api/src/modules/reference/reference.routes.ts +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -1,13 +1,15 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { Router } from "express"; -import { getAllergies, getDiets } from "./reference.service.js"; +import { getAllergies, getDiets, getIngredients } from "./reference.service.js"; /** - * Router mounted at `/reference` in app.ts. Both routes are deliberately + * Router mounted at `/reference` in app.ts. Every route is deliberately * public (no `requireAuth`) — this is static reference data, not * per-household state, and the signup wizard (household/regime/allergen * steps) needs to read it before an account — and therefore a session — - * exists. + * exists. `/ingredients` follows the same reasoning even though it's only + * consumed post-login (the recipe catalog) — it's still non-administrable + * reference data, no reason to require a session to read it. */ export const referenceRouter = Router(); @@ -24,3 +26,10 @@ referenceRouter.get( res.status(200).json(await getAllergies()); }), ); + +referenceRouter.get( + "/ingredients", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getIngredients()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 2c43575..5440f20 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,4 +1,4 @@ -import type { AllergyView, DietView } from "@batch-cooking/shared"; +import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ @@ -23,3 +23,28 @@ export async function getAllergies(): Promise { kind: allergy.category.kind, })); } + +/** + * All reference ingredients, alphabetically, each resolved to its allergens + * (see `IngredientAllergy` in schema.prisma) — same aplattening approach as + * {@link getAllergies}. Ingredients with no linked allergen come back with + * `allergens: []`. + */ +export async function getIngredients(): Promise { + const ingredients = await prisma.ingredient.findMany({ + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + }, + orderBy: { name: "asc" }, + }); + return ingredients.map((ingredient) => ({ + id: ingredient.id, + name: ingredient.name, + icon: ingredient.icon, + allergens: ingredient.allergies.map(({ allergy }) => ({ + id: allergy.id, + name: allergy.category.name, + kind: allergy.category.kind, + })), + })); +} diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index e96a609..9299693 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -97,7 +97,9 @@ describe("Planning", () => { const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseId: number = houseRes.body.id; - const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); + const recipe = await prisma.recipe.create({ + data: { name: "Ratatouille", authorId: houseRes.body.adminId }, + }); const planning = await prisma.planning.create({ data: { houseId, @@ -145,7 +147,9 @@ describe("Planning", () => { const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseId: number = houseRes.body.id; - const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } }); + const recipe = await prisma.recipe.create({ + data: { name: "Curry de lentilles", authorId: houseRes.body.adminId }, + }); const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 }); const planning = await prisma.planning.create({ data: { diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts index f515935..b2247ef 100644 --- a/apps/api/test/profile.test.ts +++ b/apps/api/test/profile.test.ts @@ -125,4 +125,64 @@ describe("Profile", () => { expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND); }); }); + + describe("GET /profile/disliked-ingredients + PATCH /profile/disliked-ingredients", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const getRes = await request(app).get("/profile/disliked-ingredients"); + const patchRes = await request(app) + .patch("/profile/disliked-ingredients") + .send({ dislikedIngredientIds: [] }); + + expect(getRes.status).to.equal(401); + expect(patchRes.status).to.equal(401); + }); + + it("starts empty, then reflects a saved selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); + const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + + const initial = await agent.get("/profile/disliked-ingredients"); + expect(initial.body).to.deep.equal([]); + + const patchRes = await agent + .patch("/profile/disliked-ingredients") + .send({ dislikedIngredientIds: [tomate.id, oignon.id] }); + expect(patchRes.status).to.equal(200); + expect(patchRes.body.sort()).to.deep.equal([tomate.id, oignon.id].sort()); + + const refetch = await agent.get("/profile/disliked-ingredients"); + expect(refetch.body.sort()).to.deep.equal([tomate.id, oignon.id].sort()); + }); + + it("replaces (not merges) the previous selection", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); + const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + + await agent + .patch("/profile/disliked-ingredients") + .send({ dislikedIngredientIds: [tomate.id] }); + await agent + .patch("/profile/disliked-ingredients") + .send({ dislikedIngredientIds: [oignon.id] }); + + const res = await agent.get("/profile/disliked-ingredients"); + expect(res.body).to.deep.equal([oignon.id]); + }); + + it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent + .patch("/profile/disliked-ingredients") + .send({ dislikedIngredientIds: [999_999] }); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); + }); + }); }); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts new file mode 100644 index 0000000..0af5e82 --- /dev/null +++ b/apps/api/test/recipe.test.ts @@ -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 { + 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; 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); + }); + }); +}); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 85e048b..700f60e 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -46,4 +46,23 @@ describe("Reference data", () => { expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); }); }); + + describe("GET /reference/ingredients", () => { + it("returns the seeded ingredients, no session required", async () => { + const res = await request(app).get("/reference/ingredients"); + + expect(res.status).to.equal(200); + expect(res.body.length).to.be.greaterThan(0); + expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); + expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]); + }); + + it("resolves each ingredient's linked allergens, empty for one with none", async () => { + const res = await request(app).get("/reference/ingredients"); + + const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name); + expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); + expect(byName("Tomate").allergens).to.deep.equal([]); + }); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index eda674e..5555b30 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; import { LoginPage } from "./pages/LoginPage"; import { PlanningPage } from "./pages/PlanningPage"; +import { RecipeFormPage } from "./pages/RecipeFormPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; @@ -47,7 +48,14 @@ export function App() { } > } /> + {/* 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). */} } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 408ecbc..2852c70 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,15 +1,21 @@ import { type AllergyView, type ApiErrorResponse, + type CreateRecipeInput, type DietView, ErrorCode, type HouseView, + type IngredientView, type LoginInput, type PlanningView, type PreferencesView, + type RecipeSummaryView, + type RecipeTab, + type RecipeView, type SafeUserProfile, type SignupInput, type ThemePreference, + type UpdateRecipeInput, } from "@batch-cooking/shared"; /** @@ -132,6 +138,48 @@ export class ApiClient { return this.request("/reference/allergies"); } + /** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */ + public getIngredients(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.request(`/recipes/${id}`, { method: "DELETE" }); + } + + /** Favorites a recipe for the current user — idempotent. */ + public addFavoriteRecipe(id: number): Promise { + return this.request(`/recipes/${id}/favorite`, { method: "POST" }); + } + + /** Unfavorites a recipe for the current user — idempotent. */ + public removeFavoriteRecipe(id: number): Promise { + return this.request(`/recipes/${id}/favorite`, { method: "DELETE" }); + } + /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ public getCurrentHouse(): Promise { 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 { + 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 { + return this.request("/profile/disliked-ingredients", { + method: "PATCH", + body: JSON.stringify({ dislikedIngredientIds }), + }); + } + /** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */ public getPreferences(): Promise { return this.request("/preferences"); diff --git a/apps/web/src/features/profile/DislikedIngredientsField.tsx b/apps/web/src/features/profile/DislikedIngredientsField.tsx new file mode 100644 index 0000000..6dcb2a0 --- /dev/null +++ b/apps/web/src/features/profile/DislikedIngredientsField.tsx @@ -0,0 +1,68 @@ +import type { IngredientView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; +// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's +// ingredient picker, features/recipes/) rather than a second search field — +// same reference ingredient list, same "type to filter" behavior, just +// without the recipe form's quantity/unit per line. Its own recipes.scss +// import already covers `.ingredient-autocomplete`; this file's explicit +// import below only adds `.disliked-ingredients-field__*` (see recipes.scss +// — colocated there since it's the same "reference ingredient picker" +// visual family, even though this field lives in the profile feature). +import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete"; +import "../recipes/recipes.scss"; +import "./profile-forms.scss"; + +/** + * Search-and-add + removable-chips field for the current user's personal + * "disliked ingredients" list — a taste preference, distinct from + * `AllergySelect`'s medical allergy list. Used on `/parametres/preferences` + * (`PreferencesPage`); crossed against a recipe's own ingredients on its + * detail panel (`RecipeDetailPanel`) to surface just the relevant ones. + */ +export function DislikedIngredientsField({ + ingredients, + value, + onChange, +}: { + ingredients: IngredientView[]; + value: number[]; + onChange: (ingredientIds: number[]) => void; +}) { + const { t } = useTranslation(); + const selected = ingredients.filter((ingredient) => value.includes(ingredient.id)); + + function add(ingredient: IngredientView) { + onChange([...value, ingredient.id]); + } + + function remove(ingredientId: number) { + onChange(value.filter((id) => id !== ingredientId)); + } + + return ( + // `
`/`` (not a bare `