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/migrations/20260818081109_ingredient_category/migration.sql b/apps/api/prisma/migrations/20260818081109_ingredient_category/migration.sql new file mode 100644 index 0000000..f2674a0 --- /dev/null +++ b/apps/api/prisma/migrations/20260818081109_ingredient_category/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS'); + +-- AlterTable +ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS'; + diff --git a/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql b/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql new file mode 100644 index 0000000..ef07b6a --- /dev/null +++ b/apps/api/prisma/migrations/20260818104845_ingredient_diet/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ingredient_diet" ( + "ingredient_id" INTEGER NOT NULL, + "diet_id" INTEGER NOT NULL, + + CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id") +); + +-- AddForeignKey +ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql b/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql new file mode 100644 index 0000000..b2cc977 --- /dev/null +++ b/apps/api/prisma/migrations/20260818113250_ingredient_taxonomy_rework/migration.sql @@ -0,0 +1,38 @@ +-- Rewritten after this migration failed on a database that already had +-- seeded ingredient rows: the original version (auto-generated by `prisma +-- migrate diff`) cast every existing `category` value directly from the +-- old 18-value enum to the new 7-value one, which fails for every row +-- since none of the old values exist in the new enum. This version instead +-- adds the new columns with a safe default (never casting existing data), +-- then swaps them in — the same "add with a default, correct for real on +-- the next seed run" pattern already used for `IngredientCategory`'s and +-- `IngredientSubcategory`'s own `@default(...)` (see their doc comments in +-- schema.prisma). `seedReferenceData()` runs right after `migrate deploy` +-- on every container start (see apps/api/Dockerfile) and corrects every +-- row's real category/subcategory immediately. +-- +-- `DROP TYPE IF EXISTS "IngredientSubcategory"` guards against a previous +-- failed attempt at this exact migration: that CREATE TYPE statement runs +-- outside the AlterEnum transaction below and so persists even though the +-- rest of that failed attempt rolled back — retrying without this guard +-- would hit "type already exists". + +-- CreateEnum +DROP TYPE IF EXISTS "IngredientSubcategory"; +CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES'); + +-- CreateEnum +DROP TYPE IF EXISTS "IngredientCategory_new"; +CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES'); + +-- AlterTable: add the new columns at their defaults — no cast of existing +-- `category` values, so this succeeds regardless of what the table +-- currently holds. +ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'EPICERIE_SECHE'; +ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES'; + +-- Swap the old `category` column (old 18-value enum) out for the new one. +ALTER TABLE "ingredients" DROP COLUMN "category"; +ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category"; +DROP TYPE "IngredientCategory"; +ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory"; diff --git a/apps/api/prisma/migrations/20260818121549_ingredient_icon_type/migration.sql b/apps/api/prisma/migrations/20260818121549_ingredient_icon_type/migration.sql new file mode 100644 index 0000000..8ba6cb1 --- /dev/null +++ b/apps/api/prisma/migrations/20260818121549_ingredient_icon_type/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR'); + +-- AlterTable +ALTER TABLE "ingredients" DROP COLUMN "icon", +ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f398ab4..c680e77 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,9 @@ model Diet { id Int @id @default(autoincrement()) name String @unique - users UserProfile[] + users UserProfile[] + recipes RecipeDiet[] + ingredients IngredientDiet[] @@map("diet") } @@ -70,8 +75,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 +96,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 +214,271 @@ 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") } -model Ingredient { - id Int @id @default(autoincrement()) - name String - icon String? - alternateRecipeId Int? @map("alternate_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") - alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) + userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + + @@id([userProfileId, recipeId]) + @@map("recipe_favorite") +} + +/// Explicit join table for the recipe <-> diet "associated regime" tags +/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a +/// manual reminder set by whoever creates/edits the recipe, not computed +/// from its ingredients. +model RecipeDiet { + recipeId Int @map("recipe_id") + dietId Int @map("diet_id") + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) + + @@id([recipeId, dietId]) + @@map("recipe_diet") +} + +/// `name` is `@unique` — not in the original spec doc, added so the seed +/// script (reference-seed-data.ts) can `upsert` by name and stay +/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. +/// Ingredients are reference data (like Diet/Allergy): seeded, never +/// created/edited/deleted through the API. +/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so +/// the ingredient picker (apps/web) can offer category browsing, not just +/// free-text search: with 400+ reference ingredients, search alone doesn't +/// scale to actually *finding* one. Reworked from an earlier, less +/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style +/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" — +/// meant an ingredient's category depended on which one you thought of +/// first) into how a French grocery store is actually laid out: 7 aisles, +/// each with a couple of {@link IngredientSubcategory} racks for finer +/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors +/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file +/// is the single source of truth for which ingredient belongs to which +/// (category, subcategory) pair, these enums just give it type-safe +/// columns to live in. `@default(EPICERIE_SECHE)` exists only so this +/// column can be added `NOT NULL` to a table that may already have rows — +/// the seed script corrects every row's real category on the very next +/// run, this default is never the intended value for a real ingredient. +enum IngredientCategory { + /// 🥦 Légumes, fruits, herbes fraîches. + PRODUITS_FRAIS + /// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer. + BOUCHERIE_POISSONNERIE + /// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des + /// produits secs/en conserve qui ne rentre dans aucune autre case + /// (algues séchées, champignons séchés…). + EPICERIE_SECHE + /// 🍞 Pains et pâtes à cuire (crues, à enfourner). + BOULANGERIE + /// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux, + /// tofu…). + CREMERIE_FROMAGE + /// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de + /// cuisine…). + CONDIMENTS_EPICES + /// 🍳 Bases de préparation (farines, bouillons, eau), épaississants + /// (levures, fécules, gélatine), sucres. + AIDES_CULINAIRES +} + +/// Finer-grained rack within one {@link IngredientCategory} aisle — see +/// that enum's doc comment for why this two-level scheme replaced a flat +/// list. Each value belongs to exactly one category by construction (see +/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the +/// database level — Postgres enums can't express that relationship, same +/// tradeoff already accepted for `IngredientCategory` itself). +/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as +/// `IngredientCategory`'s default, never the intended value for a real row. +enum IngredientSubcategory { + // --- Produits frais ------------------------------------------------------ + LEGUMES + FRUITS + HERBES_FRAICHES + // --- Boucherie & poissonnerie --------------------------------------------- + VIANDES + VOLAILLES + POISSONS + CRUSTACES_FRUITS_DE_MER + // --- Épicerie sèche -------------------------------------------------------- + FECULENTS + LEGUMINEUSES + GRAINES_FRUITS_SECS + /// Catch-all for dried/tinned pantry items that don't fit the three + /// subcategories above — dried seaweed, dried mushrooms, tinned bamboo + /// shoots/water chestnuts… + AUTRES + // --- Boulangerie ------------------------------------------------------- + PAINS + /// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) — + /// distinct from `PAINS` (already-baked bread). + PATES_A_CUIRE + // --- Crémerie & fromage -------------------------------------------------- + PRODUITS_LAITIERS + OEUFS + /// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu. + ALTERNATIVES + // --- Condiments & épices ------------------------------------------------- + EPICES + SAUCES + /// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that + /// season rather than form the base of a dish. + ASSAISONNEMENTS + // --- Aides culinaires ---------------------------------------------------- + /// Flours, stocks/broths, canned tomato bases, water — the literal base + /// a recipe is built on. + BASES + /// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch, + /// gelatin. + EPAISSISSANTS + SUCRES +} + +/// Generic pictogram *type* for an ingredient — not in the original spec +/// doc. Started as a free-text emoji column (one character per ingredient, +/// 437 different ones), which the product decision recorded in chat +/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG +/// icons (unrealistic), ingredients share a small vocabulary of ~20 +/// generic shapes grouped by *what kind of thing* they are — a vegetable, +/// a bottle of oil, a wedge of cheese — regardless of which specific +/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps +/// each value to its actual SVG (matching the app's hand-drawn line-icon +/// style, never emoji — see that file for the full reasoning and the +/// exact `reference-seed-data.ts` assignment per ingredient). +/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as +/// `IngredientCategory`'s default, never the intended value for a real row. +enum IngredientIcon { + VEGETABLE + FRUIT + HERB + MEAT + POULTRY + FISH + SHELLFISH + GRAIN + LEGUME + NUT_SEED + BREAD + DOUGH + MILK + CHEESE + EGG + SPROUT + SPICE + JAR + BOTTLE + DRINK + STOCK_POT + SUGAR +} + +model Ingredient { + id Int @id @default(autoincrement()) + name String @unique + icon IngredientIcon @default(JAR) + category IngredientCategory @default(EPICERIE_SECHE) + subcategory IngredientSubcategory @default(AUTRES) + alternateRecipeId Int? @map("alternate_recipe") + + 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[] + /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. + diets IngredientDiet[] @@map("ingredients") } +/// Explicit join table for the ingredients <-> diet regime association — +/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe +/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can +/// flag e.g. an ingredient as vegan without the user having to open its +/// packaging. Seeded by category in `reference-seed-data.ts` (most +/// ingredients in a category share the same compatible regimes, with +/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as +/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is +/// trivially compatible — storing it would be pure noise) and `Sans gluten` +/// (already fully derivable from whether `IngredientAllergy` links this +/// ingredient to the `Gluten` allergen — a second, hand-maintained source +/// for the same fact would only risk drifting out of sync with it). +model IngredientDiet { + ingredientId Int @map("ingredient_id") + dietId Int @map("diet_id") + + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) + + @@id([ingredientId, dietId]) + @@map("ingredient_diet") +} + +/// Explicit join table for the ingredients <-> allergy association — not in +/// the original spec doc, added so the recipe catalog can surface which +/// allergens an ingredient (and by extension a recipe) carries. Same shape +/// as `UserProfileAllergy`. +model IngredientAllergy { + ingredientId Int @map("ingredient_id") + allergyId Int @map("allergy_id") + + ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) + + @@id([ingredientId, allergyId]) + @@map("ingredient_allergy") +} + /// recipe <-> ingredients association. The spec documents this as a plain /// 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..d9de8d5 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -1,4 +1,10 @@ -import type { AllergenKind, PrismaClient } from "@prisma/client"; +import type { + AllergenKind, + IngredientCategory, + IngredientIcon, + IngredientSubcategory, + PrismaClient, +} from "@prisma/client"; // Short, optional-to-pick regime list — `UserProfile.dietId` stays // nullable, this is not meant to be exhaustive. @@ -28,6 +34,848 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ { name: "Mollusques", kind: "ALLERGY" }, ]; +interface IngredientSeed { + name: string; + /** + * Generic pictogram type, overriding its group's `defaultIcon` below — + * only needed for the exceptions within a subcategory (a wedge of cheese + * inside an otherwise-milk "produits laitiers" group, a stockpot inside + * an otherwise-flour "bases" group…). See `IngredientIcon` in + * schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx` + * for the actual pictograms — this was a free-text emoji field until the + * product decision recorded in chat replaced it with this small, shared + * vocabulary. + */ + icon?: IngredientIcon; + allergenNames: string[]; + /** + * Diet regimes this ingredient is compatible with, overriding its group's + * `defaultDiets` below — only needed for the exceptions within a + * subcategory (a fish-based stock inside an otherwise-vegan "bases" + * group, a butter-based dough inside an otherwise-vegan "pâtes à + * cuire"…). References `DIETS` by name, same as `allergenNames` + * references `ALLERGENS`. Deliberately never includes `"Omnivore"` + * (trivial, every ingredient qualifies) or `"Sans gluten"` (derived from + * `allergenNames` instead — see `IngredientDiet` in schema.prisma for + * why). + */ + dietNames?: string[]; +} + +// A broad pantry list — the goal is to cover the large majority of what a +// home cook reaches for (viandes, poissons, légumes, fruits, féculents, +// condiments, épices...), not just enough to exercise the recipe catalog in +// tests. Ingredients are reference data (see `Ingredient` in schema.prisma: +// `name` is `@unique`, there's no create/update/delete endpoint), so this is +// meant to already be comprehensive at first deploy rather than grown +// piecemeal as recipes need more of it. `allergenNames` reference +// `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is +// covered by at least one ingredient here. +// +// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/ +// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy +// ("rayons") reworked from an earlier, less intuitive scheme that mixed +// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones +// ("légumes") — an ingredient's category used to depend on which angle you +// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses +// this to offer category browsing, not just free-text search — with 400+ +// ingredients, search alone doesn't scale to actually *finding* something, +// and a single flat list of 7 aisles alone wouldn't either (some aisles +// would be 100+ items deep). Each group's key is the single source of +// truth for that mapping; `INGREDIENTS` below just flattens it back to one +// array for the seeding loop. +// +// `defaultDiets`/`defaultIcon` are what every item in the group shares +// unless it sets its own `dietNames`/`icon` — most groups are homogeneous +// on both counts (a vegetable is always vegan and always looks like a +// vegetable; a cut of meat never is and never does), so this avoids +// repeating the same values on hundreds of items; only a group's +// exceptions (a fish-based stock inside "bases", a cheese inside "produits +// laitiers"…) need a per-item override. +const INGREDIENT_GROUPS: Array<{ + category: IngredientCategory; + subcategory: IngredientSubcategory; + defaultDiets: string[]; + defaultIcon: IngredientIcon; + items: IngredientSeed[]; +}> = [ + // ========================================================================= + // Produits frais + // ========================================================================= + { + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "VEGETABLE", + items: [ + { name: "Tomate", allergenNames: [] }, + { name: "Oignon", allergenNames: [] }, + { name: "Échalote", allergenNames: [] }, + { name: "Ail", allergenNames: [] }, + { name: "Carotte", allergenNames: [] }, + { name: "Courgette", allergenNames: [] }, + { name: "Concombre", allergenNames: [] }, + { name: "Cornichons", allergenNames: [] }, + { name: "Poivron", allergenNames: [] }, + { name: "Champignon", allergenNames: [] }, + { name: "Cèpes", allergenNames: [] }, + { name: "Aubergine", allergenNames: [] }, + { name: "Brocoli", allergenNames: [] }, + { name: "Chou-fleur", allergenNames: [] }, + { name: "Chou blanc", allergenNames: [] }, + { name: "Chou rouge", allergenNames: [] }, + { name: "Chou de Bruxelles", allergenNames: [] }, + { name: "Épinard", allergenNames: [] }, + { name: "Blette", allergenNames: [] }, + { name: "Salade", allergenNames: [] }, + { name: "Roquette", allergenNames: [] }, + { name: "Cresson", allergenNames: [] }, + { name: "Poireau", allergenNames: [] }, + { name: "Céleri", allergenNames: ["Céleri"] }, + { name: "Radis", allergenNames: [] }, + { name: "Betterave", allergenNames: [] }, + { name: "Navet", allergenNames: [] }, + { name: "Panais", allergenNames: [] }, + { name: "Haricot vert", allergenNames: [] }, + { name: "Petit pois", allergenNames: [] }, + { name: "Maïs", allergenNames: [] }, + { name: "Artichaut", allergenNames: [] }, + { name: "Fenouil", allergenNames: [] }, + { name: "Endive", allergenNames: [] }, + { name: "Potiron", allergenNames: [] }, + { name: "Butternut", allergenNames: [] }, + { name: "Asperge", allergenNames: [] }, + { name: "Avocat", allergenNames: [] }, + { name: "Pomme de terre", allergenNames: [] }, + { name: "Patate douce", allergenNames: [] }, + { name: "Tomates cerises", allergenNames: [] }, + { name: "Pak-choï", allergenNames: [] }, + { name: "Germes de soja", allergenNames: ["Soja"] }, + { name: "Shiitake", allergenNames: [] }, + { name: "Daikon", allergenNames: [] }, + { name: "Piment vert frais", allergenNames: [] }, + ], + }, + { + category: "PRODUITS_FRAIS", + subcategory: "FRUITS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "FRUIT", + items: [ + { name: "Citron", allergenNames: [] }, + { name: "Citron vert", allergenNames: [] }, + { name: "Pomme", allergenNames: [] }, + { name: "Poire", allergenNames: [] }, + { name: "Banane", allergenNames: [] }, + { name: "Orange", allergenNames: [] }, + { name: "Clémentine", allergenNames: [] }, + { name: "Pamplemousse", allergenNames: [] }, + { name: "Fraise", allergenNames: [] }, + { name: "Framboise", allergenNames: [] }, + { name: "Myrtille", allergenNames: [] }, + { name: "Mûre", allergenNames: [] }, + { name: "Cerise", allergenNames: [] }, + { name: "Abricot", allergenNames: [] }, + { name: "Pêche", allergenNames: [] }, + { name: "Prune", allergenNames: [] }, + { name: "Raisin", allergenNames: [] }, + { name: "Melon", allergenNames: [] }, + { name: "Pastèque", allergenNames: [] }, + { name: "Ananas", allergenNames: [] }, + { name: "Mangue", allergenNames: [] }, + { name: "Kiwi", allergenNames: [] }, + { name: "Figue", allergenNames: [] }, + { name: "Datte", allergenNames: [] }, + { name: "Litchi", allergenNames: [] }, + { name: "Grenade", allergenNames: [] }, + { name: "Rhubarbe", allergenNames: [] }, + { name: "Coing", allergenNames: [] }, + ], + }, + { + category: "PRODUITS_FRAIS", + subcategory: "HERBES_FRAICHES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "HERB", + items: [ + { name: "Basilic", allergenNames: [] }, + { name: "Persil", allergenNames: [] }, + { name: "Thym", allergenNames: [] }, + { name: "Romarin", allergenNames: [] }, + { name: "Laurier", allergenNames: [] }, + { name: "Ciboulette", allergenNames: [] }, + { name: "Coriandre fraîche", allergenNames: [] }, + { name: "Menthe", allergenNames: [] }, + { name: "Origan", allergenNames: [] }, + { name: "Aneth", allergenNames: [] }, + { name: "Estragon", allergenNames: [] }, + { name: "Sarriette", allergenNames: [] }, + { name: "Marjolaine", allergenNames: [] }, + { name: "Sauge", allergenNames: [] }, + { name: "Cerfeuil", allergenNames: [] }, + { name: "Gingembre", allergenNames: [] }, + { name: "Citronnelle", allergenNames: [] }, + { name: "Combava", allergenNames: [] }, + ], + }, + // ========================================================================= + // Boucherie & poissonnerie + // ========================================================================= + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "VIANDES", + defaultDiets: [], + defaultIcon: "MEAT", + items: [ + { name: "Lapin", allergenNames: [] }, + { name: "Bœuf haché", allergenNames: [] }, + { name: "Steak de bœuf", allergenNames: [] }, + { name: "Rôti de bœuf", allergenNames: [] }, + { name: "Escalope de veau", allergenNames: [] }, + { name: "Filet mignon de porc", allergenNames: [] }, + { name: "Côte de porc", allergenNames: [] }, + { name: "Agneau", allergenNames: [] }, + { name: "Gigot d'agneau", allergenNames: [] }, + { name: "Lardons", allergenNames: [] }, + { name: "Bacon", allergenNames: [] }, + { name: "Jambon blanc", allergenNames: [] }, + { name: "Jambon cru", allergenNames: [] }, + { name: "Saucisse", allergenNames: [] }, + { name: "Chorizo", allergenNames: [] }, + { name: "Merguez", allergenNames: [] }, + { name: "Prosciutto", allergenNames: [] }, + { name: "Pancetta", allergenNames: [] }, + { name: "Mortadelle", allergenNames: [] }, + { name: "Salami", allergenNames: [] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "VOLAILLES", + defaultDiets: [], + defaultIcon: "POULTRY", + items: [ + { name: "Poulet", allergenNames: [] }, + { name: "Dinde", allergenNames: [] }, + { name: "Canard", allergenNames: [] }, + { name: "Magret de canard", allergenNames: [] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "POISSONS", + defaultDiets: ["Pescétarien"], + defaultIcon: "FISH", + items: [ + { name: "Saumon", allergenNames: ["Poissons"] }, + { name: "Thon", allergenNames: ["Poissons"] }, + { name: "Cabillaud", allergenNames: ["Poissons"] }, + { name: "Truite", allergenNames: ["Poissons"] }, + { name: "Sardine", allergenNames: ["Poissons"] }, + { name: "Anchois", allergenNames: ["Poissons"] }, + { name: "Merlan", allergenNames: ["Poissons"] }, + { name: "Surimi", allergenNames: ["Poissons"] }, + { name: "Bar (loup de mer)", allergenNames: ["Poissons"] }, + { name: "Dorade", allergenNames: ["Poissons"] }, + { name: "Sole", allergenNames: ["Poissons"] }, + { name: "Turbot", allergenNames: ["Poissons"] }, + { name: "Merlu", allergenNames: ["Poissons"] }, + { name: "Colin", allergenNames: ["Poissons"] }, + { name: "Lieu noir", allergenNames: ["Poissons"] }, + { name: "Églefin", allergenNames: ["Poissons"] }, + { name: "Maquereau", allergenNames: ["Poissons"] }, + { name: "Hareng", allergenNames: ["Poissons"] }, + { name: "Rouget", allergenNames: ["Poissons"] }, + { name: "Raie", allergenNames: ["Poissons"] }, + { name: "Lotte", allergenNames: ["Poissons"] }, + { name: "Flétan", allergenNames: ["Poissons"] }, + { name: "Espadon", allergenNames: ["Poissons"] }, + { name: "Carpe", allergenNames: ["Poissons"] }, + { name: "Brochet", allergenNames: ["Poissons"] }, + { name: "Perche", allergenNames: ["Poissons"] }, + { name: "Tilapia", allergenNames: ["Poissons"] }, + { name: "Panga", allergenNames: ["Poissons"] }, + { name: "Saumon fumé", allergenNames: ["Poissons"] }, + { name: "Poisson séché", allergenNames: ["Poissons"] }, + ], + }, + { + category: "BOUCHERIE_POISSONNERIE", + subcategory: "CRUSTACES_FRUITS_DE_MER", + defaultDiets: ["Pescétarien"], + defaultIcon: "SHELLFISH", + items: [ + { name: "Crevettes", allergenNames: ["Crustacés"] }, + { name: "Langoustines", allergenNames: ["Crustacés"] }, + { name: "Homard", allergenNames: ["Crustacés"] }, + { name: "Crabe", allergenNames: ["Crustacés"] }, + { name: "Langouste", allergenNames: ["Crustacés"] }, + { name: "Moules", allergenNames: ["Mollusques"] }, + { name: "Huîtres", allergenNames: ["Mollusques"] }, + { name: "Saint-Jacques", allergenNames: ["Mollusques"] }, + { name: "Calamar", allergenNames: ["Mollusques"] }, + { name: "Poulpe", allergenNames: ["Mollusques"] }, + { name: "Palourdes", allergenNames: ["Mollusques"] }, + { name: "Bulots", allergenNames: ["Mollusques"] }, + ], + }, + // ========================================================================= + // Épicerie sèche + // ========================================================================= + { + category: "EPICERIE_SECHE", + subcategory: "FECULENTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "GRAIN", + items: [ + { name: "Semoule", allergenNames: ["Gluten"] }, + { name: "Couscous", allergenNames: ["Gluten"] }, + { name: "Boulgour", allergenNames: ["Gluten"] }, + { name: "Polenta", allergenNames: [] }, + { name: "Quinoa", allergenNames: [] }, + { name: "Pâtes", allergenNames: ["Gluten"] }, + { name: "Pâtes complètes", allergenNames: ["Gluten"] }, + { name: "Riz", allergenNames: [] }, + { name: "Riz basmati", allergenNames: [] }, + { name: "Riz complet", allergenNames: [] }, + { name: "Flocons d'avoine", allergenNames: ["Gluten"] }, + { name: "Spaghetti", allergenNames: ["Gluten"] }, + { name: "Penne", allergenNames: ["Gluten"] }, + { name: "Tagliatelles", allergenNames: ["Gluten"] }, + { name: "Lasagnes (feuilles)", allergenNames: ["Gluten"] }, + { name: "Gnocchi", allergenNames: ["Gluten"] }, + { name: "Riz arborio", allergenNames: [] }, + { name: "Nouilles de riz", allergenNames: [] }, + { name: "Nouilles udon", allergenNames: ["Gluten"] }, + { name: "Nouilles soba", allergenNames: ["Gluten"] }, + { name: "Nouilles chinoises", allergenNames: ["Gluten"] }, + { name: "Vermicelles de riz", allergenNames: [] }, + { name: "Vermicelles de soja", allergenNames: [] }, + { name: "Riz gluant", allergenNames: [] }, + { name: "Riz à sushi", allergenNames: [] }, + { name: "Riz jasmin", allergenNames: [] }, + ], + }, + { + category: "EPICERIE_SECHE", + subcategory: "LEGUMINEUSES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "LEGUME", + items: [ + { name: "Lentilles vertes", allergenNames: [] }, + { name: "Lentilles corail", allergenNames: [] }, + { name: "Pois chiches", allergenNames: [] }, + { name: "Haricots blancs", allergenNames: [] }, + { name: "Haricots rouges", allergenNames: [] }, + { name: "Haricots noirs", allergenNames: [] }, + { name: "Pois cassés", allergenNames: [] }, + { name: "Fèves", allergenNames: [] }, + { name: "Edamame", allergenNames: ["Soja"] }, + { name: "Haricots pinto", allergenNames: [] }, + ], + }, + { + category: "EPICERIE_SECHE", + subcategory: "GRAINES_FRUITS_SECS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "NUT_SEED", + items: [ + { name: "Cacahuètes", allergenNames: ["Arachides"] }, + { name: "Amandes", allergenNames: ["Fruits à coque"] }, + { name: "Noix", allergenNames: ["Fruits à coque"] }, + { name: "Noisettes", allergenNames: ["Fruits à coque"] }, + { name: "Noix de cajou", allergenNames: ["Fruits à coque"] }, + { name: "Pistaches", allergenNames: ["Fruits à coque"] }, + { name: "Noix de pécan", allergenNames: ["Fruits à coque"] }, + { name: "Poudre d'amande", allergenNames: ["Fruits à coque"] }, + { name: "Pignons de pin", allergenNames: [] }, + { name: "Graines de tournesol", allergenNames: [] }, + { name: "Graines de courge", allergenNames: [] }, + { name: "Noix de coco râpée", allergenNames: [] }, + { name: "Raisins secs", allergenNames: ["Sulfites"] }, + { name: "Pruneaux", allergenNames: ["Sulfites"] }, + { name: "Abricots secs", allergenNames: ["Sulfites"] }, + { name: "Graines de sésame", allergenNames: ["Graines de sésame"] }, + ], + }, + { + category: "EPICERIE_SECHE", + subcategory: "AUTRES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "JAR", + items: [ + { name: "Champignons noirs", allergenNames: [] }, + { name: "Algue nori", allergenNames: [] }, + { name: "Algue wakamé", allergenNames: [] }, + { name: "Algue kombu", allergenNames: [] }, + { name: "Pousses de bambou", allergenNames: [] }, + { name: "Châtaignes d'eau", allergenNames: [] }, + ], + }, + // ========================================================================= + // Boulangerie + // ========================================================================= + { + category: "BOULANGERIE", + subcategory: "PAINS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "BREAD", + items: [ + { name: "Pain", allergenNames: ["Gluten"] }, + { name: "Pain de mie", allergenNames: ["Gluten"] }, + { name: "Pain complet", allergenNames: ["Gluten"] }, + { name: "Baguette", allergenNames: ["Gluten"] }, + { name: "Pain de seigle", allergenNames: ["Gluten"] }, + { name: "Chapelure", allergenNames: ["Gluten"] }, + { + name: "Pain à burger", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pain brioché", + allergenNames: ["Gluten", "Lait", "Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pain à hot-dog", allergenNames: ["Gluten"] }, + { name: "Pain pita", allergenNames: ["Gluten"] }, + { name: "Pain bagel", allergenNames: ["Gluten"] }, + { name: "Naan", allergenNames: ["Gluten"] }, + { name: "Pain wrap", allergenNames: ["Gluten"] }, + { + name: "Pain viennois", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pain de campagne", allergenNames: ["Gluten"] }, + { name: "Pain aux céréales", allergenNames: ["Gluten"] }, + { name: "Petit pain", allergenNames: ["Gluten"] }, + { name: "Pain suédois", allergenNames: ["Gluten"] }, + { name: "Pain sans gluten", allergenNames: [] }, + { name: "Biscotte", allergenNames: ["Gluten"] }, + { name: "Croûtons", allergenNames: ["Gluten"] }, + { name: "Focaccia", allergenNames: ["Gluten"] }, + { name: "Ciabatta", allergenNames: ["Gluten"] }, + { name: "Tortilla de maïs", allergenNames: [] }, + { name: "Tortilla de blé", allergenNames: ["Gluten"] }, + ], + }, + { + category: "BOULANGERIE", + subcategory: "PATES_A_CUIRE", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "DOUGH", + items: [ + { + name: "Pâte feuilletée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pâte brisée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pâte à pizza", allergenNames: ["Gluten"] }, + { + name: "Pâte à tarte sablée", + allergenNames: ["Gluten", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + ], + }, + // ========================================================================= + // Crémerie & fromage + // ========================================================================= + { + category: "CREMERIE_FROMAGE", + subcategory: "PRODUITS_LAITIERS", + defaultDiets: ["Végétarien", "Pescétarien"], + defaultIcon: "MILK", + items: [ + { name: "Lait", allergenNames: ["Lait"] }, + { name: "Beurre", allergenNames: ["Lait"] }, + { name: "Crème fraîche", allergenNames: ["Lait"] }, + { name: "Crème liquide", allergenNames: ["Lait"] }, + { name: "Fromage", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Emmental", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Gruyère", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Parmesan", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Mozzarella", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Chèvre (fromage)", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Feta", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Comté", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Fromage blanc", allergenNames: ["Lait"] }, + { name: "Mascarpone", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Yaourt", allergenNames: ["Lait"] }, + { name: "Burrata", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Ricotta", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Pecorino", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Gorgonzola", icon: "CHEESE", allergenNames: ["Lait"] }, + { name: "Cheddar", icon: "CHEESE", allergenNames: ["Lait"] }, + ], + }, + { + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + defaultDiets: ["Végétarien", "Pescétarien"], + defaultIcon: "EGG", + items: [{ name: "Œuf", allergenNames: ["Œufs"] }], + }, + { + category: "CREMERIE_FROMAGE", + subcategory: "ALTERNATIVES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "SPROUT", + items: [ + { name: "Lait de coco", icon: "MILK", allergenNames: [] }, + { name: "Crème de coco", icon: "MILK", allergenNames: [] }, + { name: "Lait d'amande", icon: "MILK", allergenNames: ["Fruits à coque"] }, + { name: "Lait d'avoine", icon: "MILK", allergenNames: ["Gluten"] }, + { name: "Tofu", allergenNames: ["Soja"] }, + { name: "Tofu soyeux", allergenNames: ["Soja"] }, + ], + }, + // ========================================================================= + // Condiments & épices + // ========================================================================= + { + category: "CONDIMENTS_EPICES", + subcategory: "EPICES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "SPICE", + items: [ + { name: "Herbes de Provence", allergenNames: [] }, + { name: "Poivre noir", allergenNames: [] }, + { name: "Paprika", allergenNames: [] }, + { name: "Piment d'Espelette", allergenNames: [] }, + { name: "Piment de Cayenne", allergenNames: [] }, + { name: "Cumin", allergenNames: [] }, + { name: "Curry (poudre)", allergenNames: [] }, + { name: "Curcuma", allergenNames: [] }, + { name: "Cannelle", allergenNames: [] }, + { name: "Muscade", allergenNames: [] }, + { name: "Safran", allergenNames: [] }, + { name: "Clou de girofle", allergenNames: [] }, + { name: "Vanille (gousse)", allergenNames: [] }, + { name: "Poivre blanc", allergenNames: [] }, + { name: "Poivre rose", allergenNames: [] }, + { name: "Poivre du Sichuan", allergenNames: [] }, + { name: "Paprika fumé", allergenNames: [] }, + { name: "Piment oiseau", allergenNames: [] }, + { name: "Baies de genièvre", allergenNames: [] }, + { name: "Anis étoilé (badiane)", allergenNames: [] }, + { name: "Anis vert", allergenNames: [] }, + { name: "Graines de fenouil", allergenNames: [] }, + { name: "Sumac", allergenNames: [] }, + { name: "Nigelle", allergenNames: [] }, + { name: "Quatre épices", allergenNames: [] }, + { name: "Colombo (poudre)", allergenNames: [] }, + { name: "Baharat", allergenNames: [] }, + { name: "Raifort", allergenNames: [] }, + { name: "Sel aux herbes", allergenNames: [] }, + { name: "Sel de céleri", allergenNames: ["Céleri"] }, + { name: "Fleur de sel", allergenNames: [] }, + { name: "Sel", allergenNames: [] }, + { name: "Cinq épices", allergenNames: [] }, + { name: "Garam masala", allergenNames: [] }, + { name: "Graines de coriandre", allergenNames: [] }, + { name: "Cardamome", allergenNames: [] }, + { name: "Fenugrec", allergenNames: [] }, + { name: "Piment jalapeño", allergenNames: [] }, + { name: "Piment chipotle", allergenNames: [] }, + { name: "Piment poblano", allergenNames: [] }, + { name: "Piment habanero", allergenNames: [] }, + { name: "Ras el hanout", allergenNames: [] }, + { name: "Za'atar", allergenNames: ["Graines de sésame"] }, + ], + }, + { + category: "CONDIMENTS_EPICES", + subcategory: "SAUCES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "JAR", + items: [ + { name: "Sauce soja", allergenNames: ["Soja"] }, + { name: "Moutarde", allergenNames: ["Moutarde"] }, + { + name: "Mayonnaise", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Ketchup", allergenNames: [] }, + { name: "Tabasco", allergenNames: [] }, + { + name: "Sauce Worcestershire", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { + name: "Sauce nuoc-mâm", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { name: "Wasabi", allergenNames: [] }, + { name: "Harissa", allergenNames: [] }, + { name: "Pâte de curry", allergenNames: [] }, + { name: "Beurre de cacahuète", allergenNames: ["Arachides"] }, + { name: "Moutarde de Dijon", allergenNames: ["Moutarde"] }, + { name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] }, + { name: "Sauce barbecue", allergenNames: [] }, + { + name: "Sauce tartare", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce cocktail", + allergenNames: ["Œufs"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce béarnaise", + allergenNames: ["Œufs", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce hollandaise", + allergenNames: ["Œufs", "Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce béchamel", + allergenNames: ["Lait", "Gluten"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Sauce teriyaki", allergenNames: ["Soja"] }, + { + name: "Sauce ponzu", + allergenNames: ["Soja", "Poissons"], + dietNames: ["Pescétarien"], + }, + { name: "Chimichurri", allergenNames: [] }, + { + name: "Pesto rouge (tomates séchées)", + allergenNames: ["Lait", "Fruits à coque"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Pesto", + allergenNames: ["Lait", "Fruits à coque"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Sauce huître", + allergenNames: ["Mollusques"], + dietNames: ["Pescétarien"], + }, + { name: "Sauce hoisin", allergenNames: ["Soja"] }, + { name: "Sauce sriracha", allergenNames: [] }, + { name: "Sauce sweet chili", allergenNames: [] }, + { name: "Miso", allergenNames: ["Soja"] }, + { + name: "Pâte de crevettes", + allergenNames: ["Crustacés"], + dietNames: ["Pescétarien"], + }, + { name: "Pâte de curry rouge (thaï)", allergenNames: [] }, + { name: "Pâte de curry vert (thaï)", allergenNames: [] }, + { name: "Tahini", allergenNames: ["Graines de sésame"] }, + ], + }, + { + category: "CONDIMENTS_EPICES", + subcategory: "ASSAISONNEMENTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "BOTTLE", + items: [ + { name: "Huile d'olive", allergenNames: [] }, + { name: "Huile de tournesol", allergenNames: [] }, + { name: "Huile de colza", allergenNames: [] }, + { name: "Huile de coco", allergenNames: [] }, + { name: "Huile de sésame", allergenNames: ["Graines de sésame"] }, + { name: "Vinaigre de cidre", allergenNames: [] }, + { name: "Vinaigre blanc", allergenNames: [] }, + { name: "Vinaigre balsamique", allergenNames: ["Sulfites"] }, + { name: "Câpres", icon: "JAR", allergenNames: [] }, + { name: "Olives", icon: "JAR", allergenNames: [] }, + { name: "Vin blanc (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] }, + { name: "Vin rouge (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin rouge", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de vin blanc", allergenNames: ["Sulfites"] }, + { name: "Vinaigre de xérès", allergenNames: ["Sulfites"] }, + { name: "Huile de noix", allergenNames: ["Fruits à coque"] }, + { name: "Huile de noisette", allergenNames: ["Fruits à coque"] }, + { name: "Huile d'arachide", allergenNames: ["Arachides"] }, + { name: "Huile pimentée", allergenNames: [] }, + { name: "Vinaigre de riz", allergenNames: [] }, + { name: "Mirin", icon: "DRINK", allergenNames: [] }, + { name: "Saké (cuisine)", icon: "DRINK", allergenNames: [] }, + { name: "Jus de citron", icon: "DRINK", allergenNames: [] }, + { name: "Jus de citron vert", icon: "DRINK", allergenNames: [] }, + { name: "Jus d'orange", icon: "DRINK", allergenNames: [] }, + { name: "Jus de pomme", icon: "DRINK", allergenNames: [] }, + { name: "Jus de raisin", icon: "DRINK", allergenNames: [] }, + { name: "Jus de tomate", icon: "DRINK", allergenNames: [] }, + { name: "Jus de cranberry", icon: "DRINK", allergenNames: [] }, + { name: "Café", icon: "DRINK", allergenNames: [] }, + { name: "Thé", icon: "DRINK", allergenNames: [] }, + { name: "Bière (cuisine)", icon: "DRINK", allergenNames: ["Gluten"] }, + { name: "Cidre (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] }, + { + name: "Champagne / vin pétillant (cuisine)", + icon: "DRINK", + allergenNames: ["Sulfites"], + }, + { name: "Porto (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] }, + { name: "Vin jaune (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] }, + { name: "Cognac", icon: "DRINK", allergenNames: [] }, + { name: "Rhum", icon: "DRINK", allergenNames: [] }, + { name: "Whisky", icon: "DRINK", allergenNames: [] }, + { name: "Vodka", icon: "DRINK", allergenNames: [] }, + ], + }, + // ========================================================================= + // Aides culinaires + // ========================================================================= + { + category: "AIDES_CULINAIRES", + subcategory: "BASES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "GRAIN", + items: [ + { name: "Farine de blé", allergenNames: ["Gluten"] }, + { name: "Farine complète", allergenNames: ["Gluten"] }, + { name: "Farine de maïs", allergenNames: [] }, + { name: "Farine de sarrasin", allergenNames: [] }, + { name: "Farine de riz", allergenNames: [] }, + { name: "Bouillon cube légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] }, + { + name: "Bouillon cube volaille", + icon: "STOCK_POT", + allergenNames: ["Céleri"], + dietNames: [], + }, + { name: "Concentré de tomate", icon: "JAR", allergenNames: [] }, + { name: "Coulis de tomate", icon: "JAR", allergenNames: [] }, + { name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] }, + { name: "Tomates séchées", icon: "JAR", allergenNames: [] }, + { name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, + { name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, + { + name: "Bouillon cube bœuf", + icon: "STOCK_POT", + allergenNames: ["Céleri"], + dietNames: [], + }, + { + name: "Bouillon cube poisson", + icon: "STOCK_POT", + allergenNames: ["Poissons", "Céleri"], + dietNames: ["Pescétarien"], + }, + { name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] }, + { + name: "Bouillon de volaille", + icon: "STOCK_POT", + allergenNames: ["Céleri"], + dietNames: [], + }, + { name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] }, + { name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] }, + { + name: "Dashi (bouillon japonais)", + icon: "STOCK_POT", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + { + name: "Bisque de crustacés", + icon: "STOCK_POT", + allergenNames: ["Crustacés"], + dietNames: ["Pescétarien"], + }, + { name: "Farine de tapioca", allergenNames: [] }, + { name: "Masa harina", allergenNames: [] }, + { name: "Eau", icon: "DRINK", allergenNames: [] }, + { name: "Eau gazeuse", icon: "DRINK", allergenNames: [] }, + { name: "Eau de fleur d'oranger", icon: "DRINK", allergenNames: [] }, + { name: "Eau de rose", icon: "DRINK", allergenNames: [] }, + { + name: "Fumet de poisson", + icon: "STOCK_POT", + allergenNames: ["Poissons"], + dietNames: ["Pescétarien"], + }, + ], + }, + { + category: "AIDES_CULINAIRES", + subcategory: "EPAISSISSANTS", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "JAR", + items: [ + { name: "Levure boulangère", allergenNames: [] }, + { name: "Levure chimique", allergenNames: [] }, + { name: "Maïzena", allergenNames: [] }, + { name: "Farine de lupin", allergenNames: ["Lupin"] }, + // Animal collagen (bones/skin, usually pork or beef) — not + // vegetarian/vegan, and not reliably fish-derived either, so no + // pescetarian flag. + { name: "Gélatine", allergenNames: [], dietNames: [] }, + { name: "Bicarbonate de soude", allergenNames: [] }, + { name: "Fécule de pomme de terre", allergenNames: [] }, + ], + }, + { + category: "AIDES_CULINAIRES", + subcategory: "SUCRES", + defaultDiets: ["Végétarien", "Végan", "Pescétarien"], + defaultIcon: "SUGAR", + items: [ + { name: "Sucre", allergenNames: [] }, + { name: "Miel", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] }, + { name: "Sirop d'érable", allergenNames: [] }, + { name: "Sucre roux", allergenNames: [] }, + { name: "Sucre glace", allergenNames: [] }, + { name: "Cassonade", allergenNames: [] }, + { name: "Chocolat noir", allergenNames: [] }, + { + name: "Chocolat au lait", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { + name: "Chocolat blanc", + allergenNames: ["Lait"], + dietNames: ["Végétarien", "Pescétarien"], + }, + { name: "Pépites de chocolat", allergenNames: [] }, + { name: "Cacao en poudre", allergenNames: [] }, + { name: "Extrait de vanille", allergenNames: [] }, + { name: "Sucre de palme", allergenNames: [] }, + { name: "Sirop de sucre de canne", allergenNames: [] }, + ], + }, +]; + +const INGREDIENTS: Array< + Omit & { + category: IngredientCategory; + subcategory: IngredientSubcategory; + icon: IngredientIcon; + dietNames: string[]; + } +> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) => + items.map((item) => ({ + ...item, + category, + subcategory, + icon: item.icon ?? defaultIcon, + dietNames: item.dietNames ?? defaultDiets, + })), +); + /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * (safe to call against a database that already has this data — upserts by @@ -58,4 +906,90 @@ 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`/`category`/`subcategory` only for the rows where any + // of them actually changed — on a freshly-truncated table (the common + // test-suite case) that's zero updates, on a real re-deploy it's however + // many rows were edited in code since the last deploy, never the full + // list. + const existingIngredients = await prisma.ingredient.findMany({ + where: { name: { in: INGREDIENTS.map((i) => i.name) } }, + select: { id: true, name: true, icon: true, category: true, subcategory: true }, + }); + const existingByName = new Map(existingIngredients.map((i) => [i.name, i])); + + const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name)); + if (missingIngredients.length > 0) { + await prisma.ingredient.createMany({ + data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ + name, + icon, + category, + subcategory, + })), + }); + } + + const changed = INGREDIENTS.filter((i) => { + const existing = existingByName.get(i.name); + return ( + existing && + (existing.icon !== i.icon || + existing.category !== i.category || + existing.subcategory !== i.subcategory) + ); + }); + for (const { name, icon, category, subcategory } of changed) { + await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } }); + } + + // Re-resolve every ingredient's id (existing + just-created) and every + // allergy's id (by its category name) once, then link them in a single + // bulk insert — same "re-derived every time, not upserted per link" + // reasoning as before for `IngredientAllergy` (it has no natural per-row + // identity to upsert against), just batched instead of looped. + const allIngredients = await prisma.ingredient.findMany({ + where: { name: { in: INGREDIENTS.map((i) => i.name) } }, + select: { id: true, name: true }, + }); + const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id])); + + const allergies = await prisma.allergy.findMany({ include: { category: true } }); + const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id])); + + const links: Array<{ ingredientId: number; allergyId: number }> = []; + for (const { name, allergenNames } of INGREDIENTS) { + const ingredientId = ingredientIdByName.get(name); + if (ingredientId === undefined) continue; + for (const allergenName of allergenNames) { + const allergyId = allergyIdByCategoryName.get(allergenName); + if (allergyId !== undefined) links.push({ ingredientId, allergyId }); + } + } + if (links.length > 0) { + await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true }); + } + + // Same bulk-insert approach as the allergy links above, resolved against + // `dietNames` (item override, falling back to its group's `defaultDiets` + // in the `INGREDIENTS` flatten step) instead of `allergenNames`. + const diets = await prisma.diet.findMany(); + const dietIdByName = new Map(diets.map((d) => [d.name, d.id])); + + const dietLinks: Array<{ ingredientId: number; dietId: number }> = []; + for (const { name, dietNames } of INGREDIENTS) { + const ingredientId = ingredientIdByName.get(name); + if (ingredientId === undefined) continue; + for (const dietName of dietNames) { + const dietId = dietIdByName.get(dietName); + if (dietId !== undefined) dietLinks.push({ ingredientId, dietId }); + } + } + if (dietLinks.length > 0) { + await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true }); + } } 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..0e80a1f --- /dev/null +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -0,0 +1,421 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { + type AllergyView, + type CreateRecipeInput, + type DietView, + ErrorCode, + type IngredientView, + type RecipeSummaryView, + type RecipeTab, + type RecipeView, + type UpdateRecipeInput, +} from "@batch-cooking/shared"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "../../db/prisma.js"; + +/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ +function recipeInclude(viewerId: number) { + return { + ingredients: { + include: { + ingredient: { + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, + }, + }, + }, + }, + steps: { orderBy: { order: "asc" } }, + diets: { include: { diet: true } }, + favoritedBy: { where: { userProfileId: viewerId } }, + } satisfies Prisma.RecipeInclude; +} + +type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; +type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; + +/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ +function toIngredientView(ingredient: IngredientWithDetails): IngredientView { + return { + id: ingredient.id, + name: ingredient.name, + icon: ingredient.icon, + category: ingredient.category, + subcategory: ingredient.subcategory, + allergens: ingredient.allergies.map(({ allergy }) => ({ + id: allergy.id, + name: allergy.category.name, + kind: allergy.category.kind, + })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + }; +} + +function toDietView(diet: { id: number; name: string }): DietView { + return { id: diet.id, name: diet.name }; +} + +/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ +function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { + const byId = new Map(); + 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..832f770 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,33 @@ export async function getAllergies(): Promise { kind: allergy.category.kind, })); } + +/** + * All reference ingredients, alphabetically, each resolved to its allergens + * (see `IngredientAllergy` in schema.prisma) and compatible diet regimes + * (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}. + * Ingredients with no linked allergen/diet come back with `allergens: []`/ + * `diets: []`. + */ +export async function getIngredients(): Promise { + const ingredients = await prisma.ingredient.findMany({ + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, + }, + orderBy: { name: "asc" }, + }); + return ingredients.map((ingredient) => ({ + id: ingredient.id, + name: ingredient.name, + icon: ingredient.icon, + category: ingredient.category, + subcategory: ingredient.subcategory, + allergens: ingredient.allergies.map(({ allergy }) => ({ + id: allergy.id, + name: allergy.category.name, + kind: allergy.category.kind, + })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + })); +} 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..47e4e80 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -46,4 +46,31 @@ 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", + "category", + "subcategory", + "allergens", + "diets", + ]); + }); + + it("resolves each ingredient's linked allergens, empty for one with none", async () => { + const res = await request(app).get("/reference/ingredients"); + + const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name); + expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); + expect(byName("Tomate").allergens).to.deep.equal([]); + }); + }); }); diff --git a/apps/web/cypress/e2e/preferences.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts index 698a219..fcaaa64 100644 --- a/apps/web/cypress/e2e/preferences.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -28,6 +28,14 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => { ], }); cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); + // The page also loads the reference ingredient list + the profile's + // disliked-ingredients selection for `DislikedIngredientsField` — added + // alongside `getDiets`/`getAllergies` in the same `Promise.all` (see + // PreferencesPage.tsx), so both need mocking here too or that `Promise.all` + // rejects and the whole page renders its error state instead of the form, + // taking `#diet`/the allergy checkboxes down with it. + cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); }); it("loads the current regime, and shows allergies/intolerances as two groups", () => { 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..6c9556f --- /dev/null +++ b/apps/web/src/features/profile/DislikedIngredientsField.tsx @@ -0,0 +1,72 @@ +import type { IngredientView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; +import { IngredientPicker } from "../recipes/IngredientPicker"; +// Reuses `IngredientPicker` verbatim (built for the recipe form's +// ingredient picker, features/recipes/) rather than a second search+browse +// field — same reference ingredient list, same category/search browsing, +// just without the recipe form's quantity/unit per line. Its own +// recipes.scss import already covers `.ingredient-picker`; this file's +// explicit import below only adds `.disliked-ingredients-field__*` (see +// recipes.scss — colocated there since it's the same "reference ingredient +// picker" visual family, even though this field lives in the profile +// feature). +import { IngredientTypeIcon } from "../recipes/ingredient-icons"; +import "../recipes/recipes.scss"; +import "./profile-forms.scss"; + +/** + * Search-and-add + removable-chips field for the current user's personal + * "disliked ingredients" list — a taste preference, distinct from + * `AllergySelect`'s medical allergy list. Used on `/parametres/preferences` + * (`PreferencesPage`); crossed against a recipe's own ingredients on its + * detail panel (`RecipeDetailPanel`) to surface just the relevant ones. + */ +export function DislikedIngredientsField({ + ingredients, + value, + onChange, +}: { + ingredients: IngredientView[]; + value: number[]; + onChange: (ingredientIds: number[]) => void; +}) { + const { t } = useTranslation(); + const selected = ingredients.filter((ingredient) => value.includes(ingredient.id)); + + function add(ingredient: IngredientView) { + onChange([...value, ingredient.id]); + } + + function remove(ingredientId: number) { + onChange(value.filter((id) => id !== ingredientId)); + } + + return ( + // `
`/`` (not a bare `