feat(recipes): catalogue v2 - visibilité, favoris, régimes et catalogue d'ingrédients exhaustif
Recipe catalog v2: - Recipe gagne visibility (PERSONAL/HOUSE/PUBLIC), authorId, authorHouseId - Favoris par utilisateur (RecipeFavorite), régimes associés (RecipeDiet) - Aliments "pas aimés" par utilisateur (UserProfileDislikedIngredient), distinct des allergies médicales - API: GET /recipes?tab=favoris|perso|foyer|publique avec contrôle d'accès complet, POST/DELETE /recipes/:id/favorite, édition/suppression réservées à l'auteur (403 NOT_RECIPE_AUTHOR), GET/PATCH /profile/disliked-ingredients - Frontend: vue maître-détail (onglets + tableau + panneau détail), formulaire enrichi (visibilité, régimes), section préférences pour les aliments pas aimés Catalogue d'ingrédients de référence: - Extension du seed de 39 à ~430 ingrédients (viandes, poissons/fruits de mer, légumes, fruits, féculents, condiments/sauces, épices/herbes, pains à sandwich, cuisines italienne/asiatique/mexicaine/maghrébine, liquides et boissons de cuisine, bouillons/fonds) - Chaque ingrédient lié à ses allergènes UE (IngredientAllergy) — les 14 allergènes réglementaires restent tous couverts - Seeding optimisé en requêtes groupées (createMany/diff ciblé) plutôt qu'un upsert par ligne, pour garder resetDatabase() rapide en test Tests: 102 tests Mocha + 32 scénarios BDD, tous verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1367f62f32
commit
acab18ac4a
43 changed files with 4714 additions and 41 deletions
59
apps/api/features/recipe.feature
Normal file
59
apps/api/features/recipe.feature
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
Feature: Recipe catalog
|
||||
As a signed-in user
|
||||
I want to browse, create and manage recipes
|
||||
So that the household can plan meals from a shared catalog
|
||||
|
||||
Scenario: A visitor without a session cannot browse the catalog
|
||||
When I request the recipe catalog
|
||||
Then the response status should be 401
|
||||
And the response error code should be "NOT_AUTHENTICATED"
|
||||
|
||||
Scenario: A signed-in user creates a recipe with an ingredient and a step
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
When I create a recipe named "Ratatouille" with ingredient "Tomate" and step "Couper les légumes"
|
||||
Then the response status should be 201
|
||||
And the created recipe should have ingredient "Tomate" and step "Couper les légumes"
|
||||
|
||||
Scenario: Creating a recipe with an unknown ingredient is rejected
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
When I create a recipe named "Ratatouille" with unknown ingredient id 999999 and step "Couper les légumes"
|
||||
Then the response status should be 404
|
||||
And the response error code should be "INGREDIENT_NOT_FOUND"
|
||||
|
||||
Scenario: A signed-in user sees their own recipe in the "perso" tab
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
|
||||
When I request the recipe catalog tab "perso"
|
||||
Then the response status should be 200
|
||||
And the recipe catalog response should include "Ratatouille"
|
||||
|
||||
Scenario: A signed-in user favorites a recipe and finds it in the "favoris" tab
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
|
||||
When I favorite the recipe named "Ratatouille"
|
||||
And I request the recipe catalog tab "favoris"
|
||||
Then the response status should be 200
|
||||
And the recipe catalog response should include "Ratatouille"
|
||||
|
||||
Scenario: Only a recipe's author can edit it
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And a public recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
|
||||
And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||
And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple"
|
||||
When the second user tries to modify the recipe named "Ratatouille"
|
||||
Then the second user's response status should be 403
|
||||
And the second user's response error code should be "NOT_RECIPE_AUTHOR"
|
||||
|
||||
Scenario: Deleting a recipe still used by a planning item is rejected
|
||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And I log in with email "alice@example.com" and password "correct-horse-battery-staple"
|
||||
And a recipe named "Ratatouille" already exists with ingredient "Tomate" and step "Couper les légumes"
|
||||
And my household has a planning that uses the recipe named "Ratatouille"
|
||||
When I delete the recipe named "Ratatouille"
|
||||
Then the response status should be 409
|
||||
And the response error code should be "RECIPE_IN_USE"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
159
apps/api/features/step-definitions/recipe.steps.ts
Normal file
159
apps/api/features/step-definitions/recipe.steps.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { Given, Then, When } from "@cucumber/cucumber";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
|
||||
import type { CustomWorld } from "../support/world.js";
|
||||
|
||||
/** Resolves a reference ingredient by its seeded name — every scenario below names an ingredient by its `reference-seed-data.ts` name, never a raw id. */
|
||||
async function findIngredientId(name: string): Promise<number> {
|
||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } });
|
||||
return ingredient.id;
|
||||
}
|
||||
|
||||
When("I request the recipe catalog", async function (this: CustomWorld) {
|
||||
this.response = await this.agent.get("/recipes");
|
||||
});
|
||||
|
||||
When("I request the recipe catalog tab {string}", async function (this: CustomWorld, tab: string) {
|
||||
this.response = await this.agent.get("/recipes").query({ tab });
|
||||
});
|
||||
|
||||
Then(
|
||||
"the recipe catalog response should include {string}",
|
||||
function (this: CustomWorld, name: string) {
|
||||
const names = (this.response.body as Array<{ name: string }>).map((recipe) => recipe.name);
|
||||
assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`);
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"I create a recipe named {string} with ingredient {string} and step {string}",
|
||||
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
|
||||
const ingredientId = await findIngredientId(ingredientName);
|
||||
this.response = await this.agent.post("/recipes").send({
|
||||
name,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: step }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
"I create a recipe named {string} with unknown ingredient id {int} and step {string}",
|
||||
async function (this: CustomWorld, name: string, unknownIngredientId: number, step: string) {
|
||||
this.response = await this.agent.post("/recipes").send({
|
||||
name,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: unknownIngredientId, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: step }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the created recipe should have ingredient {string} and step {string}",
|
||||
function (this: CustomWorld, ingredientName: string, step: string) {
|
||||
const body = this.response.body as {
|
||||
ingredients: Array<{ ingredient: { name: string } }>;
|
||||
steps: Array<{ description: string }>;
|
||||
};
|
||||
assert.ok(body.ingredients.some((line) => line.ingredient.name === ingredientName));
|
||||
assert.ok(body.steps.some((s) => s.description === step));
|
||||
},
|
||||
);
|
||||
|
||||
// Created directly via Prisma (with a nested ingredient + step), not through
|
||||
// the API — same rationale as `planning.steps.ts`'s equivalent "already
|
||||
// exists" step: this is background state the scenario needs in place before
|
||||
// its actual `When`, not the behavior under test. `authorId` is the
|
||||
// currently-logged-in agent's own profile — `visibility` defaults to
|
||||
// `PERSONAL` (schema.prisma), matching a recipe this agent just created for
|
||||
// themselves.
|
||||
Given(
|
||||
"a recipe named {string} already exists with ingredient {string} and step {string}",
|
||||
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
|
||||
const ingredientId = await findIngredientId(ingredientName);
|
||||
const me = await this.agent.get("/auth/me");
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
name,
|
||||
authorId: me.body.id,
|
||||
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
|
||||
steps: { create: [{ description: step, order: 0 }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Same as above but `visibility: PUBLIC` — needed for scenarios where a
|
||||
// *second* user must be able to see (though not necessarily edit) the
|
||||
// recipe, e.g. the "only the author can edit" scenario: a `PERSONAL`
|
||||
// recipe would 404 for anyone else before the authorship check even runs
|
||||
// (see `recipe.service.ts`'s `canView`).
|
||||
Given(
|
||||
"a public recipe named {string} already exists with ingredient {string} and step {string}",
|
||||
async function (this: CustomWorld, name: string, ingredientName: string, step: string) {
|
||||
const ingredientId = await findIngredientId(ingredientName);
|
||||
const me = await this.agent.get("/auth/me");
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
name,
|
||||
authorId: me.body.id,
|
||||
visibility: "PUBLIC",
|
||||
ingredients: { create: [{ ingredientId, quantity: 1, unit: "unité" }] },
|
||||
steps: { create: [{ description: step, order: 0 }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Distinct from `planning.steps.ts`'s "my household has a planning covering
|
||||
// today with recipe {string}..." — that step always creates a *new* recipe
|
||||
// row with the given name, which wouldn't exercise the actual `RECIPE_IN_USE`
|
||||
// check against a recipe this feature already created. This step instead
|
||||
// looks up the already-existing recipe by name and points the planning item
|
||||
// at its real id.
|
||||
Given(
|
||||
"my household has a planning that uses the recipe named {string}",
|
||||
async function (this: CustomWorld, recipeName: string) {
|
||||
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name: recipeName } });
|
||||
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
When("I delete the recipe named {string}", async function (this: CustomWorld, name: string) {
|
||||
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
|
||||
this.response = await this.agent.delete(`/recipes/${recipe.id}`);
|
||||
});
|
||||
|
||||
When("I favorite the recipe named {string}", async function (this: CustomWorld, name: string) {
|
||||
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
|
||||
this.response = await this.agent.post(`/recipes/${recipe.id}/favorite`);
|
||||
});
|
||||
|
||||
When(
|
||||
"the second user tries to modify the recipe named {string}",
|
||||
async function (this: CustomWorld, name: string) {
|
||||
const ingredientId = await findIngredientId("Tomate");
|
||||
const recipe = await prisma.recipe.findFirstOrThrow({ where: { name } });
|
||||
this.secondResponse = await this.secondAgent.patch(`/recipes/${recipe.id}`).send({
|
||||
name,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Hack" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -27,6 +27,9 @@ model House {
|
|||
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")
|
||||
}
|
||||
|
|
@ -40,6 +43,7 @@ model Diet {
|
|||
name String @unique
|
||||
|
||||
users UserProfile[]
|
||||
recipes RecipeDiet[]
|
||||
|
||||
@@map("diet")
|
||||
}
|
||||
|
|
@ -72,6 +76,7 @@ model Allergy {
|
|||
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
users UserProfileAllergy[]
|
||||
ingredients IngredientAllergy[]
|
||||
|
||||
@@map("allergy")
|
||||
}
|
||||
|
|
@ -93,6 +98,14 @@ model UserProfile {
|
|||
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
|
||||
|
|
@ -103,6 +116,22 @@ model UserProfile {
|
|||
@@map("user_profiles")
|
||||
}
|
||||
|
||||
/// Explicit join table for the user_profiles <-> ingredient "disliked"
|
||||
/// association — same shape as `UserProfileAllergy`, but a personal taste
|
||||
/// preference rather than a medical restriction: not surfaced as a safety
|
||||
/// warning, just a reminder on a recipe's detail view (see
|
||||
/// `RecipeView`/`RecipeDetailPanel`, apps/web).
|
||||
model UserProfileDislikedIngredient {
|
||||
userProfileId Int @map("user_profile_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userProfileId, ingredientId])
|
||||
@@map("user_profile_disliked_ingredient")
|
||||
}
|
||||
|
||||
/// Not in the original spec doc — personalization settings (theme for now,
|
||||
/// meant to grow), one row per profile, created on demand (see
|
||||
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
||||
|
|
@ -184,35 +213,115 @@ model Source {
|
|||
@@map("sources")
|
||||
}
|
||||
|
||||
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
||||
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
||||
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
||||
enum RecipeVisibility {
|
||||
/// Visible to its author only.
|
||||
PERSONAL
|
||||
/// Visible to `authorHouseId`'s members (a snapshot of the author's
|
||||
/// household *at creation time* — see `Recipe.authorHouseId`).
|
||||
HOUSE
|
||||
/// Visible to every signed-in user — the "shared catalog" behavior the
|
||||
/// very first version of this feature shipped with.
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sourceId Int? @map("source_id")
|
||||
description String?
|
||||
picture String?
|
||||
/// Creator — not in the original spec doc, required once recipes carry a
|
||||
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
|
||||
authorId Int @map("author_id")
|
||||
/// The author's household *at the time this recipe was created* — a
|
||||
/// snapshot (same idea as `Planning.houseId`), not a live lookup: it
|
||||
/// doesn't follow the author if they later change household. `null` if
|
||||
/// the author had no household yet.
|
||||
authorHouseId Int? @map("author_house_id")
|
||||
visibility RecipeVisibility @default(PERSONAL)
|
||||
|
||||
author UserProfile @relation(fields: [authorId], references: [id])
|
||||
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
|
||||
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
||||
ingredients RecipeIngredient[]
|
||||
steps Step[]
|
||||
planningItems PlanningItem[]
|
||||
favoritedBy RecipeFavorite[]
|
||||
diets RecipeDiet[]
|
||||
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
||||
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
||||
|
||||
@@map("recipe")
|
||||
}
|
||||
|
||||
/// Explicit join table for the user_profiles <-> recipe "favorited"
|
||||
/// association — same shape as `UserProfileAllergy`. Per-user, not
|
||||
/// per-household: two members of the same household can favorite different
|
||||
/// recipes independently.
|
||||
model RecipeFavorite {
|
||||
userProfileId Int @map("user_profile_id")
|
||||
recipeId Int @map("recipe_id")
|
||||
|
||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userProfileId, recipeId])
|
||||
@@map("recipe_favorite")
|
||||
}
|
||||
|
||||
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||
/// from its ingredients.
|
||||
model RecipeDiet {
|
||||
recipeId Int @map("recipe_id")
|
||||
dietId Int @map("diet_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([recipeId, dietId])
|
||||
@@map("recipe_diet")
|
||||
}
|
||||
|
||||
/// `name` is `@unique` — not in the original spec doc, added so the seed
|
||||
/// script (reference-seed-data.ts) can `upsert` by name and stay
|
||||
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
||||
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||
/// created/edited/deleted through the API.
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
name String @unique
|
||||
icon String?
|
||||
alternateRecipeId Int? @map("alternate_recipe")
|
||||
|
||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||
recipes RecipeIngredient[]
|
||||
allergies IngredientAllergy[]
|
||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||
dislikedBy UserProfileDislikedIngredient[]
|
||||
|
||||
@@map("ingredients")
|
||||
}
|
||||
|
||||
/// Explicit join table for the ingredients <-> allergy association — not in
|
||||
/// the original spec doc, added so the recipe catalog can surface which
|
||||
/// allergens an ingredient (and by extension a recipe) carries. Same shape
|
||||
/// as `UserProfileAllergy`.
|
||||
model IngredientAllergy {
|
||||
ingredientId Int @map("ingredient_id")
|
||||
allergyId Int @map("allergy_id")
|
||||
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([ingredientId, allergyId])
|
||||
@@map("ingredient_allergy")
|
||||
}
|
||||
|
||||
/// recipe <-> ingredients association. The spec documents this as a plain
|
||||
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
||||
/// quantity per recipe, so this join table carries quantity + unit
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,492 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
|
|||
{ name: "Mollusques", kind: "ALLERGY" },
|
||||
];
|
||||
|
||||
// A broad pantry list — the goal is to cover the large majority of what a
|
||||
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
|
||||
// condiments, épices...), not just enough to exercise the recipe catalog in
|
||||
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
|
||||
// `name` is `@unique`, there's no create/update/delete endpoint), so this is
|
||||
// meant to already be comprehensive at first deploy rather than grown
|
||||
// piecemeal as recipes need more of it. `allergenNames` reference
|
||||
// `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is
|
||||
// covered by at least one ingredient here. `icon` is left unset (rather than
|
||||
// forcing a misleading emoji) for the handful of items with no good match in
|
||||
// the standard emoji set (e.g. `Radis`, `Asperge`).
|
||||
const INGREDIENTS: Array<{ name: string; icon?: string; allergenNames: string[] }> = [
|
||||
// --- Céréales, farines & féculents ---------------------------------------
|
||||
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Farine de maïs", icon: "🌽", allergenNames: [] },
|
||||
{ name: "Farine de sarrasin", icon: "🌾", allergenNames: [] },
|
||||
{ name: "Farine de riz", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Semoule", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Couscous", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Boulgour", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Polenta", icon: "🌽", allergenNames: [] },
|
||||
{ name: "Quinoa", icon: "🌾", allergenNames: [] },
|
||||
{ name: "Pâtes", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Pâtes complètes", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Riz", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Riz basmati", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Riz complet", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Flocons d'avoine", icon: "🌾", allergenNames: ["Gluten"] },
|
||||
{ name: "Pomme de terre", icon: "🥔", allergenNames: [] },
|
||||
{ name: "Patate douce", icon: "🍠", allergenNames: [] },
|
||||
{ name: "Pain", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain de mie", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain complet", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Baguette", icon: "🥖", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain de seigle", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Chapelure", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Levure boulangère", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Levure chimique", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Maïzena", icon: "🫙", allergenNames: [] },
|
||||
|
||||
// --- Légumineuses ---------------------------------------------------------
|
||||
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Pois chiches", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Haricots blancs", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Haricots rouges", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Haricots noirs", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Pois cassés", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Fèves", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Edamame", icon: "🫛", allergenNames: ["Soja"] },
|
||||
|
||||
// --- Viandes & volailles ---------------------------------------------------
|
||||
{ name: "Poulet", icon: "🍗", allergenNames: [] },
|
||||
{ name: "Dinde", icon: "🍗", allergenNames: [] },
|
||||
{ name: "Canard", icon: "🦆", allergenNames: [] },
|
||||
{ name: "Magret de canard", icon: "🦆", allergenNames: [] },
|
||||
{ name: "Lapin", icon: "🐇", allergenNames: [] },
|
||||
{ name: "Bœuf haché", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Steak de bœuf", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Rôti de bœuf", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Escalope de veau", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Filet mignon de porc", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Côte de porc", icon: "🥩", allergenNames: [] },
|
||||
{ name: "Agneau", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Gigot d'agneau", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Lardons", icon: "🥓", allergenNames: [] },
|
||||
{ name: "Bacon", icon: "🥓", allergenNames: [] },
|
||||
{ name: "Jambon blanc", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Jambon cru", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Saucisse", icon: "🌭", allergenNames: [] },
|
||||
{ name: "Chorizo", icon: "🌭", allergenNames: [] },
|
||||
{ name: "Merguez", icon: "🌭", allergenNames: [] },
|
||||
|
||||
// --- Poissons & fruits de mer -----------------------------------------------
|
||||
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Cabillaud", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Truite", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Sardine", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Anchois", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Merlan", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Surimi", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Bar (loup de mer)", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Dorade", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Sole", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Turbot", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Merlu", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Colin", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Lieu noir", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Églefin", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Maquereau", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Hareng", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Rouget", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Raie", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Lotte", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Flétan", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Espadon", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Carpe", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Brochet", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Perche", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Tilapia", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Panga", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Saumon fumé", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Crevettes", icon: "🍤", allergenNames: ["Crustacés"] },
|
||||
{ name: "Langoustines", icon: "🍤", allergenNames: ["Crustacés"] },
|
||||
{ name: "Homard", icon: "🦞", allergenNames: ["Crustacés"] },
|
||||
{ name: "Crabe", icon: "🦀", allergenNames: ["Crustacés"] },
|
||||
{ name: "Langouste", icon: "🦞", allergenNames: ["Crustacés"] },
|
||||
{ name: "Moules", icon: "🦪", allergenNames: ["Mollusques"] },
|
||||
{ name: "Huîtres", icon: "🦪", allergenNames: ["Mollusques"] },
|
||||
{ name: "Saint-Jacques", icon: "🦪", allergenNames: ["Mollusques"] },
|
||||
{ name: "Calamar", icon: "🦑", allergenNames: ["Mollusques"] },
|
||||
{ name: "Poulpe", icon: "🐙", allergenNames: ["Mollusques"] },
|
||||
{ name: "Palourdes", icon: "🦪", allergenNames: ["Mollusques"] },
|
||||
{ name: "Bulots", icon: "🐚", allergenNames: ["Mollusques"] },
|
||||
|
||||
// --- Produits laitiers & œufs -----------------------------------------------
|
||||
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
|
||||
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
|
||||
{ name: "Beurre", icon: "🧈", allergenNames: ["Lait"] },
|
||||
{ name: "Crème fraîche", icon: "🥛", allergenNames: ["Lait"] },
|
||||
{ name: "Crème liquide", icon: "🥛", allergenNames: ["Lait"] },
|
||||
{ name: "Fromage", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Emmental", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Gruyère", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Parmesan", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Mozzarella", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Chèvre (fromage)", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Feta", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Comté", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] },
|
||||
{ name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] },
|
||||
{ name: "Lait de coco", icon: "🥥", allergenNames: [] },
|
||||
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
|
||||
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
|
||||
|
||||
// --- Légumes -----------------------------------------------------------------
|
||||
{ name: "Tomate", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Oignon", icon: "🧅", allergenNames: [] },
|
||||
{ name: "Échalote", icon: "🧅", allergenNames: [] },
|
||||
{ name: "Ail", icon: "🧄", allergenNames: [] },
|
||||
{ name: "Carotte", icon: "🥕", allergenNames: [] },
|
||||
{ name: "Courgette", icon: "🥒", allergenNames: [] },
|
||||
{ name: "Concombre", icon: "🥒", allergenNames: [] },
|
||||
{ name: "Cornichons", icon: "🥒", allergenNames: [] },
|
||||
{ name: "Poivron", icon: "🫑", allergenNames: [] },
|
||||
{ name: "Champignon", icon: "🍄", allergenNames: [] },
|
||||
{ name: "Cèpes", icon: "🍄", allergenNames: [] },
|
||||
{ name: "Aubergine", icon: "🍆", allergenNames: [] },
|
||||
{ name: "Brocoli", icon: "🥦", allergenNames: [] },
|
||||
{ name: "Chou-fleur", icon: "🥦", allergenNames: [] },
|
||||
{ name: "Chou blanc", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Chou rouge", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Chou de Bruxelles", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Épinard", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Blette", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Salade", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Roquette", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Cresson", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Poireau", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Céleri", icon: "🥬", allergenNames: ["Céleri"] },
|
||||
{ name: "Radis", allergenNames: [] },
|
||||
{ name: "Betterave", allergenNames: [] },
|
||||
{ name: "Navet", allergenNames: [] },
|
||||
{ name: "Panais", allergenNames: [] },
|
||||
{ name: "Haricot vert", icon: "🫛", allergenNames: [] },
|
||||
{ name: "Petit pois", icon: "🫛", allergenNames: [] },
|
||||
{ name: "Maïs", icon: "🌽", allergenNames: [] },
|
||||
{ name: "Artichaut", allergenNames: [] },
|
||||
{ name: "Fenouil", allergenNames: [] },
|
||||
{ name: "Endive", allergenNames: [] },
|
||||
{ name: "Potiron", icon: "🎃", allergenNames: [] },
|
||||
{ name: "Butternut", icon: "🎃", allergenNames: [] },
|
||||
{ name: "Asperge", allergenNames: [] },
|
||||
{ name: "Avocat", icon: "🥑", allergenNames: [] },
|
||||
|
||||
// --- Fruits ------------------------------------------------------------------
|
||||
{ name: "Citron", icon: "🍋", allergenNames: [] },
|
||||
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
|
||||
{ name: "Pomme", icon: "🍎", allergenNames: [] },
|
||||
{ name: "Poire", icon: "🍐", allergenNames: [] },
|
||||
{ name: "Banane", icon: "🍌", allergenNames: [] },
|
||||
{ name: "Orange", icon: "🍊", allergenNames: [] },
|
||||
{ name: "Clémentine", icon: "🍊", allergenNames: [] },
|
||||
{ name: "Pamplemousse", icon: "🍊", allergenNames: [] },
|
||||
{ name: "Fraise", icon: "🍓", allergenNames: [] },
|
||||
{ name: "Framboise", icon: "🍓", allergenNames: [] },
|
||||
{ name: "Myrtille", icon: "🫐", allergenNames: [] },
|
||||
{ name: "Mûre", icon: "🫐", allergenNames: [] },
|
||||
{ name: "Cerise", icon: "🍒", allergenNames: [] },
|
||||
{ name: "Abricot", icon: "🍑", allergenNames: [] },
|
||||
{ name: "Pêche", icon: "🍑", allergenNames: [] },
|
||||
{ name: "Prune", allergenNames: [] },
|
||||
{ name: "Raisin", icon: "🍇", allergenNames: [] },
|
||||
{ name: "Melon", icon: "🍈", allergenNames: [] },
|
||||
{ name: "Pastèque", icon: "🍉", allergenNames: [] },
|
||||
{ name: "Ananas", icon: "🍍", allergenNames: [] },
|
||||
{ name: "Mangue", icon: "🥭", allergenNames: [] },
|
||||
{ name: "Kiwi", icon: "🥝", allergenNames: [] },
|
||||
{ name: "Figue", allergenNames: [] },
|
||||
{ name: "Datte", allergenNames: [] },
|
||||
{ name: "Litchi", allergenNames: [] },
|
||||
{ name: "Grenade", allergenNames: [] },
|
||||
{ name: "Rhubarbe", allergenNames: [] },
|
||||
{ name: "Coing", allergenNames: [] },
|
||||
|
||||
// --- Fruits secs & oléagineux ------------------------------------------------
|
||||
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
|
||||
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Noix", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Noisettes", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Noix de cajou", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Pistaches", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Noix de pécan", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Poudre d'amande", icon: "🌰", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Pignons de pin", icon: "🌰", allergenNames: [] },
|
||||
{ name: "Graines de tournesol", icon: "🌻", allergenNames: [] },
|
||||
{ name: "Graines de courge", allergenNames: [] },
|
||||
{ name: "Noix de coco râpée", icon: "🥥", allergenNames: [] },
|
||||
{ name: "Raisins secs", icon: "🍇", allergenNames: ["Sulfites"] },
|
||||
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
|
||||
{ name: "Abricots secs", icon: "🍑", allergenNames: ["Sulfites"] },
|
||||
|
||||
// --- Condiments, sauces & huiles ----------------------------------------------
|
||||
{ name: "Sel", icon: "🧂", allergenNames: [] },
|
||||
{ name: "Sucre", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Huile d'olive", icon: "🫒", allergenNames: [] },
|
||||
{ name: "Huile de tournesol", icon: "🧴", allergenNames: [] },
|
||||
{ name: "Huile de colza", icon: "🧴", allergenNames: [] },
|
||||
{ name: "Huile de coco", icon: "🥥", allergenNames: [] },
|
||||
{ name: "Huile de sésame", icon: "🧴", allergenNames: ["Graines de sésame"] },
|
||||
{ name: "Vinaigre de cidre", icon: "🧴", allergenNames: [] },
|
||||
{ name: "Vinaigre blanc", icon: "🧴", allergenNames: [] },
|
||||
{ name: "Vinaigre balsamique", icon: "🧴", allergenNames: ["Sulfites"] },
|
||||
{ name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] },
|
||||
{ name: "Tofu", icon: "🧊", allergenNames: ["Soja"] },
|
||||
{ name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{ name: "Ketchup", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Miel", icon: "🍯", allergenNames: [] },
|
||||
{ name: "Sirop d'érable", icon: "🍁", allergenNames: [] },
|
||||
{ name: "Câpres", allergenNames: [] },
|
||||
{ name: "Olives", icon: "🫒", allergenNames: [] },
|
||||
{ name: "Tabasco", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Sauce Worcestershire", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{ name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{ name: "Wasabi", allergenNames: [] },
|
||||
{ name: "Harissa", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Pâte de curry", icon: "🍛", allergenNames: [] },
|
||||
{ name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Concentré de tomate", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Coulis de tomate", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Vin blanc (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
|
||||
{ name: "Vin rouge (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
|
||||
{ name: "Vinaigre de vin rouge", icon: "🧴", allergenNames: ["Sulfites"] },
|
||||
{ name: "Vinaigre de vin blanc", icon: "🧴", allergenNames: ["Sulfites"] },
|
||||
{ name: "Vinaigre de xérès", icon: "🧴", allergenNames: ["Sulfites"] },
|
||||
{ name: "Huile de noix", icon: "🧴", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Huile de noisette", icon: "🧴", allergenNames: ["Fruits à coque"] },
|
||||
{ name: "Huile d'arachide", icon: "🧴", allergenNames: ["Arachides"] },
|
||||
{ name: "Huile pimentée", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Beurre de cacahuète", icon: "🥜", allergenNames: ["Arachides"] },
|
||||
{ name: "Farine de lupin", icon: "🌱", allergenNames: ["Lupin"] },
|
||||
{ name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] },
|
||||
{ name: "Sauce barbecue", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{ name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] },
|
||||
{ name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
|
||||
{ name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
|
||||
{ name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] },
|
||||
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
|
||||
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
|
||||
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Pesto rouge (tomates séchées)", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
|
||||
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] },
|
||||
{ name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] },
|
||||
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||
|
||||
// --- Épices & herbes -----------------------------------------------------------
|
||||
{ name: "Basilic", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Persil", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Thym", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Romarin", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Laurier", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Ciboulette", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Coriandre fraîche", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Menthe", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Origan", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Aneth", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Estragon", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Herbes de Provence", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Poivre noir", allergenNames: [] },
|
||||
{ name: "Paprika", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment d'Espelette", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment de Cayenne", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Cumin", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Curry (poudre)", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Curcuma", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Cannelle", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Gingembre", icon: "🫚", allergenNames: [] },
|
||||
{ name: "Muscade", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Safran", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Clou de girofle", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Vanille (gousse)", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Poivre blanc", allergenNames: [] },
|
||||
{ name: "Poivre rose", allergenNames: [] },
|
||||
{ name: "Poivre du Sichuan", allergenNames: [] },
|
||||
{ name: "Paprika fumé", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment oiseau", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Baies de genièvre", allergenNames: [] },
|
||||
{ name: "Anis étoilé (badiane)", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Anis vert", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Graines de fenouil", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Sarriette", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Marjolaine", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Sauge", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Cerfeuil", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Sumac", allergenNames: [] },
|
||||
{ name: "Nigelle", allergenNames: [] },
|
||||
{ name: "Quatre épices", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Colombo (poudre)", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Baharat", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Raifort", allergenNames: [] },
|
||||
{ name: "Sel aux herbes", icon: "🧂", allergenNames: [] },
|
||||
{ name: "Sel de céleri", icon: "🧂", allergenNames: ["Céleri"] },
|
||||
{ name: "Fleur de sel", icon: "🧂", allergenNames: [] },
|
||||
|
||||
// --- Sucre & pâtisserie ---------------------------------------------------------
|
||||
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Chocolat noir", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] },
|
||||
{ name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] },
|
||||
{ name: "Pépites de chocolat", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
|
||||
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
|
||||
|
||||
// --- Cuisine italienne ---------------------------------------------------------
|
||||
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Tagliatelles", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] },
|
||||
{ name: "Riz arborio", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Burrata", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] },
|
||||
{ name: "Prosciutto", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Pancetta", icon: "🥓", allergenNames: [] },
|
||||
{ name: "Mortadelle", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Salami", icon: "🍖", allergenNames: [] },
|
||||
{ name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
|
||||
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
|
||||
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
|
||||
// --- Cuisine asiatique (chinoise, japonaise, thaïe, coréenne, indienne...) ------
|
||||
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
|
||||
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
|
||||
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Sauce sweet chili", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Vinaigre de riz", icon: "🧴", allergenNames: [] },
|
||||
{ name: "Mirin", icon: "🍶", allergenNames: [] },
|
||||
{ name: "Saké (cuisine)", icon: "🍶", allergenNames: [] },
|
||||
{ name: "Miso", icon: "🫙", allergenNames: ["Soja"] },
|
||||
{ name: "Tofu soyeux", icon: "🧊", allergenNames: ["Soja"] },
|
||||
{ name: "Graines de sésame", icon: "🌱", allergenNames: ["Graines de sésame"] },
|
||||
{ name: "Nouilles de riz", icon: "🍜", allergenNames: [] },
|
||||
{ name: "Nouilles udon", icon: "🍜", allergenNames: ["Gluten"] },
|
||||
{ name: "Nouilles soba", icon: "🍜", allergenNames: ["Gluten"] },
|
||||
{ name: "Nouilles chinoises", icon: "🍜", allergenNames: ["Gluten"] },
|
||||
{ name: "Vermicelles de riz", icon: "🍜", allergenNames: [] },
|
||||
{ name: "Vermicelles de soja", icon: "🍜", allergenNames: [] },
|
||||
{ name: "Riz gluant", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Riz à sushi", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Riz jasmin", icon: "🍚", allergenNames: [] },
|
||||
{ name: "Citronnelle", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Combava", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Pak-choï", icon: "🥬", allergenNames: [] },
|
||||
{ name: "Germes de soja", icon: "🌱", allergenNames: ["Soja"] },
|
||||
{ name: "Shiitake", icon: "🍄", allergenNames: [] },
|
||||
{ name: "Champignons noirs", icon: "🍄", allergenNames: [] },
|
||||
{ name: "Daikon", allergenNames: [] },
|
||||
{ name: "Algue nori", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Algue wakamé", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Algue kombu", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Cinq épices", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Garam masala", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Graines de coriandre", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Cardamome", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Fenugrec", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Piment vert frais", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Farine de tapioca", icon: "🌾", allergenNames: [] },
|
||||
{ name: "Sucre de palme", icon: "🍬", allergenNames: [] },
|
||||
{ name: "Pousses de bambou", allergenNames: [] },
|
||||
{ name: "Châtaignes d'eau", allergenNames: [] },
|
||||
{ name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] },
|
||||
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
|
||||
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
|
||||
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
|
||||
|
||||
// --- Cuisine mexicaine -----------------------------------------------------------
|
||||
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
|
||||
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
|
||||
{ name: "Haricots pinto", icon: "🫘", allergenNames: [] },
|
||||
{ name: "Piment jalapeño", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment chipotle", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment poblano", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
|
||||
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
|
||||
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
|
||||
|
||||
// --- Maghreb & Moyen-Orient --------------------------------------------------------
|
||||
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
|
||||
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
|
||||
{ name: "Tahini", icon: "🫙", allergenNames: ["Graines de sésame"] },
|
||||
|
||||
// --- Pains & sandwichs ---------------------------------------------------------------
|
||||
// `Pain`/`Pain de mie`/`Pain complet`/`Baguette`/`Pain de seigle` are
|
||||
// seeded further up (Céréales, farines & féculents) — this section covers
|
||||
// the shapes specifically reached for to build a sandwich, plus a few
|
||||
// international flatbreads not tied to one cuisine section above.
|
||||
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
||||
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
|
||||
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] },
|
||||
{ name: "Naan", icon: "🫓", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain viennois", icon: "🍞", allergenNames: ["Gluten", "Lait"] },
|
||||
{ name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain suédois", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Pain sans gluten", icon: "🍞", allergenNames: [] },
|
||||
{ name: "Biscotte", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
{ name: "Croûtons", icon: "🍞", allergenNames: ["Gluten"] },
|
||||
|
||||
// --- Épicerie divers ---------------------------------------------------------------
|
||||
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
|
||||
|
||||
// --- Liquides & boissons de cuisine -------------------------------------------
|
||||
{ name: "Eau", icon: "💧", allergenNames: [] },
|
||||
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
|
||||
{ name: "Eau de fleur d'oranger", icon: "💧", allergenNames: [] },
|
||||
{ name: "Eau de rose", icon: "💧", allergenNames: [] },
|
||||
{ name: "Jus de citron", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus de citron vert", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus d'orange", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus de pomme", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus de raisin", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus de tomate", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Jus de cranberry", icon: "🧃", allergenNames: [] },
|
||||
{ name: "Café", icon: "☕", allergenNames: [] },
|
||||
{ name: "Thé", icon: "🍵", allergenNames: [] },
|
||||
{ name: "Bière (cuisine)", icon: "🍺", allergenNames: ["Gluten"] },
|
||||
{ name: "Cidre (cuisine)", allergenNames: ["Sulfites"] },
|
||||
{ name: "Champagne / vin pétillant (cuisine)", icon: "🥂", allergenNames: ["Sulfites"] },
|
||||
{ name: "Porto (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
|
||||
{ name: "Vin jaune (cuisine)", icon: "🍷", allergenNames: ["Sulfites"] },
|
||||
{ name: "Cognac", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Rhum", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Whisky", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Vodka", icon: "🥃", allergenNames: [] },
|
||||
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
|
||||
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
||||
* (safe to call against a database that already has this data — upserts by
|
||||
|
|
@ -58,4 +544,60 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
await prisma.allergy.create({ data: { categoryId: category.id } });
|
||||
}
|
||||
}
|
||||
|
||||
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
|
||||
// hundred entries long, and `seedReferenceData` re-runs on every single
|
||||
// test's `resetDatabase()` — a per-row round trip made the whole suite
|
||||
// measurably slower). Bulk-create whatever's missing in one query, then
|
||||
// reconcile `icon` only for the rows where it actually changed — on a
|
||||
// freshly-truncated table (the common test-suite case) that's zero
|
||||
// updates, on a real re-deploy it's however many icons were edited in
|
||||
// code since the last deploy, never the full list.
|
||||
const existingIngredients = await prisma.ingredient.findMany({
|
||||
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
||||
select: { id: true, name: true, icon: true },
|
||||
});
|
||||
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
|
||||
|
||||
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
|
||||
if (missingIngredients.length > 0) {
|
||||
await prisma.ingredient.createMany({
|
||||
data: missingIngredients.map(({ name, icon }) => ({ name, icon })),
|
||||
});
|
||||
}
|
||||
|
||||
const changedIcons = INGREDIENTS.filter((i) => {
|
||||
const existing = existingByName.get(i.name);
|
||||
return existing && existing.icon !== (i.icon ?? null);
|
||||
});
|
||||
for (const { name, icon } of changedIcons) {
|
||||
await prisma.ingredient.update({ where: { name }, data: { icon } });
|
||||
}
|
||||
|
||||
// Re-resolve every ingredient's id (existing + just-created) and every
|
||||
// allergy's id (by its category name) once, then link them in a single
|
||||
// bulk insert — same "re-derived every time, not upserted per link"
|
||||
// reasoning as before for `IngredientAllergy` (it has no natural per-row
|
||||
// identity to upsert against), just batched instead of looped.
|
||||
const allIngredients = await prisma.ingredient.findMany({
|
||||
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id]));
|
||||
|
||||
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
||||
const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id]));
|
||||
|
||||
const links: Array<{ ingredientId: number; allergyId: number }> = [];
|
||||
for (const { name, allergenNames } of INGREDIENTS) {
|
||||
const ingredientId = ingredientIdByName.get(name);
|
||||
if (ingredientId === undefined) continue;
|
||||
for (const allergenName of allergenNames) {
|
||||
const allergyId = allergyIdByCategoryName.get(allergenName);
|
||||
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
|
||||
}
|
||||
}
|
||||
if (links.length > 0) {
|
||||
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
|
||||
import {
|
||||
updateAllergiesSchema,
|
||||
updateDietSchema,
|
||||
updateDislikedIngredientsSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
|
||||
import {
|
||||
getAllergyIds,
|
||||
getDislikedIngredientIds,
|
||||
updateAllergies,
|
||||
updateDiet,
|
||||
updateDislikedIngredients,
|
||||
} from "./profile.service.js";
|
||||
|
||||
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
||||
export const profileRouter = Router();
|
||||
|
|
@ -37,3 +47,26 @@ profileRouter.patch(
|
|||
res.status(200).json(allergyIds);
|
||||
}),
|
||||
);
|
||||
|
||||
profileRouter.get(
|
||||
"/disliked-ingredients",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const ids = await getDislikedIngredientIds(res.locals.userProfile.id);
|
||||
res.status(200).json(ids);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Personal taste preference — distinct from `/allergies`, which is medical. Managed from `/parametres/preferences` (see `PreferencesPage.tsx`). */
|
||||
profileRouter.patch(
|
||||
"/disliked-ingredients",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updateDislikedIngredientsSchema.parse(req.body);
|
||||
const ids = await updateDislikedIngredients(
|
||||
res.locals.userProfile.id,
|
||||
input.dislikedIngredientIds,
|
||||
);
|
||||
res.status(200).json(ids);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -74,3 +74,49 @@ export async function updateAllergies(
|
|||
|
||||
return allergyIds;
|
||||
}
|
||||
|
||||
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
||||
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||
where: { userProfileId },
|
||||
select: { ingredientId: true },
|
||||
});
|
||||
return rows.map((row) => row.ingredientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a profile's full disliked-ingredient set (not a merge — the
|
||||
* caller sends the complete list every time, same contract as
|
||||
* {@link updateAllergies}).
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference `Ingredient` row.
|
||||
*/
|
||||
export async function updateDislikedIngredients(
|
||||
userProfileId: number,
|
||||
dislikedIngredientIds: number[],
|
||||
): Promise<number[]> {
|
||||
if (dislikedIngredientIds.length > 0) {
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: dislikedIngredientIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
|
||||
prisma.userProfileDislikedIngredient.createMany({
|
||||
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
|
||||
}),
|
||||
]);
|
||||
|
||||
return dislikedIngredientIds;
|
||||
}
|
||||
|
|
|
|||
104
apps/api/src/modules/recipe/recipe.routes.ts
Normal file
104
apps/api/src/modules/recipe/recipe.routes.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createRecipeSchema,
|
||||
listRecipesSchema,
|
||||
updateRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import {
|
||||
addFavorite,
|
||||
createRecipe,
|
||||
deleteRecipe,
|
||||
getRecipe,
|
||||
listRecipes,
|
||||
removeFavorite,
|
||||
updateRecipe,
|
||||
} from "./recipe.service.js";
|
||||
|
||||
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
|
||||
export const recipeRouter = Router();
|
||||
|
||||
/** Parses and validates an `:id` route param, shared by every route below that targets one recipe. */
|
||||
function parseRecipeId(rawId: string | undefined): number {
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
recipeRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = listRecipesSchema.parse(req.query);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await listRecipes(viewerId, houseId, input.tab, input.search));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.get(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await getRecipe(id, viewerId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = createRecipeSchema.parse(req.body);
|
||||
const { id: authorId, houseId } = res.locals.userProfile;
|
||||
res.status(201).json(await createRecipe(input, authorId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.patch(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const input = updateRecipeSchema.parse(req.body);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
res.status(200).json(await updateRecipe(id, input, viewerId, houseId));
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.delete(
|
||||
"/:id",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
await deleteRecipe(id, viewerId, houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.post(
|
||||
"/:id/favorite",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
||||
await addFavorite(id, viewerId, houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
||||
recipeRouter.delete(
|
||||
"/:id/favorite",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parseRecipeId(req.params.id);
|
||||
await removeFavorite(id, res.locals.userProfile.id);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
415
apps/api/src/modules/recipe/recipe.service.ts
Normal file
415
apps/api/src/modules/recipe/recipe.service.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import {
|
||||
type AllergyView,
|
||||
type CreateRecipeInput,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
type RecipeSummaryView,
|
||||
type RecipeTab,
|
||||
type RecipeView,
|
||||
type UpdateRecipeInput,
|
||||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
||||
function recipeInclude(viewerId: number) {
|
||||
return {
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: { allergies: { include: { allergy: { include: { category: true } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: { orderBy: { order: "asc" } },
|
||||
diets: { include: { diet: true } },
|
||||
favoritedBy: { where: { userProfileId: viewerId } },
|
||||
} satisfies Prisma.RecipeInclude;
|
||||
}
|
||||
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||
type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
|
||||
/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||
function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function toDietView(diet: { id: number; name: string }): DietView {
|
||||
return { id: diet.id, name: diet.name };
|
||||
}
|
||||
|
||||
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||
const byId = new Map<number, AllergyView>();
|
||||
for (const ingredient of ingredients) {
|
||||
for (const allergen of ingredient.allergens) {
|
||||
byId.set(allergen.id, allergen);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
||||
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||
const allergens = aggregateAllergens(
|
||||
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
||||
);
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
description: recipe.description,
|
||||
picture: recipe.picture,
|
||||
authorId: recipe.authorId,
|
||||
visibility: recipe.visibility,
|
||||
allergens,
|
||||
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
||||
isFavorite: recipe.favoritedBy.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
|
||||
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
quantity: Number(recipeIngredient.quantity),
|
||||
unit: recipeIngredient.unit,
|
||||
}));
|
||||
return {
|
||||
...toRecipeSummaryView(recipe),
|
||||
ingredients,
|
||||
steps: recipe.steps.map((step) => ({
|
||||
id: step.id,
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
order: step.order,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `viewerId`/`viewerHouseId` may *read* this recipe — the author
|
||||
* always can, whatever the current visibility (even a `HOUSE` recipe if
|
||||
* they've since left that household — access to your own creations never
|
||||
* regresses). Otherwise follows `visibility` as documented on
|
||||
* `RecipeVisibility` in schema.prisma.
|
||||
*/
|
||||
function canView(
|
||||
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): boolean {
|
||||
if (recipe.authorId === viewerId) return true;
|
||||
if (recipe.visibility === "PUBLIC") return true;
|
||||
if (recipe.visibility === "HOUSE") {
|
||||
return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */
|
||||
function visibleToViewerWhere(
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Prisma.RecipeWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ authorId: viewerId },
|
||||
{ visibility: "PUBLIC" },
|
||||
...(viewerHouseId !== null
|
||||
? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipes visible to `viewerId` under one catalog tab, alphabetically,
|
||||
* optionally filtered further by a case-insensitive name substring. No
|
||||
* "toutes" tab — every recipe a viewer can see falls under exactly one of
|
||||
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
|
||||
* orthogonal, cross-cutting filter on top (and re-applies
|
||||
* {@link visibleToViewerWhere} in case access to a previously-favorited
|
||||
* recipe has since changed, e.g. leaving the house that granted it).
|
||||
*/
|
||||
export async function listRecipes(
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
tab: RecipeTab,
|
||||
search?: string,
|
||||
): Promise<RecipeSummaryView[]> {
|
||||
const conditions: Prisma.RecipeWhereInput[] = [];
|
||||
if (search) {
|
||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||
}
|
||||
|
||||
switch (tab) {
|
||||
case "favoris":
|
||||
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||
break;
|
||||
case "perso":
|
||||
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||
break;
|
||||
case "foyer":
|
||||
// No household — nothing can carry this viewer's authorHouseId.
|
||||
if (viewerHouseId === null) return [];
|
||||
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||
break;
|
||||
case "publique":
|
||||
conditions.push({ visibility: "PUBLIC" });
|
||||
break;
|
||||
}
|
||||
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
where: { AND: conditions },
|
||||
include: recipeInclude(viewerId),
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single recipe's full detail.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one).
|
||||
*/
|
||||
export async function getRecipe(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return toRecipeView(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
||||
* go — steps' `order` is derived from their position in `input.steps`,
|
||||
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||
* `GET /reference/ingredients`; there's no way to create one here).
|
||||
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
||||
* later edits (see {@link updateRecipe}).
|
||||
*
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function createRecipe(
|
||||
input: CreateRecipeInput,
|
||||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
|
||||
const created = await prisma.recipe.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
picture: input.picture ?? null,
|
||||
authorId,
|
||||
authorHouseId,
|
||||
visibility: input.visibility,
|
||||
ingredients: {
|
||||
create: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
include: recipeInclude(authorId),
|
||||
});
|
||||
return toRecipeView(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||
* missing from `input` is removed, same contract as `PATCH
|
||||
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||
* never transfers ownership.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||
*/
|
||||
export async function updateRecipe(
|
||||
id: number,
|
||||
input: UpdateRecipeInput,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||
await assertDietsExist(input.dietIds);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.step.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
||||
prisma.recipe.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
picture: input.picture ?? null,
|
||||
visibility: input.visibility,
|
||||
ingredients: {
|
||||
create: input.ingredients.map((ingredient) => ({
|
||||
ingredientId: ingredient.ingredientId,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a recipe outright — its ingredients/steps/diet tags/favorites
|
||||
* cascade away (see `onDelete: Cascade` in schema.prisma).
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||
* @throws {HttpError} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation.
|
||||
*/
|
||||
export async function deleteRecipe(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||
|
||||
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
|
||||
if (usedInPlanning) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
ErrorCode.RECIPE_IN_USE,
|
||||
"Recipe is still used by at least one planning item",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorites a recipe for `viewerId` — idempotent (favoriting an
|
||||
* already-favorited recipe is a no-op, not an error).
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action.
|
||||
*/
|
||||
export async function addFavorite(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
await prisma.recipeFavorite.upsert({
|
||||
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
|
||||
update: {},
|
||||
create: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
}
|
||||
|
||||
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
|
||||
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
|
||||
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
|
||||
}
|
||||
|
||||
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
|
||||
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
include: recipeInclude(viewerId),
|
||||
});
|
||||
if (!recipe) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
|
||||
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
||||
async function assertIsAuthor(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||
}
|
||||
if (recipe.authorId !== viewerId) {
|
||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
|
||||
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(ingredientIds)];
|
||||
const found = await prisma.ingredient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||
`Ingredient(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(dietIds)];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const found = await prisma.diet.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(found.map((diet) => diet.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AllergyView, DietView } from "@batch-cooking/shared";
|
||||
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
|
||||
|
|
@ -23,3 +23,28 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
|||
kind: allergy.category.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* All reference ingredients, alphabetically, each resolved to its allergens
|
||||
* (see `IngredientAllergy` in schema.prisma) — same aplattening approach as
|
||||
* {@link getAllergies}. Ingredients with no linked allergen come back with
|
||||
* `allergens: []`.
|
||||
*/
|
||||
export async function getIngredients(): Promise<IngredientView[]> {
|
||||
const ingredients = await prisma.ingredient.findMany({
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return ingredients.map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
icon: ingredient.icon,
|
||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||
id: allergy.id,
|
||||
name: allergy.category.name,
|
||||
kind: allergy.category.kind,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
526
apps/api/test/recipe.test.ts
Normal file
526
apps/api/test/recipe.test.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import type { SignupInput } from "@batch-cooking/shared";
|
||||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||
password: faker.internet.password({ length: 16 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` name. */
|
||||
async function ingredientId(name: string): Promise<number> {
|
||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } });
|
||||
return ingredient.id;
|
||||
}
|
||||
|
||||
describe("Recipes", () => {
|
||||
const app = createApp();
|
||||
|
||||
/** Signs up a fresh profile and returns both its session `agent` and profile id — most tests below need the id for `authorId` on directly-created fixture rows. */
|
||||
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
return { agent, profileId: res.body.id };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("GET /recipes", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/recipes").query({ tab: "publique" });
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("rejects a missing ?tab= with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.get("/recipes");
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("returns an empty catalog when no recipe exists yet", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "publique" });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("filters the catalog by name when ?search= is given", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
await prisma.recipe.create({
|
||||
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
await prisma.recipe.create({
|
||||
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Ratatouille"]);
|
||||
});
|
||||
|
||||
it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { profileId: otherId } = await signup();
|
||||
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } });
|
||||
await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } });
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "perso" });
|
||||
|
||||
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["La mienne"]);
|
||||
});
|
||||
|
||||
it("foyer tab only returns HOUSE recipes authored within the viewer's current house", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const { profileId: otherId } = await signup();
|
||||
const otherHouseRes = await request.agent(app).post("/house").send({ name: "Chez un autre" });
|
||||
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Recette du foyer",
|
||||
authorId: profileId,
|
||||
visibility: "HOUSE",
|
||||
authorHouseId: houseRes.body.id,
|
||||
},
|
||||
});
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Recette d'un autre foyer",
|
||||
authorId: otherId,
|
||||
visibility: "HOUSE",
|
||||
authorHouseId: otherHouseRes.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "foyer" });
|
||||
|
||||
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Recette du foyer"]);
|
||||
});
|
||||
|
||||
it("foyer tab is empty when the viewer has no household", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "foyer" });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("favoris tab only returns recipes the viewer has favorited", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const favorited = await prisma.recipe.create({
|
||||
data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
await prisma.recipe.create({
|
||||
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
await agent.post(`/recipes/${favorited.id}/favorite`);
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "favoris" });
|
||||
|
||||
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Favorite"]);
|
||||
});
|
||||
|
||||
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
|
||||
const { agent } = await signup();
|
||||
const { profileId: otherId } = await signup();
|
||||
await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
|
||||
|
||||
const res = await agent.get("/recipes").query({ tab: "publique" });
|
||||
|
||||
expect(res.body).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /recipes", () => {
|
||||
it("creates a recipe with its ingredients, ordered steps and diet tags", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
const oeuf = await ingredientId("Œuf");
|
||||
const vegetarien = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } });
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Omelette provençale",
|
||||
description: "Rapide et savoureuse",
|
||||
dietIds: [vegetarien.id],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 2, unit: "unité" },
|
||||
{ ingredientId: oeuf, quantity: 3, unit: "unité" },
|
||||
],
|
||||
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.name).to.equal("Omelette provençale");
|
||||
expect(res.body.ingredients).to.have.length(2);
|
||||
expect(
|
||||
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
||||
).to.deep.equal([0, 1]);
|
||||
// Allergens aggregated across ingredients — "Œuf" carries "Œufs".
|
||||
expect(res.body.allergens.map((a: { name: string }) => a.name)).to.include("Œufs");
|
||||
expect(res.body.diets.map((d: { name: string }) => d.name)).to.deep.equal(["Végétarien"]);
|
||||
});
|
||||
|
||||
it("defaults to PERSONAL visibility, and stamps the author's current household", async () => {
|
||||
const { agent } = await signup();
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const tomate = await ingredientId("Tomate");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.body.visibility).to.equal("PERSONAL");
|
||||
// authorHouseId isn't in the API response, but the "foyer" tab
|
||||
// proves it was stamped — a HOUSE recipe created next should show up.
|
||||
const houseRecipe = await agent.post("/recipes").send({
|
||||
name: "Foyer",
|
||||
visibility: "HOUSE",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
|
||||
expect(foyerRes.body.map((r: { id: number }) => r.id)).to.include(houseRecipe.body.id);
|
||||
expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
|
||||
});
|
||||
|
||||
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Test",
|
||||
dietIds: [999_999],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent
|
||||
.post("/recipes")
|
||||
.send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /recipes/:id", () => {
|
||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.get("/recipes/999999");
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("returns the full recipe detail", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.get(`/recipes/${created.body.id}`);
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.name).to.equal("Salade");
|
||||
expect(res.body.ingredients[0].ingredient.name).to.equal("Tomate");
|
||||
expect(res.body.isFavorite).to.equal(false);
|
||||
});
|
||||
|
||||
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
|
||||
const { agent } = await signup();
|
||||
const { profileId: otherId } = await signup();
|
||||
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
|
||||
|
||||
const res = await agent.get(`/recipes/${recipe.id}`);
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("returns 200 for a PUBLIC recipe belonging to someone else", async () => {
|
||||
const { agent } = await signup();
|
||||
const { profileId: otherId } = await signup();
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
const res = await agent.get(`/recipes/${recipe.id}`);
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
it("returns 200 for a HOUSE recipe shared with the viewer's household, 404 otherwise", async () => {
|
||||
const { agent } = await signup();
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const { profileId: otherId } = await signup();
|
||||
const inHouse = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Du foyer",
|
||||
authorId: otherId,
|
||||
visibility: "HOUSE",
|
||||
authorHouseId: houseRes.body.id,
|
||||
},
|
||||
});
|
||||
const otherHouseId = (
|
||||
await prisma.house.create({
|
||||
data: { name: "Autre", adminId: otherId, inviteCode: "TESTHOUS" },
|
||||
})
|
||||
).id;
|
||||
const outsideHouse = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "D'un autre foyer",
|
||||
authorId: otherId,
|
||||
visibility: "HOUSE",
|
||||
authorHouseId: otherHouseId,
|
||||
},
|
||||
});
|
||||
|
||||
const inHouseRes = await agent.get(`/recipes/${inHouse.id}`);
|
||||
const outsideHouseRes = await agent.get(`/recipes/${outsideHouse.id}`);
|
||||
|
||||
expect(inHouseRes.status).to.equal(200);
|
||||
expect(outsideHouseRes.status).to.equal(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /recipes/:id", () => {
|
||||
it("replaces the recipe's whole content", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
const oignon = await ingredientId("Oignon");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade composée",
|
||||
visibility: "PUBLIC",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
|
||||
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.name).to.equal("Salade composée");
|
||||
expect(res.body.visibility).to.equal("PUBLIC");
|
||||
expect(res.body.ingredients).to.have.length(1);
|
||||
expect(res.body.ingredients[0].ingredient.name).to.equal("Oignon");
|
||||
expect(res.body.steps).to.have.length(2);
|
||||
});
|
||||
|
||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
|
||||
const res = await agent.patch("/recipes/999999").send({
|
||||
name: "Test",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade",
|
||||
dietIds: [999_999],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { agent: otherAgent } = await signup();
|
||||
const tomate = await ingredientId("Tomate");
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
||||
name: "Hack",
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||
steps: [{ description: "Étape" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(403);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /recipes/:id", () => {
|
||||
it("deletes a recipe not referenced by any planning item", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "À supprimer", authorId: profileId },
|
||||
});
|
||||
|
||||
const res = await agent.delete(`/recipes/${recipe.id}`);
|
||||
expect(res.status).to.equal(204);
|
||||
|
||||
const getRes = await agent.get(`/recipes/${recipe.id}`);
|
||||
expect(getRes.status).to.equal(404);
|
||||
});
|
||||
|
||||
it("rejects deleting a recipe still used by a planning item with 409 RECIPE_IN_USE", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Ratatouille", authorId: profileId },
|
||||
});
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId: houseRes.body.id,
|
||||
startDate: new Date(Date.UTC(2026, 0, 1)),
|
||||
finishDate: new Date(Date.UTC(2026, 0, 7)),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
|
||||
});
|
||||
|
||||
const res = await agent.delete(`/recipes/${recipe.id}`);
|
||||
|
||||
expect(res.status).to.equal(409);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_IN_USE);
|
||||
});
|
||||
|
||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||
const { agent } = await signup();
|
||||
|
||||
const res = await agent.delete("/recipes/999999");
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("rejects deleting someone else's recipe with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||
const { profileId } = await signup();
|
||||
const { agent: otherAgent } = await signup();
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
const res = await otherAgent.delete(`/recipes/${recipe.id}`);
|
||||
|
||||
expect(res.status).to.equal(403);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_RECIPE_AUTHOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST/DELETE /recipes/:id/favorite", () => {
|
||||
it("adds and removes a recipe from the viewer's favorites", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
|
||||
expect(addRes.status).to.equal(204);
|
||||
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(true);
|
||||
|
||||
const removeRes = await agent.delete(`/recipes/${recipe.id}/favorite`);
|
||||
expect(removeRes.status).to.equal(204);
|
||||
expect((await agent.get(`/recipes/${recipe.id}`)).body.isFavorite).to.equal(false);
|
||||
});
|
||||
|
||||
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" },
|
||||
});
|
||||
|
||||
await agent.post(`/recipes/${recipe.id}/favorite`);
|
||||
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
|
||||
|
||||
expect(res.status).to.equal(204);
|
||||
});
|
||||
|
||||
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const { profileId: otherId } = await signup();
|
||||
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } });
|
||||
|
||||
const res = await agent.post(`/recipes/${recipe.id}/favorite`);
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -46,4 +46,23 @@ describe("Reference data", () => {
|
|||
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /reference/ingredients", () => {
|
||||
it("returns the seeded ingredients, no session required", async () => {
|
||||
const res = await request(app).get("/reference/ingredients");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.length).to.be.greaterThan(0);
|
||||
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
||||
expect(res.body[0]).to.have.keys(["id", "name", "icon", "allergens"]);
|
||||
});
|
||||
|
||||
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||
const res = await request(app).get("/reference/ingredients");
|
||||
|
||||
const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name);
|
||||
expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs");
|
||||
expect(byName("Tomate").allergens).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth";
|
|||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PlanningPage } from "./pages/PlanningPage";
|
||||
import { RecipeFormPage } from "./pages/RecipeFormPage";
|
||||
import { RecipesPage } from "./pages/RecipesPage";
|
||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||
import { SignupPage } from "./pages/SignupPage";
|
||||
|
|
@ -47,7 +48,14 @@ export function App() {
|
|||
}
|
||||
>
|
||||
<Route path="/" element={<PlanningPage />} />
|
||||
{/* Same component for both — a master-detail layout, not a
|
||||
navigation to a separate page: the tab bar + table stay
|
||||
mounted, only RecipesPage's detail panel changes with `:id`
|
||||
(see RecipesPage.tsx). */}
|
||||
<Route path="/recettes" element={<RecipesPage />} />
|
||||
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
|
||||
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import {
|
||||
type AllergyView,
|
||||
type ApiErrorResponse,
|
||||
type CreateRecipeInput,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type HouseView,
|
||||
type IngredientView,
|
||||
type LoginInput,
|
||||
type PlanningView,
|
||||
type PreferencesView,
|
||||
type RecipeSummaryView,
|
||||
type RecipeTab,
|
||||
type RecipeView,
|
||||
type SafeUserProfile,
|
||||
type SignupInput,
|
||||
type ThemePreference,
|
||||
type UpdateRecipeInput,
|
||||
} from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
|
|
@ -132,6 +138,48 @@ export class ApiClient {
|
|||
return this.request("/reference/allergies");
|
||||
}
|
||||
|
||||
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
|
||||
public getIngredients(): Promise<IngredientView[]> {
|
||||
return this.request("/reference/ingredients");
|
||||
}
|
||||
|
||||
/** One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), optionally filtered further by a name substring. */
|
||||
public listRecipes(tab: RecipeTab, search?: string): Promise<RecipeSummaryView[]> {
|
||||
const params = new URLSearchParams({ tab });
|
||||
if (search) params.set("search", search);
|
||||
return this.request(`/recipes?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
|
||||
public getRecipe(id: number): Promise<RecipeView> {
|
||||
return this.request(`/recipes/${id}`);
|
||||
}
|
||||
|
||||
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
|
||||
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
|
||||
return this.request("/recipes", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
|
||||
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
|
||||
return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
|
||||
public deleteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Favorites a recipe for the current user — idempotent. */
|
||||
public addFavoriteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}/favorite`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Unfavorites a recipe for the current user — idempotent. */
|
||||
public removeFavoriteRecipe(id: number): Promise<void> {
|
||||
return this.request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
||||
public getCurrentHouse(): Promise<HouseView | null> {
|
||||
return this.request("/house/current");
|
||||
|
|
@ -185,6 +233,19 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
|
||||
public getDislikedIngredientIds(): Promise<number[]> {
|
||||
return this.request("/profile/disliked-ingredients");
|
||||
}
|
||||
|
||||
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
|
||||
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
|
||||
return this.request("/profile/disliked-ingredients", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ dislikedIngredientIds }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
||||
public getPreferences(): Promise<PreferencesView> {
|
||||
return this.request("/preferences");
|
||||
|
|
|
|||
68
apps/web/src/features/profile/DislikedIngredientsField.tsx
Normal file
68
apps/web/src/features/profile/DislikedIngredientsField.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// Reuses `IngredientAutocomplete` verbatim (built for the recipe form's
|
||||
// ingredient picker, features/recipes/) rather than a second search field —
|
||||
// same reference ingredient list, same "type to filter" behavior, just
|
||||
// without the recipe form's quantity/unit per line. Its own recipes.scss
|
||||
// import already covers `.ingredient-autocomplete`; this file's explicit
|
||||
// import below only adds `.disliked-ingredients-field__*` (see recipes.scss
|
||||
// — colocated there since it's the same "reference ingredient picker"
|
||||
// visual family, even though this field lives in the profile feature).
|
||||
import { IngredientAutocomplete } from "../recipes/IngredientAutocomplete";
|
||||
import "../recipes/recipes.scss";
|
||||
import "./profile-forms.scss";
|
||||
|
||||
/**
|
||||
* Search-and-add + removable-chips field for the current user's personal
|
||||
* "disliked ingredients" list — a taste preference, distinct from
|
||||
* `AllergySelect`'s medical allergy list. Used on `/parametres/preferences`
|
||||
* (`PreferencesPage`); crossed against a recipe's own ingredients on its
|
||||
* detail panel (`RecipeDetailPanel`) to surface just the relevant ones.
|
||||
*/
|
||||
export function DislikedIngredientsField({
|
||||
ingredients,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ingredients: IngredientView[];
|
||||
value: number[];
|
||||
onChange: (ingredientIds: number[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selected = ingredients.filter((ingredient) => value.includes(ingredient.id));
|
||||
|
||||
function add(ingredient: IngredientView) {
|
||||
onChange([...value, ingredient.id]);
|
||||
}
|
||||
|
||||
function remove(ingredientId: number) {
|
||||
onChange(value.filter((id) => id !== ingredientId));
|
||||
}
|
||||
|
||||
return (
|
||||
// `<fieldset>`/`<legend>` (not a bare `<label>`, which only associates
|
||||
// with a single control) — this labels the composite widget (chips +
|
||||
// search), same reasoning as AllergySelect.
|
||||
<fieldset className="disliked-ingredients-field">
|
||||
<legend>{t("preferences.form.dislikedIngredientsLabel")}</legend>
|
||||
{selected.length > 0 && (
|
||||
<ul className="disliked-ingredients-field__chips">
|
||||
{selected.map((ingredient) => (
|
||||
<li key={ingredient.id} className="disliked-ingredients-field__chip">
|
||||
<span aria-hidden="true">{ingredient.icon}</span>
|
||||
{ingredient.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(ingredient.id)}
|
||||
title={t("preferences.form.removeDislikedIngredient")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<IngredientAutocomplete ingredients={ingredients} excludeIds={value} onSelect={add} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/features/recipes/AllergenBadges.tsx
Normal file
25
apps/web/src/features/recipes/AllergenBadges.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { AllergyView } from "@batch-cooking/shared";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
* A row of allergen pills — used both on {@link RecipeCard} (catalog list)
|
||||
* and the recipe detail page, for a single ingredient's allergens as well
|
||||
* as a recipe's aggregated set (`RecipeSummaryView.allergens`/
|
||||
* `RecipeView.allergens`, already deduplicated server-side). Renders
|
||||
* nothing for an empty list rather than an empty wrapper, so callers can
|
||||
* mount it unconditionally.
|
||||
*/
|
||||
export function AllergenBadges({ allergens }: { allergens: AllergyView[] }) {
|
||||
if (allergens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ul className="allergen-badges">
|
||||
{allergens.map((allergen) => (
|
||||
<li key={allergen.id} className="allergen-badge">
|
||||
{allergen.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/features/recipes/DietBadges.tsx
Normal file
25
apps/web/src/features/recipes/DietBadges.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { DietView } from "@batch-cooking/shared";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
* A row of diet-regime pills — the "associated regime" reminder tagged on a
|
||||
* recipe (`RecipeSummaryView.diets`/`RecipeView.diets`, manually chosen by
|
||||
* the author, see `RecipeFormPage`). Uses `--color-tag` (turmeric,
|
||||
* "category/classification tags" per `_theme.scss`) — never
|
||||
* `--color-allergen`, to stay visually distinct from a safety warning.
|
||||
* Renders nothing for an empty list, same convention as `AllergenBadges`.
|
||||
*/
|
||||
export function DietBadges({ diets }: { diets: DietView[] }) {
|
||||
if (diets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ul className="diet-badges">
|
||||
{diets.map((diet) => (
|
||||
<li key={diet.id} className="diet-badge">
|
||||
{diet.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/features/recipes/DietTagSelect.tsx
Normal file
41
apps/web/src/features/recipes/DietTagSelect.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { DietView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
* Multi-select (checkbox grid, same pattern as `AllergySelect`) for the
|
||||
* regime(s) a recipe is tagged as suiting — manually chosen by whoever
|
||||
* creates/edits it (see `RecipeFormPage`), a reminder shown in the catalog
|
||||
* table and detail panel (`DietBadges`), not computed from ingredients.
|
||||
*/
|
||||
export function DietTagSelect({
|
||||
diets,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
diets: DietView[];
|
||||
value: number[];
|
||||
onChange: (dietIds: number[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function toggle(id: number) {
|
||||
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="diet-tag-select">
|
||||
<legend>{t("recipes.form.dietsLabel")}</legend>
|
||||
{diets.map((diet) => {
|
||||
const checked = value.includes(diet.id);
|
||||
return (
|
||||
<label key={diet.id} className={checked ? "is-selected" : undefined}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(diet.id)} />
|
||||
<span className="check-mark" aria-hidden="true" />
|
||||
{diet.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/features/recipes/FavoriteStarButton.tsx
Normal file
58
apps/web/src/features/recipes/FavoriteStarButton.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { FavoriteIcon } from "../../layouts/nav-icons";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
* Star toggle for a recipe's favorite status — the overlay button on
|
||||
* {@link RecipeDetailPanel}'s photo header. Optimistic: flips immediately,
|
||||
* calls the API in the background, and reverts if it fails (favoriting is
|
||||
* rare enough, and low-stakes enough, that a rollback-on-error is simpler
|
||||
* and just as correct as a pending state).
|
||||
*
|
||||
* `onToggled` lets the owning page (`RecipesPage`) keep the catalog table's
|
||||
* row (the fav-mark next to the name, and the `favoris` tab's membership)
|
||||
* in sync — this component only owns the button's own optimistic look.
|
||||
*/
|
||||
export function FavoriteStarButton({
|
||||
recipeId,
|
||||
isFavorite,
|
||||
onToggled,
|
||||
}: {
|
||||
recipeId: number;
|
||||
isFavorite: boolean;
|
||||
onToggled: (isFavorite: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
const next = !isFavorite;
|
||||
onToggled(next);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (next) {
|
||||
await apiClient.addFavoriteRecipe(recipeId);
|
||||
} else {
|
||||
await apiClient.removeFavoriteRecipe(recipeId);
|
||||
}
|
||||
} catch {
|
||||
onToggled(!next);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`favorite-star-button${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={handleClick}
|
||||
disabled={isSaving}
|
||||
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
|
||||
>
|
||||
<FavoriteIcon />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/features/recipes/IngredientAutocomplete.tsx
Normal file
71
apps/web/src/features/recipes/IngredientAutocomplete.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** Caps how many matches are shown at once — the reference list is small, but an unbounded dropdown would still be unwieldy for a broad query like a single letter. */
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
/**
|
||||
* Text field over the static reference ingredient list (`GET
|
||||
* /reference/ingredients`) — receives the full list as a prop rather than
|
||||
* fetching it itself, same rationale as `AllergySelect`/`DietSelect`, and
|
||||
* filters it client-side as the user types: it's small, non-administrable
|
||||
* reference data, no dedicated search endpoint needed. `excludeIds` (the
|
||||
* ingredients already on the recipe) keeps the same one from being added
|
||||
* twice.
|
||||
*/
|
||||
export function IngredientAutocomplete({
|
||||
ingredients,
|
||||
excludeIds,
|
||||
onSelect,
|
||||
}: {
|
||||
ingredients: IngredientView[];
|
||||
excludeIds: number[];
|
||||
onSelect: (ingredient: IngredientView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const suggestions =
|
||||
normalizedQuery.length === 0
|
||||
? []
|
||||
: ingredients
|
||||
.filter(
|
||||
(ingredient) =>
|
||||
!excludeIds.includes(ingredient.id) &&
|
||||
ingredient.name.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS);
|
||||
|
||||
function handleSelect(ingredient: IngredientView) {
|
||||
onSelect(ingredient);
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ingredient-autocomplete">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("recipes.form.addIngredientPlaceholder")}
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<ul className="ingredient-autocomplete__suggestions">
|
||||
{suggestions.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
<button type="button" onClick={() => handleSelect(ingredient)}>
|
||||
<span aria-hidden="true">{ingredient.icon}</span>
|
||||
<span className="ingredient-autocomplete__name">{ingredient.name}</span>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/features/recipes/IngredientRow.tsx
Normal file
58
apps/web/src/features/recipes/IngredientRow.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { IngredientView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientAutocomplete`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
||||
export function IngredientRow({
|
||||
ingredient,
|
||||
quantity,
|
||||
unit,
|
||||
onQuantityChange,
|
||||
onUnitChange,
|
||||
onRemove,
|
||||
}: {
|
||||
ingredient: IngredientView;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
onQuantityChange: (quantity: string) => void;
|
||||
onUnitChange: (unit: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<li className="ingredient-row">
|
||||
<span className="ingredient-row__icon" aria-hidden="true">
|
||||
{ingredient.icon}
|
||||
</span>
|
||||
<span className="ingredient-row__name">{ingredient.name}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
className="ingredient-row__quantity"
|
||||
value={quantity}
|
||||
onChange={(e) => onQuantityChange(e.target.value)}
|
||||
aria-label={t("recipes.form.quantityLabel")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="ingredient-row__unit"
|
||||
value={unit}
|
||||
onChange={(e) => onUnitChange(e.target.value)}
|
||||
placeholder={t("recipes.form.unitPlaceholder")}
|
||||
aria-label={t("recipes.form.unitLabel")}
|
||||
/>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
<button
|
||||
type="button"
|
||||
className="ingredient-row__remove"
|
||||
onClick={onRemove}
|
||||
title={t("recipes.form.removeIngredient")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
190
apps/web/src/features/recipes/RecipeDetailPanel.tsx
Normal file
190
apps/web/src/features/recipes/RecipeDetailPanel.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { ErrorCode, type RecipeView } from "@batch-cooking/shared";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { FavoriteStarButton } from "./FavoriteStarButton";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */
|
||||
export type RecipeDetailState =
|
||||
| { status: "empty" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; recipe: RecipeView }
|
||||
| { status: "not-found" }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Right-hand panel of the catalog's master-detail layout (`RecipesPage`) —
|
||||
* header (photo + favorite star), name + allergen/disliked-ingredient
|
||||
* badges, description, ordered steps. `dislikedIngredientIds` is the
|
||||
* *viewer's* personal taste-preference list (`GET
|
||||
* /profile/disliked-ingredients`) — crossed here against this recipe's own
|
||||
* ingredients to surface just the ones relevant to it, not the viewer's
|
||||
* whole list.
|
||||
*/
|
||||
export function RecipeDetailPanel({
|
||||
state,
|
||||
dislikedIngredientIds,
|
||||
onFavoriteToggled,
|
||||
onDeleted,
|
||||
}: {
|
||||
state: RecipeDetailState;
|
||||
dislikedIngredientIds: number[];
|
||||
onFavoriteToggled: (recipeId: number, isFavorite: boolean) => void;
|
||||
onDeleted: (recipeId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.status === "empty") {
|
||||
return (
|
||||
<aside className="recipe-detail-panel">
|
||||
<p className="recipe-detail-panel__status">{t("recipes.detail.empty")}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<aside className="recipe-detail-panel">
|
||||
<p className="recipe-detail-panel__status">{t("recipes.loading")}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (state.status === "not-found") {
|
||||
return (
|
||||
<aside className="recipe-detail-panel">
|
||||
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
|
||||
{t("recipes.notFound")}
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<aside className="recipe-detail-panel">
|
||||
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const { recipe } = state;
|
||||
const dislikedIngredients = recipe.ingredients
|
||||
.map((line) => line.ingredient)
|
||||
.filter((ingredient) => dislikedIngredientIds.includes(ingredient.id));
|
||||
|
||||
return (
|
||||
<aside className="recipe-detail-panel">
|
||||
<div className="recipe-detail-panel__header">
|
||||
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
||||
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
|
||||
</div>
|
||||
<FavoriteStarButton
|
||||
recipeId={recipe.id}
|
||||
isFavorite={recipe.isFavorite}
|
||||
onToggled={(isFavorite) => onFavoriteToggled(recipe.id, isFavorite)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="recipe-detail-panel__title-row">
|
||||
<h2>{recipe.name}</h2>
|
||||
<div className="recipe-detail-panel__title-badges">
|
||||
<AllergenBadges allergens={recipe.allergens} />
|
||||
{dislikedIngredients.length > 0 && (
|
||||
<ul className="disliked-badges">
|
||||
{dislikedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="disliked-badge">
|
||||
🚫 {ingredient.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-detail-panel__actions">
|
||||
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
|
||||
{t("recipes.editButton")}
|
||||
</Link>
|
||||
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted(recipe.id)} />
|
||||
</div>
|
||||
|
||||
{recipe.description && (
|
||||
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
||||
<p className="recipe-detail-panel__description">{recipe.description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="recipe-detail-panel__section">
|
||||
<h3>{t("recipes.stepsTitle")}</h3>
|
||||
<ol className="recipe-detail-panel__steps">
|
||||
{recipe.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.picture && <img src={step.picture} alt="" />}
|
||||
<p>{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete action with an inline two-step confirmation, same pattern as `HouseholdSettingsPage`'s danger zone. */
|
||||
function DeleteRecipeButton({
|
||||
recipeId,
|
||||
onDeleted,
|
||||
}: {
|
||||
recipeId: number;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteRecipe(recipeId);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setError(errorMessageService.getLabel(code));
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConfirming) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-detail-panel__danger-button"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
>
|
||||
{t("recipes.deleteButton")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="recipe-detail-panel__delete-confirm">
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-detail-panel__danger-button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("recipes.confirmDeleteButton")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsConfirming(false)} disabled={isDeleting}>
|
||||
{t("recipes.cancelDeleteButton")}
|
||||
</button>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/features/recipes/RecipeTable.tsx
Normal file
84
apps/web/src/features/recipes/RecipeTable.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { RecipeSummaryView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AllergenBadges } from "./AllergenBadges";
|
||||
import { DietBadges } from "./DietBadges";
|
||||
import "./recipes.scss";
|
||||
|
||||
/**
|
||||
* Left-aligned catalog table — photo / name / allergens-intolerances /
|
||||
* associated regime, one row per recipe. Replaces the earlier card grid
|
||||
* (`RecipeCard`, removed): clicking a row selects it (`onSelect`) rather
|
||||
* than navigating to a separate page — the detail renders alongside, in
|
||||
* `RecipeDetailPanel` (see `RecipesPage`'s master-detail layout).
|
||||
*/
|
||||
export function RecipeTable({
|
||||
recipes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
recipes: RecipeSummaryView[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="recipe-table-wrap">
|
||||
<table className="recipe-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("recipes.table.name")}</th>
|
||||
<th>{t("recipes.table.allergens")}</th>
|
||||
<th>{t("recipes.table.diets")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recipes.map((recipe) => (
|
||||
<tr
|
||||
key={recipe.id}
|
||||
className={recipe.id === selectedId ? "selected" : undefined}
|
||||
onClick={() => onSelect(recipe.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(recipe.id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
aria-current={recipe.id === selectedId ? "true" : undefined}
|
||||
>
|
||||
<td>
|
||||
<span className="recipe-table__photo" aria-hidden="true">
|
||||
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="recipe-table__name">
|
||||
{recipe.name}
|
||||
{recipe.isFavorite && (
|
||||
<span className="recipe-table__fav-mark" aria-hidden="true">
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{recipe.allergens.length > 0 ? (
|
||||
<AllergenBadges allergens={recipe.allergens} />
|
||||
) : (
|
||||
<span className="recipe-table__muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{recipe.diets.length > 0 ? (
|
||||
<DietBadges diets={recipe.diets} />
|
||||
) : (
|
||||
<span className="recipe-table__muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/features/recipes/RecipeTabs.tsx
Normal file
54
apps/web/src/features/recipes/RecipeTabs.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { RecipeTab } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
|
||||
const TABS: Array<{ value: RecipeTab; Icon: () => JSX.Element }> = [
|
||||
{ value: "favoris", Icon: FavoriteIcon },
|
||||
{ value: "perso", Icon: AccountIcon },
|
||||
{ value: "foyer", Icon: HouseholdIcon },
|
||||
{ value: "publique", Icon: PublicIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* Catalog tab bar — Favoris / Perso / Foyer / Publique, plus a disabled
|
||||
* placeholder for external sources (not built yet, see the plan's "hors
|
||||
* scope" note) so the eventual nav slot is visible without being
|
||||
* functional. No "toutes" tab: every recipe a viewer can see falls under
|
||||
* exactly one of perso/foyer/publique (its own visibility) — see
|
||||
* `recipe.service.ts`'s `listRecipes`.
|
||||
*/
|
||||
export function RecipeTabs({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: RecipeTab;
|
||||
onChange: (tab: RecipeTab) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="recipe-tabs">
|
||||
{TABS.map(({ value, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
|
||||
onClick={() => onChange(value)}
|
||||
>
|
||||
<Icon />
|
||||
{t(`recipes.tabs.${value}`)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-tabs__tab placeholder"
|
||||
disabled
|
||||
title={t("recipes.tabs.sourcesSoonHint")}
|
||||
>
|
||||
{t("recipes.tabs.sourcesSoon")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/features/recipes/StepListEditor.tsx
Normal file
101
apps/web/src/features/recipes/StepListEditor.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import "./recipes.scss";
|
||||
|
||||
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
|
||||
export interface StepDraft {
|
||||
key: string;
|
||||
description: string;
|
||||
picture: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered step editor — reordering is just moving array elements (up/down
|
||||
* buttons), no drag-and-drop library needed for a first version. `order` is
|
||||
* never tracked explicitly here: the array's position *is* the order, sent
|
||||
* to the API as-is on submit.
|
||||
*/
|
||||
export function StepListEditor({
|
||||
steps,
|
||||
onChange,
|
||||
}: {
|
||||
steps: StepDraft[];
|
||||
onChange: (steps: StepDraft[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function addStep() {
|
||||
onChange([...steps, { key: crypto.randomUUID(), description: "", picture: "" }]);
|
||||
}
|
||||
|
||||
function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) {
|
||||
onChange(steps.map((step) => (step.key === key ? { ...step, ...patch } : step)));
|
||||
}
|
||||
|
||||
function removeStep(key: string) {
|
||||
onChange(steps.filter((step) => step.key !== key));
|
||||
}
|
||||
|
||||
function moveStep(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
const next = [...steps];
|
||||
const moved = next.splice(index, 1)[0];
|
||||
if (!moved) return;
|
||||
next.splice(target, 0, moved);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="step-list-editor">
|
||||
<ol className="step-list-editor__list">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.key} className="step-list-editor__item">
|
||||
<div className="step-list-editor__reorder">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(index, -1)}
|
||||
disabled={index === 0}
|
||||
title={t("recipes.form.moveStepUp")}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(index, 1)}
|
||||
disabled={index === steps.length - 1}
|
||||
title={t("recipes.form.moveStepDown")}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
<div className="step-list-editor__fields">
|
||||
<textarea
|
||||
value={step.description}
|
||||
onChange={(e) => updateStep(step.key, { description: e.target.value })}
|
||||
placeholder={t("recipes.form.stepDescriptionPlaceholder")}
|
||||
rows={2}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={step.picture}
|
||||
onChange={(e) => updateStep(step.key, { picture: e.target.value })}
|
||||
placeholder={t("recipes.form.stepPicturePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="step-list-editor__remove"
|
||||
onClick={() => removeStep(step.key)}
|
||||
title={t("recipes.form.removeStep")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button type="button" className="step-list-editor__add" onClick={addStep}>
|
||||
{t("recipes.form.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
888
apps/web/src/features/recipes/recipes.scss
Normal file
888
apps/web/src/features/recipes/recipes.scss
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
// =============================================================================
|
||||
// Recipe catalog — shared by RecipesPage, RecipeFormPage and every component
|
||||
// under features/recipes/. Colocated here (rather than split per-component)
|
||||
// since the catalog is one cohesive visual unit and most rules are small
|
||||
// enough that splitting them would just add files to jump between — same
|
||||
// choice as settings-pages.scss for the settings pages.
|
||||
//
|
||||
// The master-detail layout (tabs + table + detail panel) was ported from a
|
||||
// reviewed standalone HTML mockup onto the app's real design tokens — no
|
||||
// light/dark duplication needed here, every `var(--color-*)` below already
|
||||
// resolves per-theme globally (see styles/_theme.scss).
|
||||
// =============================================================================
|
||||
|
||||
// --- Allergen / diet / disliked-ingredient badges ---------------------------
|
||||
// Three visually distinct pill languages, each with its own meaning — never
|
||||
// mixed, so a glance tells them apart without reading the label:
|
||||
// - allergens: --color-allergen, solid fill (a food-safety warning)
|
||||
// - diets: --color-tag (turmeric — "category/classification tag" per
|
||||
// _theme.scss), solid fill (a neutral reminder, not a warning)
|
||||
// - disliked ingredients: neutral outline, dashed border (a personal
|
||||
// taste preference — deliberately *not* on the allergen/warning scale)
|
||||
.allergen-badges,
|
||||
.diet-badges,
|
||||
.disliked-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.allergen-badge,
|
||||
.diet-badge,
|
||||
.disliked-badge {
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.allergen-badge {
|
||||
color: var(--color-allergen-ink);
|
||||
background: var(--color-allergen);
|
||||
}
|
||||
|
||||
.diet-badge {
|
||||
color: var(--color-tag-ink);
|
||||
background: var(--color-tag);
|
||||
}
|
||||
|
||||
.disliked-badge {
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
// --- Catalog page -------------------------------------------------------------
|
||||
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
||||
// height — same reasoning as `planning-page.scss`. `.recipes-page` fills
|
||||
// that box as a column so `__catalog` can grow to fill whatever's left
|
||||
// under the header/tabs, instead of being only as tall as its content.
|
||||
.recipes-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1 1 16rem;
|
||||
max-width: 24rem;
|
||||
padding: 0.5rem var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__new-button {
|
||||
flex: none;
|
||||
padding: 0.5rem var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
margin-top: var(--space-xl);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
// The master-detail row: table (flexible) + detail panel (never under
|
||||
// 35% of the *viewport* width — `minmax(35vw, 38vw)`, not a fraction of
|
||||
// this grid, per the reviewed mockup), both stretched to the same full
|
||||
// height so the detail panel never just shrink-wraps to its own content.
|
||||
&__catalog {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(35vw, 38vw);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
&__catalog {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tabs ------------------------------------------------------------------
|
||||
.recipe-tabs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
&__tab {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.placeholder {
|
||||
color: var(--color-border);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Catalog table -----------------------------------------------------------
|
||||
.recipe-table-wrap {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.recipe-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
||||
|
||||
td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__photo {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__fav-mark {
|
||||
color: var(--color-tag);
|
||||
margin-left: 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&__muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recipe detail panel -----------------------------------------------------
|
||||
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
|
||||
// panel itself never scrolls, only its content does past that height
|
||||
// (`overflow-y: auto`) — the photo/star/name stay visible while reading.
|
||||
.recipe-detail-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
&__status {
|
||||
padding: var(--space-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__photo {
|
||||
height: 9rem;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
|
||||
var(--color-surface-alt)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-md) 0;
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&__title-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
|
||||
.allergen-badges,
|
||||
.disliked-badges {
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
&__danger-button {
|
||||
padding: 0.5rem var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-error);
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
|
||||
button:not(.recipe-detail-panel__danger-button) {
|
||||
padding: 0.5rem var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-base);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__section {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-md);
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// The description gets its own, more generous section — the only "free
|
||||
// text" on the panel, it needs more room to breathe than a badge or a
|
||||
// short step.
|
||||
&__section--description {
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
counter-reset: step;
|
||||
|
||||
li {
|
||||
counter-increment: step;
|
||||
|
||||
&::before {
|
||||
content: counter(step) ". ";
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-base);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Favorite star toggle (detail panel header) -----------------------------
|
||||
.favorite-star-button {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
color 0.12s ease;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Overrides the icon's own `fill="none"` (a presentation attribute, lower
|
||||
// CSS priority than this rule) — solid star once favorited, outline
|
||||
// otherwise.
|
||||
&.is-favorite {
|
||||
color: var(--color-tag);
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recipe form (create/edit) ----------------------------------------------
|
||||
// Per-field validation and whole-form error messages — same small rules as
|
||||
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
|
||||
// since this page only imports recipes.scss (see global.scss's doc comment
|
||||
// on colocated, per-scope styles).
|
||||
.field-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.recipe-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 40rem;
|
||||
gap: var(--space-xs);
|
||||
|
||||
label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-top: var(--space-xl);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
&__ingredient-list {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
padding: 0.6rem var(--space-lg);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Diet tag multi-select (recipe form) -------------------------------------
|
||||
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
|
||||
// — global.scss's `label:has(> input[type="checkbox"])` rule already
|
||||
// supplies the selected/unselected look, this only arranges the rows.
|
||||
.diet-tag-select {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: var(--space-xs) 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
|
||||
legend {
|
||||
width: 100%;
|
||||
padding: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ingredient row (selected ingredient in the form) -----------------------
|
||||
.ingredient-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-base);
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__quantity {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ingredient autocomplete (recipe form + disliked-ingredients field) -----
|
||||
.ingredient-autocomplete {
|
||||
position: relative;
|
||||
|
||||
> input {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__suggestions {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-xs) 0 0;
|
||||
padding: var(--space-xs);
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step list editor -----------------------------------------------------
|
||||
.step-list-editor {
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-base);
|
||||
counter-increment: step;
|
||||
|
||||
&::before {
|
||||
content: counter(step);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-top: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__reorder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
|
||||
button {
|
||||
padding: 0.1rem 0.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
|
||||
textarea,
|
||||
input {
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-top: var(--space-sm);
|
||||
padding: 0.5rem var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Disliked-ingredients field (preferences page) ---------------------------
|
||||
// A search-and-add + removable-chips field, same idea as the recipe form's
|
||||
// ingredient picker but without quantity/unit — see
|
||||
// `features/profile/DislikedIngredientsField.tsx`.
|
||||
.disliked-ingredients-field {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: var(--space-md) 0 0;
|
||||
|
||||
legend {
|
||||
padding: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.3rem 0.2rem 0.6rem;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: var(--radius-pill);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,3 +108,23 @@ export function ChevronLeftIcon() {
|
|||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
|
||||
export function FavoriteIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
|
||||
export function PublicIcon() {
|
||||
return (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@
|
|||
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
||||
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
|
||||
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
|
||||
"NOT_RECIPE_AUTHOR": "Seul l'auteur de la recette peut faire ça",
|
||||
"NOT_FOUND": "Ressource introuvable",
|
||||
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
||||
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
||||
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
|
||||
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
|
||||
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
||||
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||
},
|
||||
"auth": {
|
||||
|
|
@ -119,7 +123,63 @@
|
|||
},
|
||||
"recipes": {
|
||||
"title": "Recettes",
|
||||
"comingSoon": "Cette section arrive bientôt."
|
||||
"searchPlaceholder": "Rechercher une recette…",
|
||||
"newButton": "Nouvelle recette",
|
||||
"loading": "Chargement…",
|
||||
"empty": "Aucune recette pour le moment.",
|
||||
"notFound": "Cette recette n'existe pas.",
|
||||
"editButton": "Modifier",
|
||||
"deleteButton": "Supprimer",
|
||||
"confirmDeleteButton": "Confirmer la suppression",
|
||||
"cancelDeleteButton": "Annuler",
|
||||
"ingredientsTitle": "Ingrédients",
|
||||
"stepsTitle": "Préparation",
|
||||
"tabs": {
|
||||
"favoris": "Favoris",
|
||||
"perso": "Perso",
|
||||
"foyer": "Foyer",
|
||||
"publique": "Publique",
|
||||
"sourcesSoon": "Sources (bientôt)",
|
||||
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"allergens": "Allergènes / intolérances",
|
||||
"diets": "Régime associé"
|
||||
},
|
||||
"detail": {
|
||||
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
||||
"favorite": "Ajouter aux favoris",
|
||||
"unfavorite": "Retirer des favoris"
|
||||
},
|
||||
"form": {
|
||||
"newTitle": "Nouvelle recette",
|
||||
"editTitle": "Modifier la recette",
|
||||
"nameLabel": "Nom de la recette",
|
||||
"descriptionLabel": "Description",
|
||||
"pictureLabel": "Photo (URL)",
|
||||
"visibilityLabel": "Visible par",
|
||||
"visibility": {
|
||||
"PERSONAL": "Moi uniquement",
|
||||
"HOUSE": "Mon foyer",
|
||||
"PUBLIC": "Tout le monde"
|
||||
},
|
||||
"dietsLabel": "Régime(s) associé(s)",
|
||||
"addIngredientPlaceholder": "Ajouter un ingrédient…",
|
||||
"quantityLabel": "Quantité",
|
||||
"unitLabel": "Unité",
|
||||
"unitPlaceholder": "g, ml, unité…",
|
||||
"removeIngredient": "Retirer cet ingrédient",
|
||||
"stepDescriptionPlaceholder": "Décrivez cette étape…",
|
||||
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
|
||||
"moveStepUp": "Monter cette étape",
|
||||
"moveStepDown": "Descendre cette étape",
|
||||
"removeStep": "Supprimer cette étape",
|
||||
"addStep": "Ajouter une étape",
|
||||
"submit": "Enregistrer",
|
||||
"submitting": "Enregistrement…",
|
||||
"genericError": "Le formulaire contient des erreurs"
|
||||
}
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Liste de courses",
|
||||
|
|
@ -147,7 +207,9 @@
|
|||
"dietLabel": "Régime alimentaire",
|
||||
"dietNone": "Aucun régime particulier",
|
||||
"allergiesLabel": "Allergies",
|
||||
"intolerancesLabel": "Intolérances"
|
||||
"intolerancesLabel": "Intolérances",
|
||||
"dislikedIngredientsLabel": "Aliments que vous n'aimez pas",
|
||||
"removeDislikedIngredient": "Retirer cet aliment"
|
||||
}
|
||||
},
|
||||
"userPreferences": {
|
||||
|
|
|
|||
275
apps/web/src/pages/RecipeFormPage.tsx
Normal file
275
apps/web/src/pages/RecipeFormPage.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import {
|
||||
type CreateRecipeInput,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
type RecipeVisibility,
|
||||
createRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../api/client";
|
||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||
import { IngredientAutocomplete } from "../features/recipes/IngredientAutocomplete";
|
||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||
import "../features/recipes/recipes.scss";
|
||||
import { errorMessageService } from "../services/error-message.service";
|
||||
|
||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||
|
||||
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. */
|
||||
interface IngredientLine {
|
||||
key: string;
|
||||
ingredient: IngredientView;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
||||
type LoadState = "loading" | "loaded" | "error";
|
||||
|
||||
/**
|
||||
* Create/edit form for one recipe — routed at `/recettes/nouvelle` and
|
||||
* `/recettes/:id/modifier`. Same component for both: edit mode is just
|
||||
* "there's an `:id` param", which also drives preloading the existing
|
||||
* recipe's fields. Saving always sends the recipe's *whole* content (name,
|
||||
* ingredients, steps) — there's no partial-field save here, matching the
|
||||
* API's `PATCH /recipes/:id` contract (see `recipe.service.ts`).
|
||||
*/
|
||||
export function RecipeFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const recipeId = id !== undefined ? Number(id) : null;
|
||||
const isEditing = recipeId !== null;
|
||||
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [picture, setPicture] = useState("");
|
||||
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadState("loading");
|
||||
|
||||
Promise.all([
|
||||
apiClient.getIngredients(),
|
||||
apiClient.getDiets(),
|
||||
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
||||
])
|
||||
.then(([ingredients, diets, recipe]) => {
|
||||
if (cancelled) return;
|
||||
setIngredientsCatalog(ingredients);
|
||||
setDietsCatalog(diets);
|
||||
if (recipe) {
|
||||
setName(recipe.name);
|
||||
setDescription(recipe.description ?? "");
|
||||
setPicture(recipe.picture ?? "");
|
||||
setVisibility(recipe.visibility);
|
||||
setDietIds(recipe.diets.map((diet) => diet.id));
|
||||
setIngredientLines(
|
||||
recipe.ingredients.map((line) => ({
|
||||
key: crypto.randomUUID(),
|
||||
ingredient: line.ingredient,
|
||||
quantity: String(line.quantity),
|
||||
unit: line.unit,
|
||||
})),
|
||||
);
|
||||
setSteps(
|
||||
recipe.steps.map((step) => ({
|
||||
key: crypto.randomUUID(),
|
||||
description: step.description,
|
||||
picture: step.picture ?? "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
setLoadState("loaded");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadState("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [recipeId]);
|
||||
|
||||
function addIngredient(ingredient: IngredientView) {
|
||||
setIngredientLines((lines) => [
|
||||
...lines,
|
||||
{ key: crypto.randomUUID(), ingredient, quantity: "", unit: "" },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateIngredientLine(
|
||||
key: string,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unit">>,
|
||||
) {
|
||||
setIngredientLines((lines) =>
|
||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeIngredientLine(key: string) {
|
||||
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
||||
}
|
||||
|
||||
// Gates the submit button — the schema (checked again on submit, see
|
||||
// `handleSubmit`) is the source of truth, this is just instant feedback
|
||||
// that doesn't need a round trip through zod on every keystroke.
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
ingredientLines.length > 0 &&
|
||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
|
||||
steps.length > 0 &&
|
||||
steps.every((step) => step.description.trim().length > 0);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const payload: CreateRecipeInput = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
picture: picture.trim() || null,
|
||||
visibility,
|
||||
dietIds,
|
||||
ingredients: ingredientLines.map((line) => ({
|
||||
ingredientId: line.ingredient.id,
|
||||
quantity: Number(line.quantity),
|
||||
unit: line.unit.trim(),
|
||||
})),
|
||||
steps: steps.map((step) => ({
|
||||
description: step.description.trim(),
|
||||
picture: step.picture.trim() || null,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = createRecipeSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
setFormError(result.error.issues[0]?.message ?? t("recipes.form.genericError"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const saved =
|
||||
recipeId !== null
|
||||
? await apiClient.updateRecipe(recipeId, result.data)
|
||||
: await apiClient.createRecipe(result.data);
|
||||
navigate(`/recettes/${saved.id}`);
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState === "loading") {
|
||||
return (
|
||||
<div className="recipe-form">
|
||||
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState === "error") {
|
||||
return (
|
||||
<div className="recipe-form">
|
||||
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||
|
||||
return (
|
||||
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||
<h1>{isEditing ? t("recipes.form.editTitle") : t("recipes.form.newTitle")}</h1>
|
||||
|
||||
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
||||
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
||||
<textarea
|
||||
id="recipe-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
||||
<input
|
||||
id="recipe-picture"
|
||||
type="url"
|
||||
value={picture}
|
||||
onChange={(e) => setPicture(e.target.value)}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
|
||||
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
||||
<select
|
||||
id="recipe-visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`recipes.form.visibility.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||
|
||||
<section className="recipe-form__section">
|
||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||
<ul className="recipe-form__ingredient-list">
|
||||
{ingredientLines.map((line) => (
|
||||
<IngredientRow
|
||||
key={line.key}
|
||||
ingredient={line.ingredient}
|
||||
quantity={line.quantity}
|
||||
unit={line.unit}
|
||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||
onUnitChange={(unit) => updateIngredientLine(line.key, { unit })}
|
||||
onRemove={() => removeIngredientLine(line.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<IngredientAutocomplete
|
||||
ingredients={ingredientsCatalog}
|
||||
excludeIds={selectedIds}
|
||||
onSelect={addIngredient}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="recipe-form__section">
|
||||
<h2>{t("recipes.stepsTitle")}</h2>
|
||||
<StepListEditor steps={steps} onChange={setSteps} />
|
||||
</section>
|
||||
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
|
||||
<div className="recipe-form__actions">
|
||||
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,174 @@
|
|||
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ComingSoonPage } from "./ComingSoonPage";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError, apiClient } from "../api/client";
|
||||
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||
import { RecipeTabs } from "../features/recipes/RecipeTabs";
|
||||
import "../features/recipes/recipes.scss";
|
||||
|
||||
/** Recipes section — routed at `/recettes`. No backend yet (see README's "Planning" section), stub for now. */
|
||||
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
|
||||
type RecipeListState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; recipes: RecipeSummaryView[] }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Recipe catalog — routed at both `/recettes` and `/recettes/:id` (the same
|
||||
* component either way, see `App.tsx`): a tab bar + table on the left stay
|
||||
* mounted at all times, only the right-hand detail panel changes with the
|
||||
* `:id` param — a master-detail layout, not a navigation to a separate
|
||||
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
|
||||
* `RecipeDetailPage`).
|
||||
*/
|
||||
export function RecipesPage() {
|
||||
const { t } = useTranslation();
|
||||
return <ComingSoonPage title={t("recipes.title")} description={t("recipes.comingSoon")} />;
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const selectedId = id !== undefined ? Number(id) : null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<RecipeTab>("publique");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
||||
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
||||
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setListState({ status: "loading" });
|
||||
|
||||
apiClient
|
||||
.listRecipes(activeTab, debouncedSearch.trim() || undefined)
|
||||
.then((recipes) => {
|
||||
if (!cancelled) setListState({ status: "loaded", recipes });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setListState({ status: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
setDetailState({ status: "empty" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setDetailState({ status: "loading" });
|
||||
|
||||
apiClient
|
||||
.getRecipe(selectedId)
|
||||
.then((recipe) => {
|
||||
if (!cancelled) setDetailState({ status: "loaded", recipe });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
|
||||
setDetailState({ status: "not-found" });
|
||||
} else {
|
||||
setDetailState({ status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId]);
|
||||
|
||||
// The viewer's personal "disliked" list only changes from the
|
||||
// preferences page, never from here — loaded once, not re-fetched on
|
||||
// every tab/selection change.
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getDislikedIngredientIds()
|
||||
.then(setDislikedIngredientIds)
|
||||
.catch(() => setDislikedIngredientIds([]));
|
||||
}, []);
|
||||
|
||||
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
|
||||
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
|
||||
setDetailState((prev) =>
|
||||
prev.status === "loaded" && prev.recipe.id === recipeId
|
||||
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
|
||||
: prev,
|
||||
);
|
||||
setListState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const recipes = prev.recipes
|
||||
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
|
||||
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
|
||||
return { status: "loaded", recipes };
|
||||
});
|
||||
}
|
||||
|
||||
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
|
||||
function handleDeleted(recipeId: number) {
|
||||
navigate("/recettes");
|
||||
setListState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipes-page">
|
||||
<div className="recipes-page__header">
|
||||
<h1>{t("recipes.title")}</h1>
|
||||
<input
|
||||
type="search"
|
||||
className="recipes-page__search"
|
||||
placeholder={t("recipes.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||
{t("recipes.newButton")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div className="recipes-page__catalog">
|
||||
{listState.status === "loading" && (
|
||||
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||
)}
|
||||
{listState.status === "error" && (
|
||||
<p className="recipes-page__status recipes-page__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
)}
|
||||
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
||||
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
||||
)}
|
||||
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
||||
<RecipeTable
|
||||
recipes={listState.recipes}
|
||||
selectedId={selectedId}
|
||||
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecipeDetailPanel
|
||||
state={detailState}
|
||||
dislikedIngredientIds={dislikedIngredientIds}
|
||||
onFavoriteToggled={handleFavoriteToggled}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { type AllergyView, type DietView, ErrorCode } from "@batch-cooking/shared";
|
||||
import {
|
||||
type AllergyView,
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
type IngredientView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApiError, apiClient } from "../../api/client";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
import { AllergySelect } from "../../features/profile/AllergySelect";
|
||||
import { DietSelect } from "../../features/profile/DietSelect";
|
||||
import { DislikedIngredientsField } from "../../features/profile/DislikedIngredientsField";
|
||||
import { errorMessageService } from "../../services/error-message.service";
|
||||
import "./settings-pages.scss";
|
||||
|
||||
|
|
@ -44,6 +50,12 @@ export function PreferencesPage() {
|
|||
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
|
||||
const allergiesTimeout = useRef<number | undefined>(undefined);
|
||||
|
||||
const [ingredients, setIngredients] = useState<IngredientView[]>([]);
|
||||
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||
const [dislikedSaveState, setDislikedSaveState] = useState<SaveState>("idle");
|
||||
const [dislikedSaveError, setDislikedSaveError] = useState<string | null>(null);
|
||||
const dislikedTimeout = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
|
||||
|
|
@ -55,15 +67,28 @@ export function PreferencesPage() {
|
|||
apiClient.getDiets(),
|
||||
apiClient.getAllergies(),
|
||||
apiClient.getAllergyIds(),
|
||||
apiClient.getIngredients(),
|
||||
apiClient.getDislikedIngredientIds(),
|
||||
apiClient.me(),
|
||||
])
|
||||
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => {
|
||||
.then(
|
||||
([
|
||||
dietsResult,
|
||||
allergiesResult,
|
||||
allergyIdsResult,
|
||||
ingredientsResult,
|
||||
dislikedResult,
|
||||
profile,
|
||||
]) => {
|
||||
if (cancelled) return;
|
||||
setDiets(dietsResult);
|
||||
setAllergies(allergiesResult);
|
||||
setAllergyIds(allergyIdsResult);
|
||||
setIngredients(ingredientsResult);
|
||||
setDislikedIngredientIds(dislikedResult);
|
||||
setDietId(profile.dietId);
|
||||
})
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadError(true);
|
||||
})
|
||||
|
|
@ -80,6 +105,7 @@ export function PreferencesPage() {
|
|||
useEffect(() => {
|
||||
return () => {
|
||||
window.clearTimeout(allergiesTimeout.current);
|
||||
window.clearTimeout(dislikedTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -116,6 +142,22 @@ export function PreferencesPage() {
|
|||
}, ALLERGIES_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleDislikedIngredientIdsChange(newIds: number[]) {
|
||||
setDislikedIngredientIds(newIds);
|
||||
window.clearTimeout(dislikedTimeout.current);
|
||||
setDislikedSaveState("saving");
|
||||
dislikedTimeout.current = window.setTimeout(async () => {
|
||||
try {
|
||||
await apiClient.updateDislikedIngredientIds(newIds);
|
||||
setDislikedSaveState("saved");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setDislikedSaveError(errorMessageService.getLabel(code));
|
||||
setDislikedSaveState("error");
|
||||
}
|
||||
}, ALLERGIES_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="settings-page">
|
||||
|
|
@ -160,6 +202,15 @@ export function PreferencesPage() {
|
|||
/>
|
||||
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
|
||||
</div>
|
||||
|
||||
<div className="settings-page__section">
|
||||
<DislikedIngredientsField
|
||||
ingredients={ingredients}
|
||||
value={dislikedIngredientIds}
|
||||
onChange={handleDislikedIngredientIdsChange}
|
||||
/>
|
||||
<SaveStatus state={dislikedSaveState} error={dislikedSaveError} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,12 @@ export enum ErrorCode {
|
|||
NOT_AUTHENTICATED = 4011,
|
||||
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
|
||||
ALREADY_HAS_HOUSE = 4020,
|
||||
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
||||
RECIPE_IN_USE = 4021,
|
||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||
NOT_HOUSE_ADMIN = 4030,
|
||||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||||
NOT_RECIPE_AUTHOR = 4031,
|
||||
/** No route/resource matches the request. */
|
||||
NOT_FOUND = 4040,
|
||||
/** The profile making the request has no household yet (`houseId` is `null`). */
|
||||
|
|
@ -48,6 +52,10 @@ export enum ErrorCode {
|
|||
ALLERGY_NOT_FOUND = 4043,
|
||||
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
|
||||
INVITE_CODE_NOT_FOUND = 4044,
|
||||
/** `GET /recipes/:id`, `PATCH /recipes/:id` or `DELETE /recipes/:id` given an id that doesn't match any recipe. */
|
||||
RECIPE_NOT_FOUND = 4045,
|
||||
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
||||
INGREDIENT_NOT_FOUND = 4046,
|
||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||
INTERNAL_ERROR = 5000,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ export * from "./schemas/household.js";
|
|||
export * from "./schemas/planning.js";
|
||||
export * from "./schemas/preferences.js";
|
||||
export * from "./schemas/profile.js";
|
||||
export * from "./schemas/recipe.js";
|
||||
export * from "./tools/assert-is-never.js";
|
||||
export * from "./types/household.js";
|
||||
export * from "./types/planning.js";
|
||||
export * from "./types/preferences.js";
|
||||
export * from "./types/recipe.js";
|
||||
export * from "./types/reference.js";
|
||||
export * from "./types/user-profile.js";
|
||||
|
|
|
|||
|
|
@ -15,3 +15,15 @@ export const updateAllergiesSchema = z.object({
|
|||
});
|
||||
/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
|
||||
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>;
|
||||
|
||||
/**
|
||||
* Payload accepted by `PATCH /profile/disliked-ingredients`. Replaces the
|
||||
* profile's full "disliked" set — a personal taste preference, not a
|
||||
* medical restriction (see `updateAllergiesSchema` above for that distinct
|
||||
* list) — an empty array clears it.
|
||||
*/
|
||||
export const updateDislikedIngredientsSchema = z.object({
|
||||
dislikedIngredientIds: z.array(z.number().int().positive()),
|
||||
});
|
||||
/** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */
|
||||
export type UpdateDislikedIngredientsInput = z.infer<typeof updateDislikedIngredientsSchema>;
|
||||
|
|
|
|||
74
packages/shared/src/schemas/recipe.ts
Normal file
74
packages/shared/src/schemas/recipe.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||
|
||||
/**
|
||||
* One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
|
||||
* `ingredientId` must reference an existing reference `Ingredient` (see
|
||||
* `GET /reference/ingredients`) — there is no way to create one from here,
|
||||
* ingredients are static reference data. An unknown id is rejected
|
||||
* service-side with `INGREDIENT_NOT_FOUND`, not here — this schema only
|
||||
* checks shape.
|
||||
*/
|
||||
const recipeIngredientInputSchema = z.object({
|
||||
ingredientId: z.number().int().positive(),
|
||||
quantity: z.number().positive("La quantité doit être positive"),
|
||||
unit: z.string().trim().min(1, "L'unité est requise").max(20),
|
||||
});
|
||||
|
||||
/**
|
||||
* One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
|
||||
* `order` is deliberately not part of this shape — it's derived server-side
|
||||
* from the step's position in the `steps` array, so the client (the
|
||||
* step reorder UI) never has to keep an explicit order field in sync.
|
||||
*/
|
||||
const recipeStepInputSchema = z.object({
|
||||
description: z.string().trim().min(1, "La description de l'étape est requise").max(2000),
|
||||
picture: z.string().trim().url("URL invalide").nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Who can read the recipe being created/edited — mirrors `RecipeVisibility`
|
||||
* in schema.prisma. Defaults to `PERSONAL` (visible to its author only) —
|
||||
* the author explicitly opens it up to `HOUSE`/`PUBLIC` if they want to
|
||||
* share it, rather than the other way around.
|
||||
*/
|
||||
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);
|
||||
|
||||
/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
|
||||
export const createRecipeSchema = z.object({
|
||||
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
picture: z.string().trim().url("URL invalide").nullable().optional(),
|
||||
visibility: recipeVisibilitySchema.default("PERSONAL"),
|
||||
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
|
||||
dietIds: z.array(z.number().int().positive()),
|
||||
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
|
||||
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
|
||||
});
|
||||
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */
|
||||
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;
|
||||
|
||||
/** `PATCH /recipes/:id` shares the exact same shape as creation — see {@link createRecipeSchema}. */
|
||||
export const updateRecipeSchema = createRecipeSchema;
|
||||
/** Inferred TS type for {@link updateRecipeSchema}'s validated output. */
|
||||
export type UpdateRecipeInput = z.infer<typeof updateRecipeSchema>;
|
||||
|
||||
/**
|
||||
* Which catalog tab `GET /recipes` should filter for — see
|
||||
* `recipe.service.ts`'s `listRecipes` for what each value actually
|
||||
* queries. No "toutes" value on purpose: every recipe visible to a viewer
|
||||
* falls under exactly one of `perso`/`foyer`/`publique` (its own
|
||||
* visibility), `favoris` is an orthogonal, cross-cutting filter on top.
|
||||
*/
|
||||
export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]);
|
||||
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
|
||||
export type RecipeTab = z.infer<typeof recipeTabSchema>;
|
||||
|
||||
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring. */
|
||||
export const listRecipesSchema = z.object({
|
||||
tab: recipeTabSchema,
|
||||
search: z.string().trim().min(1).optional(),
|
||||
});
|
||||
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
||||
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||
64
packages/shared/src/types/recipe.ts
Normal file
64
packages/shared/src/types/recipe.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { AllergyView, DietView, IngredientView } from "./reference.js";
|
||||
|
||||
/**
|
||||
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
||||
* Declared by hand (not derived from `@prisma/client`) — same reasoning as
|
||||
* `AllergenKind`: `apps/web` never depends on the Prisma client. Controls
|
||||
* only visibility, never editing — a recipe can only ever be edited/deleted
|
||||
* by its author, whatever this is set to.
|
||||
*/
|
||||
export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
|
||||
|
||||
/**
|
||||
* One ingredient line within a recipe, as returned in {@link RecipeView} —
|
||||
* the ingredient resolved to its full reference data (name, icon,
|
||||
* allergens), plus the quantity/unit specific to this recipe (carried by
|
||||
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself).
|
||||
*/
|
||||
export interface RecipeIngredientView {
|
||||
ingredient: IngredientView;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single preparation step within a recipe, in `order`. `tech_step` is
|
||||
* deliberately not surfaced here — it's tied to the (not yet built) recipe
|
||||
* import pipeline, out of scope for the manually-authored catalog.
|
||||
*/
|
||||
export interface StepView {
|
||||
id: number;
|
||||
description: string;
|
||||
picture: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recipe as it appears in the catalog table (`GET /recipes`) — enough to
|
||||
* render a row without fetching every recipe's full detail. `allergens` is
|
||||
* the union of every ingredient's allergens, deduplicated by allergy id —
|
||||
* the same aggregation `GET /recipes/:id` performs for {@link RecipeView},
|
||||
* kept consistent so the table's badges match the detail panel's.
|
||||
*
|
||||
* `isFavorite` and `diets` are resolved for the *requesting* user/recipe —
|
||||
* `isFavorite` is per-viewer (see `RecipeFavorite` in schema.prisma), while
|
||||
* `diets` is a property of the recipe itself (manually tagged by its
|
||||
* author, not viewer-specific).
|
||||
*/
|
||||
export interface RecipeSummaryView {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
picture: string | null;
|
||||
authorId: number;
|
||||
visibility: RecipeVisibility;
|
||||
allergens: AllergyView[];
|
||||
diets: DietView[];
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
/** Full recipe detail, as returned by `GET /recipes/:id`. */
|
||||
export interface RecipeView extends RecipeSummaryView {
|
||||
ingredients: RecipeIngredientView[];
|
||||
steps: StepView[];
|
||||
}
|
||||
|
|
@ -31,3 +31,23 @@ export interface AllergyView {
|
|||
name: string;
|
||||
kind: AllergenKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||
* reference-seed-data.ts`), same "static, non-administrable" status as
|
||||
* {@link DietView}/{@link AllergyView}: no create/update/delete endpoint
|
||||
* exists for it, only the seed populates it.
|
||||
*
|
||||
* `allergens` is resolved server-side from the `IngredientAllergy` join
|
||||
* table — empty for an ingredient that carries none of the 14 EU-regulated
|
||||
* allergens. Used by the recipe catalog (`apps/web`'s recipe form and detail
|
||||
* page) to pick ingredients and to surface which allergens a recipe
|
||||
* contains, aggregated across its ingredients.
|
||||
*/
|
||||
export interface IngredientView {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
allergens: AllergyView[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue