Merge pull request #19 from kyuno053/feat/recipe-catalog-v2
feat(recipes): catalogue v2 — visibilité, favoris, régimes et catalogue d'ingrédients exhaustif
This commit is contained in:
commit
c38097f522
50 changed files with 6315 additions and 46 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 houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
|
||||||
const houseId: number = houseRes.body.id;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: { name: recipeName, authorId: houseRes.body.adminId },
|
||||||
|
});
|
||||||
const planning = await prisma.planning.create({
|
const planning = await prisma.planning.create({
|
||||||
data: {
|
data: {
|
||||||
houseId,
|
houseId,
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IngredientCategory" AS ENUM ('CEREALES_FECULENTS', 'LEGUMINEUSES', 'VIANDES_VOLAILLES', 'POISSONS_FRUITS_DE_MER', 'PRODUITS_LAITIERS_OEUFS', 'LEGUMES', 'FRUITS', 'FRUITS_SECS_OLEAGINEUX', 'CONDIMENTS_SAUCES', 'EPICES_HERBES', 'SUCRE_PATISSERIE', 'CUISINE_ITALIENNE', 'CUISINE_ASIATIQUE', 'CUISINE_MEXICAINE', 'MAGHREB_MOYEN_ORIENT', 'PAINS_SANDWICHS', 'EPICERIE_DIVERS', 'LIQUIDES_BOISSONS');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ingredients" ADD COLUMN "category" "IngredientCategory" NOT NULL DEFAULT 'EPICERIE_DIVERS';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ingredient_diet" (
|
||||||
|
"ingredient_id" INTEGER NOT NULL,
|
||||||
|
"diet_id" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- Rewritten after this migration failed on a database that already had
|
||||||
|
-- seeded ingredient rows: the original version (auto-generated by `prisma
|
||||||
|
-- migrate diff`) cast every existing `category` value directly from the
|
||||||
|
-- old 18-value enum to the new 7-value one, which fails for every row
|
||||||
|
-- since none of the old values exist in the new enum. This version instead
|
||||||
|
-- adds the new columns with a safe default (never casting existing data),
|
||||||
|
-- then swaps them in — the same "add with a default, correct for real on
|
||||||
|
-- the next seed run" pattern already used for `IngredientCategory`'s and
|
||||||
|
-- `IngredientSubcategory`'s own `@default(...)` (see their doc comments in
|
||||||
|
-- schema.prisma). `seedReferenceData()` runs right after `migrate deploy`
|
||||||
|
-- on every container start (see apps/api/Dockerfile) and corrects every
|
||||||
|
-- row's real category/subcategory immediately.
|
||||||
|
--
|
||||||
|
-- `DROP TYPE IF EXISTS "IngredientSubcategory"` guards against a previous
|
||||||
|
-- failed attempt at this exact migration: that CREATE TYPE statement runs
|
||||||
|
-- outside the AlterEnum transaction below and so persists even though the
|
||||||
|
-- rest of that failed attempt rolled back — retrying without this guard
|
||||||
|
-- would hit "type already exists".
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
DROP TYPE IF EXISTS "IngredientSubcategory";
|
||||||
|
CREATE TYPE "IngredientSubcategory" AS ENUM ('LEGUMES', 'FRUITS', 'HERBES_FRAICHES', 'VIANDES', 'VOLAILLES', 'POISSONS', 'CRUSTACES_FRUITS_DE_MER', 'FECULENTS', 'LEGUMINEUSES', 'GRAINES_FRUITS_SECS', 'AUTRES', 'PAINS', 'PATES_A_CUIRE', 'PRODUITS_LAITIERS', 'OEUFS', 'ALTERNATIVES', 'EPICES', 'SAUCES', 'ASSAISONNEMENTS', 'BASES', 'EPAISSISSANTS', 'SUCRES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
DROP TYPE IF EXISTS "IngredientCategory_new";
|
||||||
|
CREATE TYPE "IngredientCategory_new" AS ENUM ('PRODUITS_FRAIS', 'BOUCHERIE_POISSONNERIE', 'EPICERIE_SECHE', 'BOULANGERIE', 'CREMERIE_FROMAGE', 'CONDIMENTS_EPICES', 'AIDES_CULINAIRES');
|
||||||
|
|
||||||
|
-- AlterTable: add the new columns at their defaults — no cast of existing
|
||||||
|
-- `category` values, so this succeeds regardless of what the table
|
||||||
|
-- currently holds.
|
||||||
|
ALTER TABLE "ingredients" ADD COLUMN "category_new" "IngredientCategory_new" NOT NULL DEFAULT 'EPICERIE_SECHE';
|
||||||
|
ALTER TABLE "ingredients" ADD COLUMN "subcategory" "IngredientSubcategory" NOT NULL DEFAULT 'AUTRES';
|
||||||
|
|
||||||
|
-- Swap the old `category` column (old 18-value enum) out for the new one.
|
||||||
|
ALTER TABLE "ingredients" DROP COLUMN "category";
|
||||||
|
ALTER TABLE "ingredients" RENAME COLUMN "category_new" TO "category";
|
||||||
|
DROP TYPE "IngredientCategory";
|
||||||
|
ALTER TYPE "IngredientCategory_new" RENAME TO "IngredientCategory";
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IngredientIcon" AS ENUM ('VEGETABLE', 'FRUIT', 'HERB', 'MEAT', 'POULTRY', 'FISH', 'SHELLFISH', 'GRAIN', 'LEGUME', 'NUT_SEED', 'BREAD', 'DOUGH', 'MILK', 'CHEESE', 'EGG', 'SPROUT', 'SPICE', 'JAR', 'BOTTLE', 'DRINK', 'STOCK_POT', 'SUGAR');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ingredients" DROP COLUMN "icon",
|
||||||
|
ADD COLUMN "icon" "IngredientIcon" NOT NULL DEFAULT 'JAR';
|
||||||
|
|
@ -24,9 +24,12 @@ model House {
|
||||||
/// member — see `house.service.ts`'s generator for the charset/length.
|
/// member — see `house.service.ts`'s generator for the charset/length.
|
||||||
inviteCode String @unique @map("invite_code")
|
inviteCode String @unique @map("invite_code")
|
||||||
|
|
||||||
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
||||||
members UserProfile[] @relation("HouseMember")
|
members UserProfile[] @relation("HouseMember")
|
||||||
plannings Planning[]
|
plannings Planning[]
|
||||||
|
/// Recipes whose author belonged to this household when they created
|
||||||
|
/// them — see `Recipe.authorHouseId`.
|
||||||
|
authoredRecipes Recipe[]
|
||||||
|
|
||||||
@@map("house")
|
@@map("house")
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +42,9 @@ model Diet {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
|
||||||
users UserProfile[]
|
users UserProfile[]
|
||||||
|
recipes RecipeDiet[]
|
||||||
|
ingredients IngredientDiet[]
|
||||||
|
|
||||||
@@map("diet")
|
@@map("diet")
|
||||||
}
|
}
|
||||||
|
|
@ -70,8 +75,9 @@ model Allergy {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
categoryId Int @map("cat_id")
|
categoryId Int @map("cat_id")
|
||||||
|
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
users UserProfileAllergy[]
|
users UserProfileAllergy[]
|
||||||
|
ingredients IngredientAllergy[]
|
||||||
|
|
||||||
@@map("allergy")
|
@@map("allergy")
|
||||||
}
|
}
|
||||||
|
|
@ -90,19 +96,43 @@ model UserProfile {
|
||||||
houseId Int? @map("house_id")
|
houseId Int? @map("house_id")
|
||||||
dietId Int? @map("diet_id")
|
dietId Int? @map("diet_id")
|
||||||
|
|
||||||
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||||
allergies UserProfileAllergy[]
|
allergies UserProfileAllergy[]
|
||||||
|
/// Ingredients this profile personally dislikes — a taste preference, not
|
||||||
|
/// a medical constraint (see {@link UserProfileDislikedIngredient} and
|
||||||
|
/// `allergies` above for the distinct medical list).
|
||||||
|
dislikedIngredients UserProfileDislikedIngredient[]
|
||||||
|
/// Recipes authored by this profile — see `Recipe.authorId`.
|
||||||
|
authoredRecipes Recipe[]
|
||||||
|
/// Recipes this profile has favorited — see {@link RecipeFavorite}.
|
||||||
|
favoriteRecipes RecipeFavorite[]
|
||||||
/// Households this profile administers. In practice at most one — a
|
/// Households this profile administers. In practice at most one — a
|
||||||
/// profile can only ever belong to (and thus admin) a single household at
|
/// profile can only ever belong to (and thus admin) a single household at
|
||||||
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
/// a time — but Prisma models the admin side of a one-to-many FK as a
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the user_profiles <-> ingredient "disliked"
|
||||||
|
/// association — same shape as `UserProfileAllergy`, but a personal taste
|
||||||
|
/// preference rather than a medical restriction: not surfaced as a safety
|
||||||
|
/// warning, just a reminder on a recipe's detail view (see
|
||||||
|
/// `RecipeView`/`RecipeDetailPanel`, apps/web).
|
||||||
|
model UserProfileDislikedIngredient {
|
||||||
|
userProfileId Int @map("user_profile_id")
|
||||||
|
ingredientId Int @map("ingredient_id")
|
||||||
|
|
||||||
|
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||||
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([userProfileId, ingredientId])
|
||||||
|
@@map("user_profile_disliked_ingredient")
|
||||||
|
}
|
||||||
|
|
||||||
/// Not in the original spec doc — personalization settings (theme for now,
|
/// Not in the original spec doc — personalization settings (theme for now,
|
||||||
/// meant to grow), one row per profile, created on demand (see
|
/// meant to grow), one row per profile, created on demand (see
|
||||||
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
/// `preferences.service.ts`) rather than at signup — same "absent means the
|
||||||
|
|
@ -184,35 +214,271 @@ model Source {
|
||||||
@@map("sources")
|
@@map("sources")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Recipe {
|
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
||||||
id Int @id @default(autoincrement())
|
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
||||||
name String
|
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
||||||
sourceId Int? @map("source_id")
|
enum RecipeVisibility {
|
||||||
description String?
|
/// Visible to its author only.
|
||||||
picture String?
|
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)
|
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
||||||
ingredients RecipeIngredient[]
|
ingredients RecipeIngredient[]
|
||||||
steps Step[]
|
steps Step[]
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
|
favoritedBy RecipeFavorite[]
|
||||||
|
diets RecipeDiet[]
|
||||||
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
||||||
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
||||||
|
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ingredient {
|
/// Explicit join table for the user_profiles <-> recipe "favorited"
|
||||||
id Int @id @default(autoincrement())
|
/// association — same shape as `UserProfileAllergy`. Per-user, not
|
||||||
name String
|
/// per-household: two members of the same household can favorite different
|
||||||
icon String?
|
/// recipes independently.
|
||||||
alternateRecipeId Int? @map("alternate_recipe")
|
model RecipeFavorite {
|
||||||
|
userProfileId Int @map("user_profile_id")
|
||||||
|
recipeId Int @map("recipe_id")
|
||||||
|
|
||||||
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([userProfileId, recipeId])
|
||||||
|
@@map("recipe_favorite")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||||
|
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||||
|
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||||
|
/// from its ingredients.
|
||||||
|
model RecipeDiet {
|
||||||
|
recipeId Int @map("recipe_id")
|
||||||
|
dietId Int @map("diet_id")
|
||||||
|
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([recipeId, dietId])
|
||||||
|
@@map("recipe_diet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name` is `@unique` — not in the original spec doc, added so the seed
|
||||||
|
/// script (reference-seed-data.ts) can `upsert` by name and stay
|
||||||
|
/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`.
|
||||||
|
/// Ingredients are reference data (like Diet/Allergy): seeded, never
|
||||||
|
/// created/edited/deleted through the API.
|
||||||
|
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
|
||||||
|
/// the ingredient picker (apps/web) can offer category browsing, not just
|
||||||
|
/// free-text search: with 400+ reference ingredients, search alone doesn't
|
||||||
|
/// scale to actually *finding* one. Reworked from an earlier, less
|
||||||
|
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
|
||||||
|
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
|
||||||
|
/// meant an ingredient's category depended on which one you thought of
|
||||||
|
/// first) into how a French grocery store is actually laid out: 7 aisles,
|
||||||
|
/// each with a couple of {@link IngredientSubcategory} racks for finer
|
||||||
|
/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors
|
||||||
|
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
|
||||||
|
/// is the single source of truth for which ingredient belongs to which
|
||||||
|
/// (category, subcategory) pair, these enums just give it type-safe
|
||||||
|
/// columns to live in. `@default(EPICERIE_SECHE)` exists only so this
|
||||||
|
/// column can be added `NOT NULL` to a table that may already have rows —
|
||||||
|
/// the seed script corrects every row's real category on the very next
|
||||||
|
/// run, this default is never the intended value for a real ingredient.
|
||||||
|
enum IngredientCategory {
|
||||||
|
/// 🥦 Légumes, fruits, herbes fraîches.
|
||||||
|
PRODUITS_FRAIS
|
||||||
|
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
|
||||||
|
BOUCHERIE_POISSONNERIE
|
||||||
|
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
|
||||||
|
/// produits secs/en conserve qui ne rentre dans aucune autre case
|
||||||
|
/// (algues séchées, champignons séchés…).
|
||||||
|
EPICERIE_SECHE
|
||||||
|
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
|
||||||
|
BOULANGERIE
|
||||||
|
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
|
||||||
|
/// tofu…).
|
||||||
|
CREMERIE_FROMAGE
|
||||||
|
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
|
||||||
|
/// cuisine…).
|
||||||
|
CONDIMENTS_EPICES
|
||||||
|
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
|
||||||
|
/// (levures, fécules, gélatine), sucres.
|
||||||
|
AIDES_CULINAIRES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
|
||||||
|
/// that enum's doc comment for why this two-level scheme replaced a flat
|
||||||
|
/// list. Each value belongs to exactly one category by construction (see
|
||||||
|
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
|
||||||
|
/// database level — Postgres enums can't express that relationship, same
|
||||||
|
/// tradeoff already accepted for `IngredientCategory` itself).
|
||||||
|
/// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as
|
||||||
|
/// `IngredientCategory`'s default, never the intended value for a real row.
|
||||||
|
enum IngredientSubcategory {
|
||||||
|
// --- Produits frais ------------------------------------------------------
|
||||||
|
LEGUMES
|
||||||
|
FRUITS
|
||||||
|
HERBES_FRAICHES
|
||||||
|
// --- Boucherie & poissonnerie ---------------------------------------------
|
||||||
|
VIANDES
|
||||||
|
VOLAILLES
|
||||||
|
POISSONS
|
||||||
|
CRUSTACES_FRUITS_DE_MER
|
||||||
|
// --- Épicerie sèche --------------------------------------------------------
|
||||||
|
FECULENTS
|
||||||
|
LEGUMINEUSES
|
||||||
|
GRAINES_FRUITS_SECS
|
||||||
|
/// Catch-all for dried/tinned pantry items that don't fit the three
|
||||||
|
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
|
||||||
|
/// shoots/water chestnuts…
|
||||||
|
AUTRES
|
||||||
|
// --- Boulangerie -------------------------------------------------------
|
||||||
|
PAINS
|
||||||
|
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
|
||||||
|
/// distinct from `PAINS` (already-baked bread).
|
||||||
|
PATES_A_CUIRE
|
||||||
|
// --- Crémerie & fromage --------------------------------------------------
|
||||||
|
PRODUITS_LAITIERS
|
||||||
|
OEUFS
|
||||||
|
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
|
||||||
|
ALTERNATIVES
|
||||||
|
// --- Condiments & épices -------------------------------------------------
|
||||||
|
EPICES
|
||||||
|
SAUCES
|
||||||
|
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
|
||||||
|
/// season rather than form the base of a dish.
|
||||||
|
ASSAISONNEMENTS
|
||||||
|
// --- Aides culinaires ----------------------------------------------------
|
||||||
|
/// Flours, stocks/broths, canned tomato bases, water — the literal base
|
||||||
|
/// a recipe is built on.
|
||||||
|
BASES
|
||||||
|
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
|
||||||
|
/// gelatin.
|
||||||
|
EPAISSISSANTS
|
||||||
|
SUCRES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic pictogram *type* for an ingredient — not in the original spec
|
||||||
|
/// doc. Started as a free-text emoji column (one character per ingredient,
|
||||||
|
/// 437 different ones), which the product decision recorded in chat
|
||||||
|
/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG
|
||||||
|
/// icons (unrealistic), ingredients share a small vocabulary of ~20
|
||||||
|
/// generic shapes grouped by *what kind of thing* they are — a vegetable,
|
||||||
|
/// a bottle of oil, a wedge of cheese — regardless of which specific
|
||||||
|
/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps
|
||||||
|
/// each value to its actual SVG (matching the app's hand-drawn line-icon
|
||||||
|
/// style, never emoji — see that file for the full reasoning and the
|
||||||
|
/// exact `reference-seed-data.ts` assignment per ingredient).
|
||||||
|
/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as
|
||||||
|
/// `IngredientCategory`'s default, never the intended value for a real row.
|
||||||
|
enum IngredientIcon {
|
||||||
|
VEGETABLE
|
||||||
|
FRUIT
|
||||||
|
HERB
|
||||||
|
MEAT
|
||||||
|
POULTRY
|
||||||
|
FISH
|
||||||
|
SHELLFISH
|
||||||
|
GRAIN
|
||||||
|
LEGUME
|
||||||
|
NUT_SEED
|
||||||
|
BREAD
|
||||||
|
DOUGH
|
||||||
|
MILK
|
||||||
|
CHEESE
|
||||||
|
EGG
|
||||||
|
SPROUT
|
||||||
|
SPICE
|
||||||
|
JAR
|
||||||
|
BOTTLE
|
||||||
|
DRINK
|
||||||
|
STOCK_POT
|
||||||
|
SUGAR
|
||||||
|
}
|
||||||
|
|
||||||
|
model Ingredient {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
icon IngredientIcon @default(JAR)
|
||||||
|
category IngredientCategory @default(EPICERIE_SECHE)
|
||||||
|
subcategory IngredientSubcategory @default(AUTRES)
|
||||||
|
alternateRecipeId Int? @map("alternate_recipe")
|
||||||
|
|
||||||
|
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
||||||
recipes RecipeIngredient[]
|
recipes RecipeIngredient[]
|
||||||
|
allergies IngredientAllergy[]
|
||||||
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||||
|
dislikedBy UserProfileDislikedIngredient[]
|
||||||
|
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||||
|
diets IngredientDiet[]
|
||||||
|
|
||||||
@@map("ingredients")
|
@@map("ingredients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the ingredients <-> diet regime association —
|
||||||
|
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
|
||||||
|
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
|
||||||
|
/// flag e.g. an ingredient as vegan without the user having to open its
|
||||||
|
/// packaging. Seeded by category in `reference-seed-data.ts` (most
|
||||||
|
/// ingredients in a category share the same compatible regimes, with
|
||||||
|
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
|
||||||
|
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
|
||||||
|
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
|
||||||
|
/// (already fully derivable from whether `IngredientAllergy` links this
|
||||||
|
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
|
||||||
|
/// for the same fact would only risk drifting out of sync with it).
|
||||||
|
model IngredientDiet {
|
||||||
|
ingredientId Int @map("ingredient_id")
|
||||||
|
dietId Int @map("diet_id")
|
||||||
|
|
||||||
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([ingredientId, dietId])
|
||||||
|
@@map("ingredient_diet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the ingredients <-> allergy association — not in
|
||||||
|
/// the original spec doc, added so the recipe catalog can surface which
|
||||||
|
/// allergens an ingredient (and by extension a recipe) carries. Same shape
|
||||||
|
/// as `UserProfileAllergy`.
|
||||||
|
model IngredientAllergy {
|
||||||
|
ingredientId Int @map("ingredient_id")
|
||||||
|
allergyId Int @map("allergy_id")
|
||||||
|
|
||||||
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
|
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([ingredientId, allergyId])
|
||||||
|
@@map("ingredient_allergy")
|
||||||
|
}
|
||||||
|
|
||||||
/// recipe <-> ingredients association. The spec documents this as a plain
|
/// recipe <-> ingredients association. The spec documents this as a plain
|
||||||
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
||||||
/// quantity per recipe, so this join table carries quantity + unit
|
/// quantity per recipe, so this join table carries quantity + unit
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
|
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
||||||
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,6 +32,7 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { AllergenKind, PrismaClient } from "@prisma/client";
|
import type {
|
||||||
|
AllergenKind,
|
||||||
|
IngredientCategory,
|
||||||
|
IngredientIcon,
|
||||||
|
IngredientSubcategory,
|
||||||
|
PrismaClient,
|
||||||
|
} from "@prisma/client";
|
||||||
|
|
||||||
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
||||||
// nullable, this is not meant to be exhaustive.
|
// nullable, this is not meant to be exhaustive.
|
||||||
|
|
@ -28,6 +34,848 @@ const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [
|
||||||
{ name: "Mollusques", kind: "ALLERGY" },
|
{ name: "Mollusques", kind: "ALLERGY" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface IngredientSeed {
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Generic pictogram type, overriding its group's `defaultIcon` below —
|
||||||
|
* only needed for the exceptions within a subcategory (a wedge of cheese
|
||||||
|
* inside an otherwise-milk "produits laitiers" group, a stockpot inside
|
||||||
|
* an otherwise-flour "bases" group…). See `IngredientIcon` in
|
||||||
|
* schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx`
|
||||||
|
* for the actual pictograms — this was a free-text emoji field until the
|
||||||
|
* product decision recorded in chat replaced it with this small, shared
|
||||||
|
* vocabulary.
|
||||||
|
*/
|
||||||
|
icon?: IngredientIcon;
|
||||||
|
allergenNames: string[];
|
||||||
|
/**
|
||||||
|
* Diet regimes this ingredient is compatible with, overriding its group's
|
||||||
|
* `defaultDiets` below — only needed for the exceptions within a
|
||||||
|
* subcategory (a fish-based stock inside an otherwise-vegan "bases"
|
||||||
|
* group, a butter-based dough inside an otherwise-vegan "pâtes à
|
||||||
|
* cuire"…). References `DIETS` by name, same as `allergenNames`
|
||||||
|
* references `ALLERGENS`. Deliberately never includes `"Omnivore"`
|
||||||
|
* (trivial, every ingredient qualifies) or `"Sans gluten"` (derived from
|
||||||
|
* `allergenNames` instead — see `IngredientDiet` in schema.prisma for
|
||||||
|
* why).
|
||||||
|
*/
|
||||||
|
dietNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broad pantry list — the goal is to cover the large majority of what a
|
||||||
|
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
|
||||||
|
// condiments, épices...), not just enough to exercise the recipe catalog in
|
||||||
|
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
|
||||||
|
// `name` is `@unique`, there's no create/update/delete endpoint), so this is
|
||||||
|
// meant to already be comprehensive at first deploy rather than grown
|
||||||
|
// piecemeal as recipes need more of it. `allergenNames` reference
|
||||||
|
// `ALLERGENS` above by name — every one of the 14 EU-regulated allergens is
|
||||||
|
// covered by at least one ingredient here.
|
||||||
|
//
|
||||||
|
// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/
|
||||||
|
// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy
|
||||||
|
// ("rayons") reworked from an earlier, less intuitive scheme that mixed
|
||||||
|
// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones
|
||||||
|
// ("légumes") — an ingredient's category used to depend on which angle you
|
||||||
|
// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses
|
||||||
|
// this to offer category browsing, not just free-text search — with 400+
|
||||||
|
// ingredients, search alone doesn't scale to actually *finding* something,
|
||||||
|
// and a single flat list of 7 aisles alone wouldn't either (some aisles
|
||||||
|
// would be 100+ items deep). Each group's key is the single source of
|
||||||
|
// truth for that mapping; `INGREDIENTS` below just flattens it back to one
|
||||||
|
// array for the seeding loop.
|
||||||
|
//
|
||||||
|
// `defaultDiets`/`defaultIcon` are what every item in the group shares
|
||||||
|
// unless it sets its own `dietNames`/`icon` — most groups are homogeneous
|
||||||
|
// on both counts (a vegetable is always vegan and always looks like a
|
||||||
|
// vegetable; a cut of meat never is and never does), so this avoids
|
||||||
|
// repeating the same values on hundreds of items; only a group's
|
||||||
|
// exceptions (a fish-based stock inside "bases", a cheese inside "produits
|
||||||
|
// laitiers"…) need a per-item override.
|
||||||
|
const INGREDIENT_GROUPS: Array<{
|
||||||
|
category: IngredientCategory;
|
||||||
|
subcategory: IngredientSubcategory;
|
||||||
|
defaultDiets: string[];
|
||||||
|
defaultIcon: IngredientIcon;
|
||||||
|
items: IngredientSeed[];
|
||||||
|
}> = [
|
||||||
|
// =========================================================================
|
||||||
|
// Produits frais
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "PRODUITS_FRAIS",
|
||||||
|
subcategory: "LEGUMES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "VEGETABLE",
|
||||||
|
items: [
|
||||||
|
{ name: "Tomate", allergenNames: [] },
|
||||||
|
{ name: "Oignon", allergenNames: [] },
|
||||||
|
{ name: "Échalote", allergenNames: [] },
|
||||||
|
{ name: "Ail", allergenNames: [] },
|
||||||
|
{ name: "Carotte", allergenNames: [] },
|
||||||
|
{ name: "Courgette", allergenNames: [] },
|
||||||
|
{ name: "Concombre", allergenNames: [] },
|
||||||
|
{ name: "Cornichons", allergenNames: [] },
|
||||||
|
{ name: "Poivron", allergenNames: [] },
|
||||||
|
{ name: "Champignon", allergenNames: [] },
|
||||||
|
{ name: "Cèpes", allergenNames: [] },
|
||||||
|
{ name: "Aubergine", allergenNames: [] },
|
||||||
|
{ name: "Brocoli", allergenNames: [] },
|
||||||
|
{ name: "Chou-fleur", allergenNames: [] },
|
||||||
|
{ name: "Chou blanc", allergenNames: [] },
|
||||||
|
{ name: "Chou rouge", allergenNames: [] },
|
||||||
|
{ name: "Chou de Bruxelles", allergenNames: [] },
|
||||||
|
{ name: "Épinard", allergenNames: [] },
|
||||||
|
{ name: "Blette", allergenNames: [] },
|
||||||
|
{ name: "Salade", allergenNames: [] },
|
||||||
|
{ name: "Roquette", allergenNames: [] },
|
||||||
|
{ name: "Cresson", allergenNames: [] },
|
||||||
|
{ name: "Poireau", allergenNames: [] },
|
||||||
|
{ name: "Céleri", allergenNames: ["Céleri"] },
|
||||||
|
{ name: "Radis", allergenNames: [] },
|
||||||
|
{ name: "Betterave", allergenNames: [] },
|
||||||
|
{ name: "Navet", allergenNames: [] },
|
||||||
|
{ name: "Panais", allergenNames: [] },
|
||||||
|
{ name: "Haricot vert", allergenNames: [] },
|
||||||
|
{ name: "Petit pois", allergenNames: [] },
|
||||||
|
{ name: "Maïs", allergenNames: [] },
|
||||||
|
{ name: "Artichaut", allergenNames: [] },
|
||||||
|
{ name: "Fenouil", allergenNames: [] },
|
||||||
|
{ name: "Endive", allergenNames: [] },
|
||||||
|
{ name: "Potiron", allergenNames: [] },
|
||||||
|
{ name: "Butternut", allergenNames: [] },
|
||||||
|
{ name: "Asperge", allergenNames: [] },
|
||||||
|
{ name: "Avocat", allergenNames: [] },
|
||||||
|
{ name: "Pomme de terre", allergenNames: [] },
|
||||||
|
{ name: "Patate douce", allergenNames: [] },
|
||||||
|
{ name: "Tomates cerises", allergenNames: [] },
|
||||||
|
{ name: "Pak-choï", allergenNames: [] },
|
||||||
|
{ name: "Germes de soja", allergenNames: ["Soja"] },
|
||||||
|
{ name: "Shiitake", allergenNames: [] },
|
||||||
|
{ name: "Daikon", allergenNames: [] },
|
||||||
|
{ name: "Piment vert frais", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "PRODUITS_FRAIS",
|
||||||
|
subcategory: "FRUITS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "FRUIT",
|
||||||
|
items: [
|
||||||
|
{ name: "Citron", allergenNames: [] },
|
||||||
|
{ name: "Citron vert", allergenNames: [] },
|
||||||
|
{ name: "Pomme", allergenNames: [] },
|
||||||
|
{ name: "Poire", allergenNames: [] },
|
||||||
|
{ name: "Banane", allergenNames: [] },
|
||||||
|
{ name: "Orange", allergenNames: [] },
|
||||||
|
{ name: "Clémentine", allergenNames: [] },
|
||||||
|
{ name: "Pamplemousse", allergenNames: [] },
|
||||||
|
{ name: "Fraise", allergenNames: [] },
|
||||||
|
{ name: "Framboise", allergenNames: [] },
|
||||||
|
{ name: "Myrtille", allergenNames: [] },
|
||||||
|
{ name: "Mûre", allergenNames: [] },
|
||||||
|
{ name: "Cerise", allergenNames: [] },
|
||||||
|
{ name: "Abricot", allergenNames: [] },
|
||||||
|
{ name: "Pêche", allergenNames: [] },
|
||||||
|
{ name: "Prune", allergenNames: [] },
|
||||||
|
{ name: "Raisin", allergenNames: [] },
|
||||||
|
{ name: "Melon", allergenNames: [] },
|
||||||
|
{ name: "Pastèque", allergenNames: [] },
|
||||||
|
{ name: "Ananas", allergenNames: [] },
|
||||||
|
{ name: "Mangue", allergenNames: [] },
|
||||||
|
{ name: "Kiwi", allergenNames: [] },
|
||||||
|
{ name: "Figue", allergenNames: [] },
|
||||||
|
{ name: "Datte", allergenNames: [] },
|
||||||
|
{ name: "Litchi", allergenNames: [] },
|
||||||
|
{ name: "Grenade", allergenNames: [] },
|
||||||
|
{ name: "Rhubarbe", allergenNames: [] },
|
||||||
|
{ name: "Coing", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "PRODUITS_FRAIS",
|
||||||
|
subcategory: "HERBES_FRAICHES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "HERB",
|
||||||
|
items: [
|
||||||
|
{ name: "Basilic", allergenNames: [] },
|
||||||
|
{ name: "Persil", allergenNames: [] },
|
||||||
|
{ name: "Thym", allergenNames: [] },
|
||||||
|
{ name: "Romarin", allergenNames: [] },
|
||||||
|
{ name: "Laurier", allergenNames: [] },
|
||||||
|
{ name: "Ciboulette", allergenNames: [] },
|
||||||
|
{ name: "Coriandre fraîche", allergenNames: [] },
|
||||||
|
{ name: "Menthe", allergenNames: [] },
|
||||||
|
{ name: "Origan", allergenNames: [] },
|
||||||
|
{ name: "Aneth", allergenNames: [] },
|
||||||
|
{ name: "Estragon", allergenNames: [] },
|
||||||
|
{ name: "Sarriette", allergenNames: [] },
|
||||||
|
{ name: "Marjolaine", allergenNames: [] },
|
||||||
|
{ name: "Sauge", allergenNames: [] },
|
||||||
|
{ name: "Cerfeuil", allergenNames: [] },
|
||||||
|
{ name: "Gingembre", allergenNames: [] },
|
||||||
|
{ name: "Citronnelle", allergenNames: [] },
|
||||||
|
{ name: "Combava", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Boucherie & poissonnerie
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "BOUCHERIE_POISSONNERIE",
|
||||||
|
subcategory: "VIANDES",
|
||||||
|
defaultDiets: [],
|
||||||
|
defaultIcon: "MEAT",
|
||||||
|
items: [
|
||||||
|
{ name: "Lapin", allergenNames: [] },
|
||||||
|
{ name: "Bœuf haché", allergenNames: [] },
|
||||||
|
{ name: "Steak de bœuf", allergenNames: [] },
|
||||||
|
{ name: "Rôti de bœuf", allergenNames: [] },
|
||||||
|
{ name: "Escalope de veau", allergenNames: [] },
|
||||||
|
{ name: "Filet mignon de porc", allergenNames: [] },
|
||||||
|
{ name: "Côte de porc", allergenNames: [] },
|
||||||
|
{ name: "Agneau", allergenNames: [] },
|
||||||
|
{ name: "Gigot d'agneau", allergenNames: [] },
|
||||||
|
{ name: "Lardons", allergenNames: [] },
|
||||||
|
{ name: "Bacon", allergenNames: [] },
|
||||||
|
{ name: "Jambon blanc", allergenNames: [] },
|
||||||
|
{ name: "Jambon cru", allergenNames: [] },
|
||||||
|
{ name: "Saucisse", allergenNames: [] },
|
||||||
|
{ name: "Chorizo", allergenNames: [] },
|
||||||
|
{ name: "Merguez", allergenNames: [] },
|
||||||
|
{ name: "Prosciutto", allergenNames: [] },
|
||||||
|
{ name: "Pancetta", allergenNames: [] },
|
||||||
|
{ name: "Mortadelle", allergenNames: [] },
|
||||||
|
{ name: "Salami", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "BOUCHERIE_POISSONNERIE",
|
||||||
|
subcategory: "VOLAILLES",
|
||||||
|
defaultDiets: [],
|
||||||
|
defaultIcon: "POULTRY",
|
||||||
|
items: [
|
||||||
|
{ name: "Poulet", allergenNames: [] },
|
||||||
|
{ name: "Dinde", allergenNames: [] },
|
||||||
|
{ name: "Canard", allergenNames: [] },
|
||||||
|
{ name: "Magret de canard", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "BOUCHERIE_POISSONNERIE",
|
||||||
|
subcategory: "POISSONS",
|
||||||
|
defaultDiets: ["Pescétarien"],
|
||||||
|
defaultIcon: "FISH",
|
||||||
|
items: [
|
||||||
|
{ name: "Saumon", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Thon", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Cabillaud", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Truite", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Sardine", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Anchois", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Merlan", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Surimi", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Bar (loup de mer)", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Dorade", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Sole", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Turbot", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Merlu", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Colin", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Lieu noir", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Églefin", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Maquereau", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Hareng", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Rouget", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Raie", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Lotte", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Flétan", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Espadon", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Carpe", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Brochet", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Perche", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Tilapia", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Panga", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Saumon fumé", allergenNames: ["Poissons"] },
|
||||||
|
{ name: "Poisson séché", allergenNames: ["Poissons"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "BOUCHERIE_POISSONNERIE",
|
||||||
|
subcategory: "CRUSTACES_FRUITS_DE_MER",
|
||||||
|
defaultDiets: ["Pescétarien"],
|
||||||
|
defaultIcon: "SHELLFISH",
|
||||||
|
items: [
|
||||||
|
{ name: "Crevettes", allergenNames: ["Crustacés"] },
|
||||||
|
{ name: "Langoustines", allergenNames: ["Crustacés"] },
|
||||||
|
{ name: "Homard", allergenNames: ["Crustacés"] },
|
||||||
|
{ name: "Crabe", allergenNames: ["Crustacés"] },
|
||||||
|
{ name: "Langouste", allergenNames: ["Crustacés"] },
|
||||||
|
{ name: "Moules", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Huîtres", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Saint-Jacques", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Calamar", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Poulpe", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Palourdes", allergenNames: ["Mollusques"] },
|
||||||
|
{ name: "Bulots", allergenNames: ["Mollusques"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Épicerie sèche
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "EPICERIE_SECHE",
|
||||||
|
subcategory: "FECULENTS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "GRAIN",
|
||||||
|
items: [
|
||||||
|
{ name: "Semoule", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Couscous", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Boulgour", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Polenta", allergenNames: [] },
|
||||||
|
{ name: "Quinoa", allergenNames: [] },
|
||||||
|
{ name: "Pâtes", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pâtes complètes", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Riz", allergenNames: [] },
|
||||||
|
{ name: "Riz basmati", allergenNames: [] },
|
||||||
|
{ name: "Riz complet", allergenNames: [] },
|
||||||
|
{ name: "Flocons d'avoine", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Spaghetti", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Penne", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Tagliatelles", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Lasagnes (feuilles)", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Gnocchi", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Riz arborio", allergenNames: [] },
|
||||||
|
{ name: "Nouilles de riz", allergenNames: [] },
|
||||||
|
{ name: "Nouilles udon", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Nouilles soba", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Nouilles chinoises", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Vermicelles de riz", allergenNames: [] },
|
||||||
|
{ name: "Vermicelles de soja", allergenNames: [] },
|
||||||
|
{ name: "Riz gluant", allergenNames: [] },
|
||||||
|
{ name: "Riz à sushi", allergenNames: [] },
|
||||||
|
{ name: "Riz jasmin", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "EPICERIE_SECHE",
|
||||||
|
subcategory: "LEGUMINEUSES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "LEGUME",
|
||||||
|
items: [
|
||||||
|
{ name: "Lentilles vertes", allergenNames: [] },
|
||||||
|
{ name: "Lentilles corail", allergenNames: [] },
|
||||||
|
{ name: "Pois chiches", allergenNames: [] },
|
||||||
|
{ name: "Haricots blancs", allergenNames: [] },
|
||||||
|
{ name: "Haricots rouges", allergenNames: [] },
|
||||||
|
{ name: "Haricots noirs", allergenNames: [] },
|
||||||
|
{ name: "Pois cassés", allergenNames: [] },
|
||||||
|
{ name: "Fèves", allergenNames: [] },
|
||||||
|
{ name: "Edamame", allergenNames: ["Soja"] },
|
||||||
|
{ name: "Haricots pinto", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "EPICERIE_SECHE",
|
||||||
|
subcategory: "GRAINES_FRUITS_SECS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "NUT_SEED",
|
||||||
|
items: [
|
||||||
|
{ name: "Cacahuètes", allergenNames: ["Arachides"] },
|
||||||
|
{ name: "Amandes", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Noix", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Noisettes", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Noix de cajou", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Pistaches", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Noix de pécan", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Poudre d'amande", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Pignons de pin", allergenNames: [] },
|
||||||
|
{ name: "Graines de tournesol", allergenNames: [] },
|
||||||
|
{ name: "Graines de courge", allergenNames: [] },
|
||||||
|
{ name: "Noix de coco râpée", allergenNames: [] },
|
||||||
|
{ name: "Raisins secs", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Pruneaux", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Abricots secs", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Graines de sésame", allergenNames: ["Graines de sésame"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "EPICERIE_SECHE",
|
||||||
|
subcategory: "AUTRES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "JAR",
|
||||||
|
items: [
|
||||||
|
{ name: "Champignons noirs", allergenNames: [] },
|
||||||
|
{ name: "Algue nori", allergenNames: [] },
|
||||||
|
{ name: "Algue wakamé", allergenNames: [] },
|
||||||
|
{ name: "Algue kombu", allergenNames: [] },
|
||||||
|
{ name: "Pousses de bambou", allergenNames: [] },
|
||||||
|
{ name: "Châtaignes d'eau", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Boulangerie
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "BOULANGERIE",
|
||||||
|
subcategory: "PAINS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "BREAD",
|
||||||
|
items: [
|
||||||
|
{ name: "Pain", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain de mie", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain complet", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Baguette", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain de seigle", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Chapelure", allergenNames: ["Gluten"] },
|
||||||
|
{
|
||||||
|
name: "Pain à burger",
|
||||||
|
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pain brioché",
|
||||||
|
allergenNames: ["Gluten", "Lait", "Œufs"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Pain à hot-dog", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain pita", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain bagel", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Naan", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain wrap", allergenNames: ["Gluten"] },
|
||||||
|
{
|
||||||
|
name: "Pain viennois",
|
||||||
|
allergenNames: ["Gluten", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Pain de campagne", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain aux céréales", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Petit pain", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain suédois", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Pain sans gluten", allergenNames: [] },
|
||||||
|
{ name: "Biscotte", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Croûtons", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Focaccia", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Ciabatta", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Tortilla de maïs", allergenNames: [] },
|
||||||
|
{ name: "Tortilla de blé", allergenNames: ["Gluten"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "BOULANGERIE",
|
||||||
|
subcategory: "PATES_A_CUIRE",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "DOUGH",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Pâte feuilletée",
|
||||||
|
allergenNames: ["Gluten", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pâte brisée",
|
||||||
|
allergenNames: ["Gluten", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Pâte à pizza", allergenNames: ["Gluten"] },
|
||||||
|
{
|
||||||
|
name: "Pâte à tarte sablée",
|
||||||
|
allergenNames: ["Gluten", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Crémerie & fromage
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "CREMERIE_FROMAGE",
|
||||||
|
subcategory: "PRODUITS_LAITIERS",
|
||||||
|
defaultDiets: ["Végétarien", "Pescétarien"],
|
||||||
|
defaultIcon: "MILK",
|
||||||
|
items: [
|
||||||
|
{ name: "Lait", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Beurre", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Crème fraîche", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Crème liquide", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Fromage", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Emmental", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Gruyère", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Parmesan", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Mozzarella", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Chèvre (fromage)", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Feta", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Comté", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Fromage blanc", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Mascarpone", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Yaourt", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Burrata", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Ricotta", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Pecorino", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Gorgonzola", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
{ name: "Cheddar", icon: "CHEESE", allergenNames: ["Lait"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "CREMERIE_FROMAGE",
|
||||||
|
subcategory: "OEUFS",
|
||||||
|
defaultDiets: ["Végétarien", "Pescétarien"],
|
||||||
|
defaultIcon: "EGG",
|
||||||
|
items: [{ name: "Œuf", allergenNames: ["Œufs"] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "CREMERIE_FROMAGE",
|
||||||
|
subcategory: "ALTERNATIVES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "SPROUT",
|
||||||
|
items: [
|
||||||
|
{ name: "Lait de coco", icon: "MILK", allergenNames: [] },
|
||||||
|
{ name: "Crème de coco", icon: "MILK", allergenNames: [] },
|
||||||
|
{ name: "Lait d'amande", icon: "MILK", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Lait d'avoine", icon: "MILK", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Tofu", allergenNames: ["Soja"] },
|
||||||
|
{ name: "Tofu soyeux", allergenNames: ["Soja"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Condiments & épices
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "CONDIMENTS_EPICES",
|
||||||
|
subcategory: "EPICES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "SPICE",
|
||||||
|
items: [
|
||||||
|
{ name: "Herbes de Provence", allergenNames: [] },
|
||||||
|
{ name: "Poivre noir", allergenNames: [] },
|
||||||
|
{ name: "Paprika", allergenNames: [] },
|
||||||
|
{ name: "Piment d'Espelette", allergenNames: [] },
|
||||||
|
{ name: "Piment de Cayenne", allergenNames: [] },
|
||||||
|
{ name: "Cumin", allergenNames: [] },
|
||||||
|
{ name: "Curry (poudre)", allergenNames: [] },
|
||||||
|
{ name: "Curcuma", allergenNames: [] },
|
||||||
|
{ name: "Cannelle", allergenNames: [] },
|
||||||
|
{ name: "Muscade", allergenNames: [] },
|
||||||
|
{ name: "Safran", allergenNames: [] },
|
||||||
|
{ name: "Clou de girofle", allergenNames: [] },
|
||||||
|
{ name: "Vanille (gousse)", allergenNames: [] },
|
||||||
|
{ name: "Poivre blanc", allergenNames: [] },
|
||||||
|
{ name: "Poivre rose", allergenNames: [] },
|
||||||
|
{ name: "Poivre du Sichuan", allergenNames: [] },
|
||||||
|
{ name: "Paprika fumé", allergenNames: [] },
|
||||||
|
{ name: "Piment oiseau", allergenNames: [] },
|
||||||
|
{ name: "Baies de genièvre", allergenNames: [] },
|
||||||
|
{ name: "Anis étoilé (badiane)", allergenNames: [] },
|
||||||
|
{ name: "Anis vert", allergenNames: [] },
|
||||||
|
{ name: "Graines de fenouil", allergenNames: [] },
|
||||||
|
{ name: "Sumac", allergenNames: [] },
|
||||||
|
{ name: "Nigelle", allergenNames: [] },
|
||||||
|
{ name: "Quatre épices", allergenNames: [] },
|
||||||
|
{ name: "Colombo (poudre)", allergenNames: [] },
|
||||||
|
{ name: "Baharat", allergenNames: [] },
|
||||||
|
{ name: "Raifort", allergenNames: [] },
|
||||||
|
{ name: "Sel aux herbes", allergenNames: [] },
|
||||||
|
{ name: "Sel de céleri", allergenNames: ["Céleri"] },
|
||||||
|
{ name: "Fleur de sel", allergenNames: [] },
|
||||||
|
{ name: "Sel", allergenNames: [] },
|
||||||
|
{ name: "Cinq épices", allergenNames: [] },
|
||||||
|
{ name: "Garam masala", allergenNames: [] },
|
||||||
|
{ name: "Graines de coriandre", allergenNames: [] },
|
||||||
|
{ name: "Cardamome", allergenNames: [] },
|
||||||
|
{ name: "Fenugrec", allergenNames: [] },
|
||||||
|
{ name: "Piment jalapeño", allergenNames: [] },
|
||||||
|
{ name: "Piment chipotle", allergenNames: [] },
|
||||||
|
{ name: "Piment poblano", allergenNames: [] },
|
||||||
|
{ name: "Piment habanero", allergenNames: [] },
|
||||||
|
{ name: "Ras el hanout", allergenNames: [] },
|
||||||
|
{ name: "Za'atar", allergenNames: ["Graines de sésame"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "CONDIMENTS_EPICES",
|
||||||
|
subcategory: "SAUCES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "JAR",
|
||||||
|
items: [
|
||||||
|
{ name: "Sauce soja", allergenNames: ["Soja"] },
|
||||||
|
{ name: "Moutarde", allergenNames: ["Moutarde"] },
|
||||||
|
{
|
||||||
|
name: "Mayonnaise",
|
||||||
|
allergenNames: ["Œufs"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Ketchup", allergenNames: [] },
|
||||||
|
{ name: "Tabasco", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Sauce Worcestershire",
|
||||||
|
allergenNames: ["Poissons"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce nuoc-mâm",
|
||||||
|
allergenNames: ["Poissons"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Wasabi", allergenNames: [] },
|
||||||
|
{ name: "Harissa", allergenNames: [] },
|
||||||
|
{ name: "Pâte de curry", allergenNames: [] },
|
||||||
|
{ name: "Beurre de cacahuète", allergenNames: ["Arachides"] },
|
||||||
|
{ name: "Moutarde de Dijon", allergenNames: ["Moutarde"] },
|
||||||
|
{ name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] },
|
||||||
|
{ name: "Sauce barbecue", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Sauce tartare",
|
||||||
|
allergenNames: ["Œufs"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce cocktail",
|
||||||
|
allergenNames: ["Œufs"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce béarnaise",
|
||||||
|
allergenNames: ["Œufs", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce hollandaise",
|
||||||
|
allergenNames: ["Œufs", "Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce béchamel",
|
||||||
|
allergenNames: ["Lait", "Gluten"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Sauce teriyaki", allergenNames: ["Soja"] },
|
||||||
|
{
|
||||||
|
name: "Sauce ponzu",
|
||||||
|
allergenNames: ["Soja", "Poissons"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Chimichurri", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Pesto rouge (tomates séchées)",
|
||||||
|
allergenNames: ["Lait", "Fruits à coque"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pesto",
|
||||||
|
allergenNames: ["Lait", "Fruits à coque"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sauce huître",
|
||||||
|
allergenNames: ["Mollusques"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Sauce hoisin", allergenNames: ["Soja"] },
|
||||||
|
{ name: "Sauce sriracha", allergenNames: [] },
|
||||||
|
{ name: "Sauce sweet chili", allergenNames: [] },
|
||||||
|
{ name: "Miso", allergenNames: ["Soja"] },
|
||||||
|
{
|
||||||
|
name: "Pâte de crevettes",
|
||||||
|
allergenNames: ["Crustacés"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Pâte de curry rouge (thaï)", allergenNames: [] },
|
||||||
|
{ name: "Pâte de curry vert (thaï)", allergenNames: [] },
|
||||||
|
{ name: "Tahini", allergenNames: ["Graines de sésame"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "CONDIMENTS_EPICES",
|
||||||
|
subcategory: "ASSAISONNEMENTS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "BOTTLE",
|
||||||
|
items: [
|
||||||
|
{ name: "Huile d'olive", allergenNames: [] },
|
||||||
|
{ name: "Huile de tournesol", allergenNames: [] },
|
||||||
|
{ name: "Huile de colza", allergenNames: [] },
|
||||||
|
{ name: "Huile de coco", allergenNames: [] },
|
||||||
|
{ name: "Huile de sésame", allergenNames: ["Graines de sésame"] },
|
||||||
|
{ name: "Vinaigre de cidre", allergenNames: [] },
|
||||||
|
{ name: "Vinaigre blanc", allergenNames: [] },
|
||||||
|
{ name: "Vinaigre balsamique", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Câpres", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Olives", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Vin blanc (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Vin rouge (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Vinaigre de vin rouge", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Vinaigre de vin blanc", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Vinaigre de xérès", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Huile de noix", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Huile de noisette", allergenNames: ["Fruits à coque"] },
|
||||||
|
{ name: "Huile d'arachide", allergenNames: ["Arachides"] },
|
||||||
|
{ name: "Huile pimentée", allergenNames: [] },
|
||||||
|
{ name: "Vinaigre de riz", allergenNames: [] },
|
||||||
|
{ name: "Mirin", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Saké (cuisine)", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de citron", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de citron vert", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus d'orange", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de pomme", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de raisin", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de tomate", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Jus de cranberry", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Café", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Thé", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Bière (cuisine)", icon: "DRINK", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Cidre (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
|
||||||
|
{
|
||||||
|
name: "Champagne / vin pétillant (cuisine)",
|
||||||
|
icon: "DRINK",
|
||||||
|
allergenNames: ["Sulfites"],
|
||||||
|
},
|
||||||
|
{ name: "Porto (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Vin jaune (cuisine)", icon: "DRINK", allergenNames: ["Sulfites"] },
|
||||||
|
{ name: "Cognac", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Rhum", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Whisky", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Vodka", icon: "DRINK", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// =========================================================================
|
||||||
|
// Aides culinaires
|
||||||
|
// =========================================================================
|
||||||
|
{
|
||||||
|
category: "AIDES_CULINAIRES",
|
||||||
|
subcategory: "BASES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "GRAIN",
|
||||||
|
items: [
|
||||||
|
{ name: "Farine de blé", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Farine complète", allergenNames: ["Gluten"] },
|
||||||
|
{ name: "Farine de maïs", allergenNames: [] },
|
||||||
|
{ name: "Farine de sarrasin", allergenNames: [] },
|
||||||
|
{ name: "Farine de riz", allergenNames: [] },
|
||||||
|
{ name: "Bouillon cube légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] },
|
||||||
|
{
|
||||||
|
name: "Bouillon cube volaille",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Céleri"],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
|
{ name: "Concentré de tomate", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Coulis de tomate", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Tomates séchées", icon: "JAR", allergenNames: [] },
|
||||||
|
{ name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
|
||||||
|
{ name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] },
|
||||||
|
{
|
||||||
|
name: "Bouillon cube bœuf",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Céleri"],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bouillon cube poisson",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Poissons", "Céleri"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] },
|
||||||
|
{
|
||||||
|
name: "Bouillon de volaille",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Céleri"],
|
||||||
|
dietNames: [],
|
||||||
|
},
|
||||||
|
{ name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] },
|
||||||
|
{ name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Dashi (bouillon japonais)",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Poissons"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bisque de crustacés",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Crustacés"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Farine de tapioca", allergenNames: [] },
|
||||||
|
{ name: "Masa harina", allergenNames: [] },
|
||||||
|
{ name: "Eau", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Eau gazeuse", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Eau de fleur d'oranger", icon: "DRINK", allergenNames: [] },
|
||||||
|
{ name: "Eau de rose", icon: "DRINK", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Fumet de poisson",
|
||||||
|
icon: "STOCK_POT",
|
||||||
|
allergenNames: ["Poissons"],
|
||||||
|
dietNames: ["Pescétarien"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "AIDES_CULINAIRES",
|
||||||
|
subcategory: "EPAISSISSANTS",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "JAR",
|
||||||
|
items: [
|
||||||
|
{ name: "Levure boulangère", allergenNames: [] },
|
||||||
|
{ name: "Levure chimique", allergenNames: [] },
|
||||||
|
{ name: "Maïzena", allergenNames: [] },
|
||||||
|
{ name: "Farine de lupin", allergenNames: ["Lupin"] },
|
||||||
|
// Animal collagen (bones/skin, usually pork or beef) — not
|
||||||
|
// vegetarian/vegan, and not reliably fish-derived either, so no
|
||||||
|
// pescetarian flag.
|
||||||
|
{ name: "Gélatine", allergenNames: [], dietNames: [] },
|
||||||
|
{ name: "Bicarbonate de soude", allergenNames: [] },
|
||||||
|
{ name: "Fécule de pomme de terre", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "AIDES_CULINAIRES",
|
||||||
|
subcategory: "SUCRES",
|
||||||
|
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
|
||||||
|
defaultIcon: "SUGAR",
|
||||||
|
items: [
|
||||||
|
{ name: "Sucre", allergenNames: [] },
|
||||||
|
{ name: "Miel", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] },
|
||||||
|
{ name: "Sirop d'érable", allergenNames: [] },
|
||||||
|
{ name: "Sucre roux", allergenNames: [] },
|
||||||
|
{ name: "Sucre glace", allergenNames: [] },
|
||||||
|
{ name: "Cassonade", allergenNames: [] },
|
||||||
|
{ name: "Chocolat noir", allergenNames: [] },
|
||||||
|
{
|
||||||
|
name: "Chocolat au lait",
|
||||||
|
allergenNames: ["Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Chocolat blanc",
|
||||||
|
allergenNames: ["Lait"],
|
||||||
|
dietNames: ["Végétarien", "Pescétarien"],
|
||||||
|
},
|
||||||
|
{ name: "Pépites de chocolat", allergenNames: [] },
|
||||||
|
{ name: "Cacao en poudre", allergenNames: [] },
|
||||||
|
{ name: "Extrait de vanille", allergenNames: [] },
|
||||||
|
{ name: "Sucre de palme", allergenNames: [] },
|
||||||
|
{ name: "Sirop de sucre de canne", allergenNames: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const INGREDIENTS: Array<
|
||||||
|
Omit<IngredientSeed, "icon" | "dietNames"> & {
|
||||||
|
category: IngredientCategory;
|
||||||
|
subcategory: IngredientSubcategory;
|
||||||
|
icon: IngredientIcon;
|
||||||
|
dietNames: string[];
|
||||||
|
}
|
||||||
|
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
|
||||||
|
items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
icon: item.icon ?? defaultIcon,
|
||||||
|
dietNames: item.dietNames ?? defaultDiets,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
||||||
* (safe to call against a database that already has this data — upserts by
|
* (safe to call against a database that already has this data — upserts by
|
||||||
|
|
@ -58,4 +906,90 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.allergy.create({ data: { categoryId: category.id } });
|
await prisma.allergy.create({ data: { categoryId: category.id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
|
||||||
|
// hundred entries long, and `seedReferenceData` re-runs on every single
|
||||||
|
// test's `resetDatabase()` — a per-row round trip made the whole suite
|
||||||
|
// measurably slower). Bulk-create whatever's missing in one query, then
|
||||||
|
// reconcile `icon`/`category`/`subcategory` only for the rows where any
|
||||||
|
// of them actually changed — on a freshly-truncated table (the common
|
||||||
|
// test-suite case) that's zero updates, on a real re-deploy it's however
|
||||||
|
// many rows were edited in code since the last deploy, never the full
|
||||||
|
// list.
|
||||||
|
const existingIngredients = await prisma.ingredient.findMany({
|
||||||
|
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
||||||
|
select: { id: true, name: true, icon: true, category: true, subcategory: true },
|
||||||
|
});
|
||||||
|
const existingByName = new Map(existingIngredients.map((i) => [i.name, i]));
|
||||||
|
|
||||||
|
const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name));
|
||||||
|
if (missingIngredients.length > 0) {
|
||||||
|
await prisma.ingredient.createMany({
|
||||||
|
data: missingIngredients.map(({ name, icon, category, subcategory }) => ({
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = INGREDIENTS.filter((i) => {
|
||||||
|
const existing = existingByName.get(i.name);
|
||||||
|
return (
|
||||||
|
existing &&
|
||||||
|
(existing.icon !== i.icon ||
|
||||||
|
existing.category !== i.category ||
|
||||||
|
existing.subcategory !== i.subcategory)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const { name, icon, category, subcategory } of changed) {
|
||||||
|
await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve every ingredient's id (existing + just-created) and every
|
||||||
|
// allergy's id (by its category name) once, then link them in a single
|
||||||
|
// bulk insert — same "re-derived every time, not upserted per link"
|
||||||
|
// reasoning as before for `IngredientAllergy` (it has no natural per-row
|
||||||
|
// identity to upsert against), just batched instead of looped.
|
||||||
|
const allIngredients = await prisma.ingredient.findMany({
|
||||||
|
where: { name: { in: INGREDIENTS.map((i) => i.name) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id]));
|
||||||
|
|
||||||
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
||||||
|
const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id]));
|
||||||
|
|
||||||
|
const links: Array<{ ingredientId: number; allergyId: number }> = [];
|
||||||
|
for (const { name, allergenNames } of INGREDIENTS) {
|
||||||
|
const ingredientId = ingredientIdByName.get(name);
|
||||||
|
if (ingredientId === undefined) continue;
|
||||||
|
for (const allergenName of allergenNames) {
|
||||||
|
const allergyId = allergyIdByCategoryName.get(allergenName);
|
||||||
|
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (links.length > 0) {
|
||||||
|
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same bulk-insert approach as the allergy links above, resolved against
|
||||||
|
// `dietNames` (item override, falling back to its group's `defaultDiets`
|
||||||
|
// in the `INGREDIENTS` flatten step) instead of `allergenNames`.
|
||||||
|
const diets = await prisma.diet.findMany();
|
||||||
|
const dietIdByName = new Map(diets.map((d) => [d.name, d.id]));
|
||||||
|
|
||||||
|
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
|
||||||
|
for (const { name, dietNames } of INGREDIENTS) {
|
||||||
|
const ingredientId = ingredientIdByName.get(name);
|
||||||
|
if (ingredientId === undefined) continue;
|
||||||
|
for (const dietName of dietNames) {
|
||||||
|
const dietId = dietIdByName.get(dietName);
|
||||||
|
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dietLinks.length > 0) {
|
||||||
|
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { updateAllergiesSchema, updateDietSchema } from "@batch-cooking/shared";
|
import {
|
||||||
|
updateAllergiesSchema,
|
||||||
|
updateDietSchema,
|
||||||
|
updateDislikedIngredientsSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { getAllergyIds, updateAllergies, updateDiet } from "./profile.service.js";
|
import {
|
||||||
|
getAllergyIds,
|
||||||
|
getDislikedIngredientIds,
|
||||||
|
updateAllergies,
|
||||||
|
updateDiet,
|
||||||
|
updateDislikedIngredients,
|
||||||
|
} from "./profile.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
|
||||||
export const profileRouter = Router();
|
export const profileRouter = Router();
|
||||||
|
|
@ -37,3 +47,26 @@ profileRouter.patch(
|
||||||
res.status(200).json(allergyIds);
|
res.status(200).json(allergyIds);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
profileRouter.get(
|
||||||
|
"/disliked-ingredients",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||||
|
const ids = await getDislikedIngredientIds(res.locals.userProfile.id);
|
||||||
|
res.status(200).json(ids);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Personal taste preference — distinct from `/allergies`, which is medical. Managed from `/parametres/preferences` (see `PreferencesPage.tsx`). */
|
||||||
|
profileRouter.patch(
|
||||||
|
"/disliked-ingredients",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = updateDislikedIngredientsSchema.parse(req.body);
|
||||||
|
const ids = await updateDislikedIngredients(
|
||||||
|
res.locals.userProfile.id,
|
||||||
|
input.dislikedIngredientIds,
|
||||||
|
);
|
||||||
|
res.status(200).json(ids);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,49 @@ export async function updateAllergies(
|
||||||
|
|
||||||
return allergyIds;
|
return allergyIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
|
||||||
|
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||||
|
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||||
|
where: { userProfileId },
|
||||||
|
select: { ingredientId: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.ingredientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a profile's full disliked-ingredient set (not a merge — the
|
||||||
|
* caller sends the complete list every time, same contract as
|
||||||
|
* {@link updateAllergies}).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference `Ingredient` row.
|
||||||
|
*/
|
||||||
|
export async function updateDislikedIngredients(
|
||||||
|
userProfileId: number,
|
||||||
|
dislikedIngredientIds: number[],
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (dislikedIngredientIds.length > 0) {
|
||||||
|
const found = await prisma.ingredient.findMany({
|
||||||
|
where: { id: { in: dislikedIngredientIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||||
|
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||||
|
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
|
||||||
|
prisma.userProfileDislikedIngredient.createMany({
|
||||||
|
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return dislikedIngredientIds;
|
||||||
|
}
|
||||||
|
|
|
||||||
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();
|
||||||
|
}),
|
||||||
|
);
|
||||||
421
apps/api/src/modules/recipe/recipe.service.ts
Normal file
421
apps/api/src/modules/recipe/recipe.service.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import {
|
||||||
|
type AllergyView,
|
||||||
|
type CreateRecipeInput,
|
||||||
|
type DietView,
|
||||||
|
ErrorCode,
|
||||||
|
type IngredientView,
|
||||||
|
type RecipeSummaryView,
|
||||||
|
type RecipeTab,
|
||||||
|
type RecipeView,
|
||||||
|
type UpdateRecipeInput,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
|
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
||||||
|
function recipeInclude(viewerId: number) {
|
||||||
|
return {
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: { orderBy: { order: "asc" } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
favoritedBy: { where: { userProfileId: viewerId } },
|
||||||
|
} satisfies Prisma.RecipeInclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||||
|
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
|
|
||||||
|
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||||
|
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
|
return {
|
||||||
|
id: ingredient.id,
|
||||||
|
name: ingredient.name,
|
||||||
|
icon: ingredient.icon,
|
||||||
|
category: ingredient.category,
|
||||||
|
subcategory: ingredient.subcategory,
|
||||||
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
|
id: allergy.id,
|
||||||
|
name: allergy.category.name,
|
||||||
|
kind: allergy.category.kind,
|
||||||
|
})),
|
||||||
|
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDietView(diet: { id: number; name: string }): DietView {
|
||||||
|
return { id: diet.id, name: diet.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
|
||||||
|
function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
|
||||||
|
const byId = new Map<number, AllergyView>();
|
||||||
|
for (const ingredient of ingredients) {
|
||||||
|
for (const allergen of ingredient.allergens) {
|
||||||
|
byId.set(allergen.id, allergen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
|
||||||
|
function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
|
const allergens = aggregateAllergens(
|
||||||
|
recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: recipe.id,
|
||||||
|
name: recipe.name,
|
||||||
|
description: recipe.description,
|
||||||
|
picture: recipe.picture,
|
||||||
|
authorId: recipe.authorId,
|
||||||
|
visibility: recipe.visibility,
|
||||||
|
allergens,
|
||||||
|
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
||||||
|
isFavorite: recipe.favoritedBy.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
|
||||||
|
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||||
|
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||||
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
|
quantity: Number(recipeIngredient.quantity),
|
||||||
|
unit: recipeIngredient.unit,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...toRecipeSummaryView(recipe),
|
||||||
|
ingredients,
|
||||||
|
steps: recipe.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture,
|
||||||
|
order: step.order,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `viewerId`/`viewerHouseId` may *read* this recipe — the author
|
||||||
|
* always can, whatever the current visibility (even a `HOUSE` recipe if
|
||||||
|
* they've since left that household — access to your own creations never
|
||||||
|
* regresses). Otherwise follows `visibility` as documented on
|
||||||
|
* `RecipeVisibility` in schema.prisma.
|
||||||
|
*/
|
||||||
|
function canView(
|
||||||
|
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): boolean {
|
||||||
|
if (recipe.authorId === viewerId) return true;
|
||||||
|
if (recipe.visibility === "PUBLIC") return true;
|
||||||
|
if (recipe.visibility === "HOUSE") {
|
||||||
|
return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */
|
||||||
|
function visibleToViewerWhere(
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Prisma.RecipeWhereInput {
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ authorId: viewerId },
|
||||||
|
{ visibility: "PUBLIC" },
|
||||||
|
...(viewerHouseId !== null
|
||||||
|
? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipes visible to `viewerId` under one catalog tab, alphabetically,
|
||||||
|
* optionally filtered further by a case-insensitive name substring. No
|
||||||
|
* "toutes" tab — every recipe a viewer can see falls under exactly one of
|
||||||
|
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
|
||||||
|
* orthogonal, cross-cutting filter on top (and re-applies
|
||||||
|
* {@link visibleToViewerWhere} in case access to a previously-favorited
|
||||||
|
* recipe has since changed, e.g. leaving the house that granted it).
|
||||||
|
*/
|
||||||
|
export async function listRecipes(
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
tab: RecipeTab,
|
||||||
|
search?: string,
|
||||||
|
): Promise<RecipeSummaryView[]> {
|
||||||
|
const conditions: Prisma.RecipeWhereInput[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case "favoris":
|
||||||
|
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||||
|
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||||
|
break;
|
||||||
|
case "perso":
|
||||||
|
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||||
|
break;
|
||||||
|
case "foyer":
|
||||||
|
// No household — nothing can carry this viewer's authorHouseId.
|
||||||
|
if (viewerHouseId === null) return [];
|
||||||
|
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||||
|
break;
|
||||||
|
case "publique":
|
||||||
|
conditions.push({ visibility: "PUBLIC" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipes = await prisma.recipe.findMany({
|
||||||
|
where: { AND: conditions },
|
||||||
|
include: recipeInclude(viewerId),
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
return recipes.map(toRecipeSummaryView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single recipe's full detail.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one).
|
||||||
|
*/
|
||||||
|
export async function getRecipe(
|
||||||
|
id: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
|
}
|
||||||
|
return toRecipeView(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a recipe with its ingredients, ordered steps and diet tags in one
|
||||||
|
* go — steps' `order` is derived from their position in `input.steps`,
|
||||||
|
* ingredients reference existing reference `Ingredient` rows by id (see
|
||||||
|
* `GET /reference/ingredients`; there's no way to create one here).
|
||||||
|
* `authorId`/`authorHouseId` are fixed at creation and never change on
|
||||||
|
* later edits (see {@link updateRecipe}).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
|
*/
|
||||||
|
export async function createRecipe(
|
||||||
|
input: CreateRecipeInput,
|
||||||
|
authorId: number,
|
||||||
|
authorHouseId: number | null,
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
|
await assertDietsExist(input.dietIds);
|
||||||
|
|
||||||
|
const created = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
picture: input.picture ?? null,
|
||||||
|
authorId,
|
||||||
|
authorHouseId,
|
||||||
|
visibility: input.visibility,
|
||||||
|
ingredients: {
|
||||||
|
create: input.ingredients.map((ingredient) => ({
|
||||||
|
ingredientId: ingredient.ingredientId,
|
||||||
|
quantity: ingredient.quantity,
|
||||||
|
unit: ingredient.unit,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
create: input.steps.map((step, index) => ({
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? null,
|
||||||
|
order: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
},
|
||||||
|
include: recipeInclude(authorId),
|
||||||
|
});
|
||||||
|
return toRecipeView(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||||
|
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||||
|
* missing from `input` is removed, same contract as `PATCH
|
||||||
|
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||||
|
* never transfers ownership.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
|
*/
|
||||||
|
export async function updateRecipe(
|
||||||
|
id: number,
|
||||||
|
input: UpdateRecipeInput,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||||
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
|
await assertDietsExist(input.dietIds);
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.step.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.recipe.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
picture: input.picture ?? null,
|
||||||
|
visibility: input.visibility,
|
||||||
|
ingredients: {
|
||||||
|
create: input.ingredients.map((ingredient) => ({
|
||||||
|
ingredientId: ingredient.ingredientId,
|
||||||
|
quantity: ingredient.quantity,
|
||||||
|
unit: ingredient.unit,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
create: input.steps.map((step, index) => ({
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? null,
|
||||||
|
order: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a recipe outright — its ingredients/steps/diet tags/favorites
|
||||||
|
* cascade away (see `onDelete: Cascade` in schema.prisma).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
|
* @throws {HttpError} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation.
|
||||||
|
*/
|
||||||
|
export async function deleteRecipe(
|
||||||
|
id: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||||
|
|
||||||
|
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
|
||||||
|
if (usedInPlanning) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
ErrorCode.RECIPE_IN_USE,
|
||||||
|
"Recipe is still used by at least one planning item",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipe.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favorites a recipe for `viewerId` — idempotent (favoriting an
|
||||||
|
* already-favorited recipe is a no-op, not an error).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action.
|
||||||
|
*/
|
||||||
|
export async function addFavorite(
|
||||||
|
id: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
|
}
|
||||||
|
await prisma.recipeFavorite.upsert({
|
||||||
|
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
|
||||||
|
update: {},
|
||||||
|
create: { userProfileId: viewerId, recipeId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
|
||||||
|
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
|
||||||
|
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
|
||||||
|
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||||
|
const recipe = await prisma.recipe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: recipeInclude(viewerId),
|
||||||
|
});
|
||||||
|
if (!recipe) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
|
}
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
||||||
|
async function assertIsAuthor(
|
||||||
|
id: number,
|
||||||
|
viewerId: number,
|
||||||
|
viewerHouseId: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
|
}
|
||||||
|
if (recipe.authorId !== viewerId) {
|
||||||
|
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
|
||||||
|
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||||
|
const uniqueIds = [...new Set(ingredientIds)];
|
||||||
|
const found = await prisma.ingredient.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== uniqueIds.length) {
|
||||||
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||||
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||||
|
`Ingredient(s) not found: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||||
|
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||||
|
const uniqueIds = [...new Set(dietIds)];
|
||||||
|
if (uniqueIds.length === 0) return;
|
||||||
|
const found = await prisma.diet.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== uniqueIds.length) {
|
||||||
|
const foundIds = new Set(found.map((diet) => diet.id));
|
||||||
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { getAllergies, getDiets } from "./reference.service.js";
|
import { getAllergies, getDiets, getIngredients } from "./reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/reference` in app.ts. Both routes are deliberately
|
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
||||||
* public (no `requireAuth`) — this is static reference data, not
|
* public (no `requireAuth`) — this is static reference data, not
|
||||||
* per-household state, and the signup wizard (household/regime/allergen
|
* per-household state, and the signup wizard (household/regime/allergen
|
||||||
* steps) needs to read it before an account — and therefore a session —
|
* steps) needs to read it before an account — and therefore a session —
|
||||||
* exists.
|
* exists. `/ingredients` follows the same reasoning even though it's only
|
||||||
|
* consumed post-login (the recipe catalog) — it's still non-administrable
|
||||||
|
* reference data, no reason to require a session to read it.
|
||||||
*/
|
*/
|
||||||
export const referenceRouter = Router();
|
export const referenceRouter = Router();
|
||||||
|
|
||||||
|
|
@ -24,3 +26,10 @@ referenceRouter.get(
|
||||||
res.status(200).json(await getAllergies());
|
res.status(200).json(await getAllergies());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
referenceRouter.get(
|
||||||
|
"/ingredients",
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getIngredients());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AllergyView, DietView } from "@batch-cooking/shared";
|
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
|
/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */
|
||||||
|
|
@ -23,3 +23,33 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
||||||
kind: allergy.category.kind,
|
kind: allergy.category.kind,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All reference ingredients, alphabetically, each resolved to its allergens
|
||||||
|
* (see `IngredientAllergy` in schema.prisma) and compatible diet regimes
|
||||||
|
* (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}.
|
||||||
|
* Ingredients with no linked allergen/diet come back with `allergens: []`/
|
||||||
|
* `diets: []`.
|
||||||
|
*/
|
||||||
|
export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
|
const ingredients = await prisma.ingredient.findMany({
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
return ingredients.map((ingredient) => ({
|
||||||
|
id: ingredient.id,
|
||||||
|
name: ingredient.name,
|
||||||
|
icon: ingredient.icon,
|
||||||
|
category: ingredient.category,
|
||||||
|
subcategory: ingredient.subcategory,
|
||||||
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
|
id: allergy.id,
|
||||||
|
name: allergy.category.name,
|
||||||
|
kind: allergy.category.kind,
|
||||||
|
})),
|
||||||
|
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ describe("Planning", () => {
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
const houseId: number = houseRes.body.id;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: { name: "Ratatouille", authorId: houseRes.body.adminId },
|
||||||
|
});
|
||||||
const planning = await prisma.planning.create({
|
const planning = await prisma.planning.create({
|
||||||
data: {
|
data: {
|
||||||
houseId,
|
houseId,
|
||||||
|
|
@ -145,7 +147,9 @@ describe("Planning", () => {
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
const houseId: number = houseRes.body.id;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: { name: "Curry de lentilles", authorId: houseRes.body.adminId },
|
||||||
|
});
|
||||||
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
|
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
|
||||||
const planning = await prisma.planning.create({
|
const planning = await prisma.planning.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -125,4 +125,64 @@ describe("Profile", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /profile/disliked-ingredients + PATCH /profile/disliked-ingredients", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const getRes = await request(app).get("/profile/disliked-ingredients");
|
||||||
|
const patchRes = await request(app)
|
||||||
|
.patch("/profile/disliked-ingredients")
|
||||||
|
.send({ dislikedIngredientIds: [] });
|
||||||
|
|
||||||
|
expect(getRes.status).to.equal(401);
|
||||||
|
expect(patchRes.status).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts empty, then reflects a saved selection", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } });
|
||||||
|
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } });
|
||||||
|
|
||||||
|
const initial = await agent.get("/profile/disliked-ingredients");
|
||||||
|
expect(initial.body).to.deep.equal([]);
|
||||||
|
|
||||||
|
const patchRes = await agent
|
||||||
|
.patch("/profile/disliked-ingredients")
|
||||||
|
.send({ dislikedIngredientIds: [tomate.id, oignon.id] });
|
||||||
|
expect(patchRes.status).to.equal(200);
|
||||||
|
expect(patchRes.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
|
||||||
|
|
||||||
|
const refetch = await agent.get("/profile/disliked-ingredients");
|
||||||
|
expect(refetch.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces (not merges) the previous selection", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } });
|
||||||
|
const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } });
|
||||||
|
|
||||||
|
await agent
|
||||||
|
.patch("/profile/disliked-ingredients")
|
||||||
|
.send({ dislikedIngredientIds: [tomate.id] });
|
||||||
|
await agent
|
||||||
|
.patch("/profile/disliked-ingredients")
|
||||||
|
.send({ dislikedIngredientIds: [oignon.id] });
|
||||||
|
|
||||||
|
const res = await agent.get("/profile/disliked-ingredients");
|
||||||
|
expect(res.body).to.deep.equal([oignon.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.patch("/profile/disliked-ingredients")
|
||||||
|
.send({ dislikedIngredientIds: [999_999] });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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,31 @@ describe("Reference data", () => {
|
||||||
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
|
expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /reference/ingredients", () => {
|
||||||
|
it("returns the seeded ingredients, no session required", async () => {
|
||||||
|
const res = await request(app).get("/reference/ingredients");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.length).to.be.greaterThan(0);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
|
||||||
|
expect(res.body[0]).to.have.keys([
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"icon",
|
||||||
|
"category",
|
||||||
|
"subcategory",
|
||||||
|
"allergens",
|
||||||
|
"diets",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves each ingredient's linked allergens, empty for one with none", async () => {
|
||||||
|
const res = await request(app).get("/reference/ingredients");
|
||||||
|
|
||||||
|
const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name);
|
||||||
|
expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs");
|
||||||
|
expect(byName("Tomate").allergens).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
|
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
|
||||||
|
// The page also loads the reference ingredient list + the profile's
|
||||||
|
// disliked-ingredients selection for `DislikedIngredientsField` — added
|
||||||
|
// alongside `getDiets`/`getAllergies` in the same `Promise.all` (see
|
||||||
|
// PreferencesPage.tsx), so both need mocking here too or that `Promise.all`
|
||||||
|
// rejects and the whole page renders its error state instead of the form,
|
||||||
|
// taking `#diet`/the allergy checkboxes down with it.
|
||||||
|
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
|
||||||
|
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads the current regime, and shows allergies/intolerances as two groups", () => {
|
it("loads the current regime, and shows allergies/intolerances as two groups", () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { PlanningPage } from "./pages/PlanningPage";
|
import { PlanningPage } from "./pages/PlanningPage";
|
||||||
|
import { RecipeFormPage } from "./pages/RecipeFormPage";
|
||||||
import { RecipesPage } from "./pages/RecipesPage";
|
import { RecipesPage } from "./pages/RecipesPage";
|
||||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||||
import { SignupPage } from "./pages/SignupPage";
|
import { SignupPage } from "./pages/SignupPage";
|
||||||
|
|
@ -47,7 +48,14 @@ export function App() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<PlanningPage />} />
|
<Route path="/" element={<PlanningPage />} />
|
||||||
|
{/* Same component for both — a master-detail layout, not a
|
||||||
|
navigation to a separate page: the tab bar + table stay
|
||||||
|
mounted, only RecipesPage's detail panel changes with `:id`
|
||||||
|
(see RecipesPage.tsx). */}
|
||||||
<Route path="/recettes" element={<RecipesPage />} />
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
|
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
|
||||||
|
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||||
|
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import {
|
import {
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
|
type CreateRecipeInput,
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type HouseView,
|
type HouseView,
|
||||||
|
type IngredientView,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
|
type RecipeSummaryView,
|
||||||
|
type RecipeTab,
|
||||||
|
type RecipeView,
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
|
type UpdateRecipeInput,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -132,6 +138,48 @@ export class ApiClient {
|
||||||
return this.request("/reference/allergies");
|
return this.request("/reference/allergies");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
|
||||||
|
public getIngredients(): Promise<IngredientView[]> {
|
||||||
|
return this.request("/reference/ingredients");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), optionally filtered further by a name substring. */
|
||||||
|
public listRecipes(tab: RecipeTab, search?: string): Promise<RecipeSummaryView[]> {
|
||||||
|
const params = new URLSearchParams({ tab });
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
return this.request(`/recipes?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */
|
||||||
|
public getRecipe(id: number): Promise<RecipeView> {
|
||||||
|
return this.request(`/recipes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */
|
||||||
|
public createRecipe(input: CreateRecipeInput): Promise<RecipeView> {
|
||||||
|
return this.request("/recipes", { method: "POST", body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */
|
||||||
|
public updateRecipe(id: number, input: UpdateRecipeInput): Promise<RecipeView> {
|
||||||
|
return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */
|
||||||
|
public deleteRecipe(id: number): Promise<void> {
|
||||||
|
return this.request(`/recipes/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Favorites a recipe for the current user — idempotent. */
|
||||||
|
public addFavoriteRecipe(id: number): Promise<void> {
|
||||||
|
return this.request(`/recipes/${id}/favorite`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfavorites a recipe for the current user — idempotent. */
|
||||||
|
public removeFavoriteRecipe(id: number): Promise<void> {
|
||||||
|
return this.request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
||||||
public getCurrentHouse(): Promise<HouseView | null> {
|
public getCurrentHouse(): Promise<HouseView | null> {
|
||||||
return this.request("/house/current");
|
return this.request("/house/current");
|
||||||
|
|
@ -185,6 +233,19 @@ export class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
|
||||||
|
public getDislikedIngredientIds(): Promise<number[]> {
|
||||||
|
return this.request("/profile/disliked-ingredients");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */
|
||||||
|
public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise<number[]> {
|
||||||
|
return this.request("/profile/disliked-ingredients", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ dislikedIngredientIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
/** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */
|
||||||
public getPreferences(): Promise<PreferencesView> {
|
public getPreferences(): Promise<PreferencesView> {
|
||||||
return this.request("/preferences");
|
return this.request("/preferences");
|
||||||
|
|
|
||||||
72
apps/web/src/features/profile/DislikedIngredientsField.tsx
Normal file
72
apps/web/src/features/profile/DislikedIngredientsField.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type { IngredientView } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
|
// Reuses `IngredientPicker` verbatim (built for the recipe form's
|
||||||
|
// ingredient picker, features/recipes/) rather than a second search+browse
|
||||||
|
// field — same reference ingredient list, same category/search browsing,
|
||||||
|
// just without the recipe form's quantity/unit per line. Its own
|
||||||
|
// recipes.scss import already covers `.ingredient-picker`; this file's
|
||||||
|
// explicit import below only adds `.disliked-ingredients-field__*` (see
|
||||||
|
// recipes.scss — colocated there since it's the same "reference ingredient
|
||||||
|
// picker" visual family, even though this field lives in the profile
|
||||||
|
// feature).
|
||||||
|
import { IngredientTypeIcon } from "../recipes/ingredient-icons";
|
||||||
|
import "../recipes/recipes.scss";
|
||||||
|
import "./profile-forms.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search-and-add + removable-chips field for the current user's personal
|
||||||
|
* "disliked ingredients" list — a taste preference, distinct from
|
||||||
|
* `AllergySelect`'s medical allergy list. Used on `/parametres/preferences`
|
||||||
|
* (`PreferencesPage`); crossed against a recipe's own ingredients on its
|
||||||
|
* detail panel (`RecipeDetailPanel`) to surface just the relevant ones.
|
||||||
|
*/
|
||||||
|
export function DislikedIngredientsField({
|
||||||
|
ingredients,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
ingredients: IngredientView[];
|
||||||
|
value: number[];
|
||||||
|
onChange: (ingredientIds: number[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selected = ingredients.filter((ingredient) => value.includes(ingredient.id));
|
||||||
|
|
||||||
|
function add(ingredient: IngredientView) {
|
||||||
|
onChange([...value, ingredient.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(ingredientId: number) {
|
||||||
|
onChange(value.filter((id) => id !== ingredientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// `<fieldset>`/`<legend>` (not a bare `<label>`, which only associates
|
||||||
|
// with a single control) — this labels the composite widget (chips +
|
||||||
|
// search), same reasoning as AllergySelect.
|
||||||
|
<fieldset className="disliked-ingredients-field">
|
||||||
|
<legend>{t("preferences.form.dislikedIngredientsLabel")}</legend>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<ul className="disliked-ingredients-field__chips">
|
||||||
|
{selected.map((ingredient) => (
|
||||||
|
<li key={ingredient.id} className="disliked-ingredients-field__chip">
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
|
</span>
|
||||||
|
{ingredient.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(ingredient.id)}
|
||||||
|
title={t("preferences.form.removeDislikedIngredient")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<IngredientPicker ingredients={ingredients} excludeIds={value} onSelect={add} />
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
198
apps/web/src/features/recipes/IngredientPicker.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import {
|
||||||
|
INGREDIENT_CATEGORIES,
|
||||||
|
INGREDIENT_CATEGORY_SUBCATEGORIES,
|
||||||
|
type IngredientCategory,
|
||||||
|
type IngredientSubcategory,
|
||||||
|
type IngredientView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SettingsIcon } from "../../layouts/nav-icons";
|
||||||
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
|
import { DietBadges } from "./DietBadges";
|
||||||
|
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
||||||
|
const ALL = "ALL" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsable ingredient picker — a two-level category/subcategory drill-down
|
||||||
|
* plus search plus a card grid, replacing the earlier `IngredientAutocomplete`
|
||||||
|
* (a plain type-to-filter dropdown). With 400+ reference ingredients, search
|
||||||
|
* alone doesn't scale to actually *finding* one, and a single flat list of
|
||||||
|
* 7 aisles wouldn't either (some — "Condiments & épices" — are 100+ items
|
||||||
|
* deep) — so picking a category reveals a second row of subcategory chips
|
||||||
|
* scoped to it (reset whenever the category changes, since a subcategory
|
||||||
|
* from the previous one wouldn't apply). Search still narrows within (or
|
||||||
|
* across) whatever's selected for when the name is already known.
|
||||||
|
*
|
||||||
|
* Receives `ingredients` as a prop rather than fetching them itself — same
|
||||||
|
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
|
||||||
|
* ingredients — a recipe's ingredient list, or a profile's disliked list)
|
||||||
|
* keeps the same one from being added twice.
|
||||||
|
*
|
||||||
|
* The settings menu trailing the search input shows/hides the allergen/diet
|
||||||
|
* badge rows on every card — a display preference local to this picker (not
|
||||||
|
* persisted), for whoever finds two rows of badges per card too noisy while
|
||||||
|
* just browsing/searching by name. A labeled checkbox menu rather than two
|
||||||
|
* bare icon-only toggle buttons — those turned out too ambiguous on their
|
||||||
|
* own (unclear what each icon meant without a label attached).
|
||||||
|
*/
|
||||||
|
export function IngredientPicker({
|
||||||
|
ingredients,
|
||||||
|
excludeIds,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
ingredients: IngredientView[];
|
||||||
|
excludeIds: number[];
|
||||||
|
onSelect: (ingredient: IngredientView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
|
||||||
|
const [subcategory, setSubcategory] = useState<IngredientSubcategory | typeof ALL>(ALL);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
// Purely a display preference for this picker's own card grid — doesn't
|
||||||
|
// touch which ingredients `visible` includes, only whether their
|
||||||
|
// allergen/diet badges render. Defaults to shown (the previous, only
|
||||||
|
// behavior); collapsing them is an opt-in for whoever finds two rows of
|
||||||
|
// badges per card too noisy while just browsing/searching by name.
|
||||||
|
const [showAllergens, setShowAllergens] = useState(true);
|
||||||
|
const [showDiets, setShowDiets] = useState(true);
|
||||||
|
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
function selectCategory(next: IngredientCategory | typeof ALL) {
|
||||||
|
setCategory(next);
|
||||||
|
setSubcategory(ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const visible = ingredients.filter((ingredient) => {
|
||||||
|
if (excludeIds.includes(ingredient.id)) return false;
|
||||||
|
if (category !== ALL) {
|
||||||
|
if (ingredient.category !== category) return false;
|
||||||
|
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
|
||||||
|
}
|
||||||
|
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSelect(ingredient: IngredientView) {
|
||||||
|
onSelect(ingredient);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ingredient-picker">
|
||||||
|
<div className="ingredient-picker__search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
||||||
|
/>
|
||||||
|
<div className="ingredient-picker__display-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__display-toggle${isDisplayMenuOpen ? " active" : ""}`}
|
||||||
|
onClick={() => setIsDisplayMenuOpen((v) => !v)}
|
||||||
|
aria-expanded={isDisplayMenuOpen}
|
||||||
|
title={t("recipes.form.displayOptions")}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</button>
|
||||||
|
{isDisplayMenuOpen && (
|
||||||
|
<div className="ingredient-picker__display-menu">
|
||||||
|
<label className={showDiets ? "is-selected" : ""}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDiets}
|
||||||
|
onChange={(e) => setShowDiets(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="check-mark" aria-hidden="true" />
|
||||||
|
{t("recipes.form.showDietsLabel")}
|
||||||
|
</label>
|
||||||
|
<label className={showAllergens ? "is-selected" : ""}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAllergens}
|
||||||
|
onChange={(e) => setShowAllergens(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="check-mark" aria-hidden="true" />
|
||||||
|
{t("recipes.form.showAllergensLabel")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ingredient-picker__categories">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
|
||||||
|
onClick={() => selectCategory(ALL)}
|
||||||
|
>
|
||||||
|
{t("recipes.form.allCategories")}
|
||||||
|
</button>
|
||||||
|
{INGREDIENT_CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
||||||
|
onClick={() => selectCategory(c)}
|
||||||
|
>
|
||||||
|
<CategoryIcon category={c} />
|
||||||
|
{t(`recipes.form.category.${c}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category !== ALL && (
|
||||||
|
<div className="ingredient-picker__subcategories">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__subcategory${subcategory === ALL ? " active" : ""}`}
|
||||||
|
onClick={() => setSubcategory(ALL)}
|
||||||
|
>
|
||||||
|
{t("recipes.form.allSubcategories")}
|
||||||
|
</button>
|
||||||
|
{INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
|
||||||
|
onClick={() => setSubcategory(s)}
|
||||||
|
>
|
||||||
|
<SubcategoryIcon subcategory={s} />
|
||||||
|
{t(`recipes.form.subcategory.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="ingredient-picker__grid">
|
||||||
|
{visible.map((ingredient) => (
|
||||||
|
<button
|
||||||
|
key={ingredient.id}
|
||||||
|
type="button"
|
||||||
|
className="ingredient-picker__card"
|
||||||
|
onClick={() => handleSelect(ingredient)}
|
||||||
|
>
|
||||||
|
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
||||||
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
|
</span>
|
||||||
|
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
||||||
|
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
||||||
|
{showDiets && <DietBadges diets={ingredient.diets} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/src/features/recipes/IngredientRow.tsx
Normal file
61
apps/web/src/features/recipes/IngredientRow.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { IngredientView } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
|
import { DietBadges } from "./DietBadges";
|
||||||
|
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
||||||
|
export function IngredientRow({
|
||||||
|
ingredient,
|
||||||
|
quantity,
|
||||||
|
unit,
|
||||||
|
onQuantityChange,
|
||||||
|
onUnitChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
ingredient: IngredientView;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
onQuantityChange: (quantity: string) => void;
|
||||||
|
onUnitChange: (unit: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="ingredient-row">
|
||||||
|
<span className="ingredient-row__icon" aria-hidden="true">
|
||||||
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
|
</span>
|
||||||
|
<span className="ingredient-row__name">{ingredient.name}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
className="ingredient-row__quantity"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => onQuantityChange(e.target.value)}
|
||||||
|
aria-label={t("recipes.form.quantityLabel")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ingredient-row__unit"
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => onUnitChange(e.target.value)}
|
||||||
|
placeholder={t("recipes.form.unitPlaceholder")}
|
||||||
|
aria-label={t("recipes.form.unitLabel")}
|
||||||
|
/>
|
||||||
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
|
<DietBadges diets={ingredient.diets} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ingredient-row__remove"
|
||||||
|
onClick={onRemove}
|
||||||
|
title={t("recipes.form.removeIngredient")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
apps/web/src/features/recipes/ingredient-icons.tsx
Normal file
360
apps/web/src/features/recipes/ingredient-icons.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
import type {
|
||||||
|
IngredientCategory,
|
||||||
|
IngredientIcon as IngredientIconType,
|
||||||
|
IngredientSubcategory,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Generic pictograms for ingredients — one per `IngredientIcon` value
|
||||||
|
// (schema.prisma), reused across the category chips, subcategory chips and
|
||||||
|
// ingredient cards in `IngredientPicker`/`IngredientRow`. Replaces the
|
||||||
|
// earlier per-ingredient emoji (437 different characters, one per
|
||||||
|
// ingredient) with a small, hand-drawn vocabulary grouped by *what kind of
|
||||||
|
// thing* an ingredient is (a vegetable, a bottle of oil, a wedge of
|
||||||
|
// cheese…) rather than a literal picture of each one — 437 unique,
|
||||||
|
// professional icons isn't a realistic hand-drawn set, but ~20 generic
|
||||||
|
// shapes reused across them is. Same 24×24, stroke-only house style as
|
||||||
|
// `layouts/nav-icons.tsx` (kept separate from that file since these are
|
||||||
|
// ingredient-domain, not app-nav).
|
||||||
|
function Icon({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carrot — root vegetables, leafy greens, and produce generally (`PRODUITS_FRAIS`/`LEGUMES`). */
|
||||||
|
export function VegetableIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 3c1.5 1 2 2.5 1.3 4M15 2c1 1.2 1 2.6 0 4" />
|
||||||
|
<path d="M13.3 7c2.8 0 4.7 2.9 4.1 5.6L15.8 20a2 2 0 0 1-3.9.2L9.2 12.6C8.3 9.9 10.3 7 13.3 7z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple — `PRODUITS_FRAIS`/`FRUITS`. */
|
||||||
|
export function FruitIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 8c-.5-1.3-.2-3 1-4" />
|
||||||
|
<path d="M12 8c-4 0-6.5 3-6.5 7a6.5 6.5 0 0 0 13 0C18.5 11 16 8 12 8z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A leaf sprig — `PRODUITS_FRAIS`/`HERBES_FRAICHES`. */
|
||||||
|
export function HerbIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 21V7" />
|
||||||
|
<path d="M12 8c0-3 2-5 5-5 0 3-2 5-5 5z" />
|
||||||
|
<path d="M12 13c0-3-2-5-5-5 0 3 2 5 5 5z" />
|
||||||
|
<path d="M12 18c0-2.5 1.7-4 4-4 0 2.5-1.7 4-4 4z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cut of meat — `BOUCHERIE_POISSONNERIE`/`VIANDES`. */
|
||||||
|
export function MeatIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M8 16 17 7a3 3 0 1 1 4 4l-9 9a5 5 0 0 1-8-2 5 5 0 0 1 4-4z" />
|
||||||
|
<circle cx="9" cy="15" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drumstick — `BOUCHERIE_POISSONNERIE`/`VOLAILLES`. */
|
||||||
|
export function PoultryIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M13 4c2.5 0 4.5 2 4.5 4.5 0 2.8-2.3 4.8-4 6.4l-3.2 3.2a2.3 2.3 0 0 1-3.3-3.3l3.2-3.2C11.7 10 13.7 7.7 13 4z" />
|
||||||
|
<path d="M6.5 21 5 22.5" />
|
||||||
|
<path d="M9 18.5 7.5 20" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fish — `BOUCHERIE_POISSONNERIE`/`POISSONS`. */
|
||||||
|
export function FishIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M3 12c3-4 8-6 13-4 2.5 1 5 3 5 4s-2.5 3-5 4c-5 2-10 0-13-4z" />
|
||||||
|
<path d="M16 9v6" />
|
||||||
|
<circle cx="6.5" cy="11" r=".6" fill="currentColor" stroke="none" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shrimp — `BOUCHERIE_POISSONNERIE`/`CRUSTACES_FRUITS_DE_MER`. */
|
||||||
|
export function ShellfishIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M6 6c6-2 12 1 13 7-4 2-9 2-12-1C5 10 4.5 8 6 6z" />
|
||||||
|
<path d="M6 6 4 4M7 8 5 7M8 11l-2.2.6" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wheat ear — grains, pasta, rice, flour (`EPICERIE_SECHE`/`FECULENTS`, and flours under `AIDES_CULINAIRES`/`BASES`). */
|
||||||
|
export function GrainIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 22V6" />
|
||||||
|
<path d="M12 7c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 7c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
|
||||||
|
<path d="M12 11c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 11c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
|
||||||
|
<path d="M12 15c-1-1-2.5-1-3.5 0 1 1 2.5 1 3.5 0zM12 15c1-1 2.5-1 3.5 0-1 1-2.5 1-3.5 0z" />
|
||||||
|
<path d="M12 6c0-1.5.8-2.8 2-3.5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bean pod — `EPICERIE_SECHE`/`LEGUMINEUSES`. */
|
||||||
|
export function LegumeIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M5 13c0-5 3.5-9 9-9 1 3-1 5-1 5s3 0 5 3c0 5-4 9-9 9-3 0-4-4-4-8z" />
|
||||||
|
<circle cx="9.5" cy="14.5" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="13" cy="11" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acorn — nuts, seeds, dried fruit (`EPICERIE_SECHE`/`GRAINES_FRUITS_SECS`). */
|
||||||
|
export function NutSeedIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M8 9c0-3 1.8-5 4-5s4 2 4 5" />
|
||||||
|
<path d="M8 9c-1.3.5-2 1.7-2 3 0 4 2.7 8 6 8s6-4 6-8c0-1.3-.7-2.5-2-3z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A loaf, scored on top — `BOULANGERIE`/`PAINS`. */
|
||||||
|
export function BreadIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M4 12c0-4.4 3.6-8 8-8s8 3.6 8 8v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
|
||||||
|
<path d="M9 8l1.5 3M14 7.5 15.5 11" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolling pin — raw, uncooked pastry (`BOULANGERIE`/`PATES_A_CUIRE`). */
|
||||||
|
export function DoughIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<rect x="6" y="10" width="12" height="4" rx="2" />
|
||||||
|
<circle cx="4" cy="12" r="2" />
|
||||||
|
<circle cx="20" cy="12" r="2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A glass of milk — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (non-cheese items). */
|
||||||
|
export function MilkIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M9 2h6l1 4-1 2v11a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V8L8 6z" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wedge of cheese with holes — `CREMERIE_FROMAGE`/`PRODUITS_LAITIERS` (cheese items). */
|
||||||
|
export function CheeseIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M3 18 20 8l1 3-3 9H4z" />
|
||||||
|
<circle cx="10" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="14" cy="17" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An egg — `CREMERIE_FROMAGE`/`OEUFS`. */
|
||||||
|
export function EggIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 3C8 8 6 12.5 6 15.5a6 6 0 0 0 12 0C18 12.5 16 8 12 3z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A seedling — plant-based dairy/meat alternatives (`CREMERIE_FROMAGE`/`ALTERNATIVES`). */
|
||||||
|
export function SproutIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 21v-9" />
|
||||||
|
<path d="M12 12C7 12 5 9 5 5c5 0 7 2 7 7z" />
|
||||||
|
<path d="M12 12c4 0 6-2.5 6-6-4 0-6 1.5-6 6z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shaker — dried spices/herbs (`CONDIMENTS_EPICES`/`EPICES`). */
|
||||||
|
export function SpiceIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<rect x="8" y="8" width="8" height="13" rx="3" />
|
||||||
|
<path d="M10 8V5a2 2 0 0 1 4 0v3" />
|
||||||
|
<path d="M11 12h.01M13 12h.01M10.5 15h.01M13.5 15h.01M12 18h.01" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A condiment jar — sauces, pickles, tinned/preserved goods (`CONDIMENTS_EPICES`/`SAUCES` and the `EPICERIE_SECHE`/`AUTRES` catch-all). */
|
||||||
|
export function JarIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<rect x="6" y="9" width="12" height="12" rx="2" />
|
||||||
|
<path d="M9 9V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
<path d="M6 13h12" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bottle — oils, vinegars (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`, the pourable subset). */
|
||||||
|
export function BottleIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M10 2h4v3.5l1.5 2V20a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2V7.5L10 5.5z" />
|
||||||
|
<path d="M9.5 11h5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A glass — juices, coffee/tea, cooking alcohols, water (`CONDIMENTS_EPICES`/`ASSAISONNEMENTS`'s drinkable subset). */
|
||||||
|
export function DrinkIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M6 3h12l-1.5 16.5A2 2 0 0 1 14.5 21h-5a2 2 0 0 1-2-1.5z" />
|
||||||
|
<path d="M7 9h10" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stockpot — broths, stocks, water bases (`AIDES_CULINAIRES`/`BASES`'s liquid-base subset). */
|
||||||
|
export function StockPotIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<rect x="4" y="10" width="16" height="10" rx="2" />
|
||||||
|
<path d="M2 12h2M20 12h2" />
|
||||||
|
<path d="M9 10V7a3 3 0 0 1 6 0v3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sugar cube — `AIDES_CULINAIRES`/`SUCRES`. */
|
||||||
|
export function SugarIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<path d="M4 8 12 4l8 4-8 4z" />
|
||||||
|
<path d="M4 8v8l8 4 8-4V8" />
|
||||||
|
<path d="M12 12v8" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every icon component, keyed by {@link IngredientIconType} — the single lookup `IngredientPicker`/`IngredientRow`/category chips render through. */
|
||||||
|
export const INGREDIENT_ICON_COMPONENTS: Record<IngredientIconType, () => JSX.Element> = {
|
||||||
|
VEGETABLE: VegetableIcon,
|
||||||
|
FRUIT: FruitIcon,
|
||||||
|
HERB: HerbIcon,
|
||||||
|
MEAT: MeatIcon,
|
||||||
|
POULTRY: PoultryIcon,
|
||||||
|
FISH: FishIcon,
|
||||||
|
SHELLFISH: ShellfishIcon,
|
||||||
|
GRAIN: GrainIcon,
|
||||||
|
LEGUME: LegumeIcon,
|
||||||
|
NUT_SEED: NutSeedIcon,
|
||||||
|
BREAD: BreadIcon,
|
||||||
|
DOUGH: DoughIcon,
|
||||||
|
MILK: MilkIcon,
|
||||||
|
CHEESE: CheeseIcon,
|
||||||
|
EGG: EggIcon,
|
||||||
|
SPROUT: SproutIcon,
|
||||||
|
SPICE: SpiceIcon,
|
||||||
|
JAR: JarIcon,
|
||||||
|
BOTTLE: BottleIcon,
|
||||||
|
DRINK: DrinkIcon,
|
||||||
|
STOCK_POT: StockPotIcon,
|
||||||
|
SUGAR: SugarIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders the generic pictogram for one {@link IngredientIconType} — the actual component `IngredientPicker`'s cards and `IngredientRow` use, rather than importing {@link INGREDIENT_ICON_COMPONENTS} directly everywhere. */
|
||||||
|
export function IngredientTypeIcon({ icon }: { icon: IngredientIconType }) {
|
||||||
|
const Component = INGREDIENT_ICON_COMPONENTS[icon];
|
||||||
|
return <Component />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One representative {@link IngredientIconType} per {@link IngredientCategory}
|
||||||
|
* aisle, for `IngredientPicker`'s top-level category chips — a category
|
||||||
|
* mixes several icon types (e.g. "Aides culinaires" has flour, stock, and
|
||||||
|
* sugar items), so this is a deliberate, illustrative pick rather than a
|
||||||
|
* derived fact.
|
||||||
|
*/
|
||||||
|
export const CATEGORY_ICON: Record<IngredientCategory, IngredientIconType> = {
|
||||||
|
PRODUITS_FRAIS: "VEGETABLE",
|
||||||
|
BOUCHERIE_POISSONNERIE: "MEAT",
|
||||||
|
EPICERIE_SECHE: "GRAIN",
|
||||||
|
BOULANGERIE: "BREAD",
|
||||||
|
CREMERIE_FROMAGE: "CHEESE",
|
||||||
|
CONDIMENTS_EPICES: "SPICE",
|
||||||
|
AIDES_CULINAIRES: "STOCK_POT",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders {@link CATEGORY_ICON}'s pictogram for one category — used by the category chip row. */
|
||||||
|
export function CategoryIcon({ category }: { category: IngredientCategory }) {
|
||||||
|
return <IngredientTypeIcon icon={CATEGORY_ICON[category]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One representative {@link IngredientIconType} per {@link IngredientSubcategory}
|
||||||
|
* rack, for `IngredientPicker`'s second-tier subcategory chips. Most map
|
||||||
|
* 1:1 onto their subcategory's dominant shape; a couple of heterogeneous
|
||||||
|
* subcategories (`ASSAISONNEMENTS` mixes oils with juices and coffee,
|
||||||
|
* `BASES` mixes flour with stock and canned tomato) get one illustrative
|
||||||
|
* pick rather than a derived fact, same reasoning as {@link CATEGORY_ICON}.
|
||||||
|
*/
|
||||||
|
export const SUBCATEGORY_ICON: Record<IngredientSubcategory, IngredientIconType> = {
|
||||||
|
LEGUMES: "VEGETABLE",
|
||||||
|
FRUITS: "FRUIT",
|
||||||
|
HERBES_FRAICHES: "HERB",
|
||||||
|
VIANDES: "MEAT",
|
||||||
|
VOLAILLES: "POULTRY",
|
||||||
|
POISSONS: "FISH",
|
||||||
|
CRUSTACES_FRUITS_DE_MER: "SHELLFISH",
|
||||||
|
FECULENTS: "GRAIN",
|
||||||
|
LEGUMINEUSES: "LEGUME",
|
||||||
|
GRAINES_FRUITS_SECS: "NUT_SEED",
|
||||||
|
AUTRES: "JAR",
|
||||||
|
PAINS: "BREAD",
|
||||||
|
PATES_A_CUIRE: "DOUGH",
|
||||||
|
PRODUITS_LAITIERS: "MILK",
|
||||||
|
OEUFS: "EGG",
|
||||||
|
ALTERNATIVES: "SPROUT",
|
||||||
|
EPICES: "SPICE",
|
||||||
|
SAUCES: "JAR",
|
||||||
|
ASSAISONNEMENTS: "BOTTLE",
|
||||||
|
BASES: "STOCK_POT",
|
||||||
|
EPAISSISSANTS: "JAR",
|
||||||
|
SUCRES: "SUGAR",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders {@link SUBCATEGORY_ICON}'s pictogram for one subcategory — used by the subcategory chip row. */
|
||||||
|
export function SubcategoryIcon({ subcategory }: { subcategory: IngredientSubcategory }) {
|
||||||
|
return <IngredientTypeIcon icon={SUBCATEGORY_ICON[subcategory]} />;
|
||||||
|
}
|
||||||
1117
apps/web/src/features/recipes/recipes.scss
Normal file
1117
apps/web/src/features/recipes/recipes.scss
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -305,6 +305,17 @@
|
||||||
// viewport (relevant early given this app is meant to be embedded via
|
// viewport (relevant early given this app is meant to be embedded via
|
||||||
// Capacitor later, see the root README) — collapse it into a horizontal
|
// Capacitor later, see the root README) — collapse it into a horizontal
|
||||||
// top bar instead of a side rail.
|
// top bar instead of a side rail.
|
||||||
|
//
|
||||||
|
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
|
||||||
|
// legible and tappable at any width — it's the app's main navigation, not
|
||||||
|
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
|
||||||
|
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
|
||||||
|
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
|
||||||
|
// (measured on a 375px viewport) — invisible labels, no real tap target.
|
||||||
|
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
|
||||||
|
// look as the desktop rail's `.collapsed` state), freeing width for the
|
||||||
|
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
|
||||||
|
// than shrinking if it still doesn't fit.
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app-layout {
|
.app-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -326,15 +337,75 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing to collapse into on a horizontal bar — there's no rail.
|
||||||
|
&__collapse-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
a {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__settings,
|
&__settings,
|
||||||
&__footer {
|
&__footer {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__settings-toggle,
|
||||||
|
&__account-toggle {
|
||||||
|
padding-left: var(--space-xs);
|
||||||
|
padding-right: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__settings-toggle-left .label,
|
||||||
|
&__account-toggle .label,
|
||||||
|
&__settings-toggle .chevron {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both reveals become floating panels anchored under their icon-only
|
||||||
|
// toggle — on the desktop rail one is inline (settings, stacks fine in
|
||||||
|
// a column) and the other already floats (account); neither can stay
|
||||||
|
// in normal flow on this horizontal bar without breaking the row.
|
||||||
|
// Selector order matters: `&__settings-nav` also carries the plain
|
||||||
|
// `&__nav` class (for the link styling), so this must come after it to
|
||||||
|
// win on `flex-direction`/`overflow-x`.
|
||||||
|
&__settings-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 14rem;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
overflow-x: visible;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
|
||||||
|
// rule above) because the footer sits at the bottom of a tall rail —
|
||||||
|
// on this horizontal top bar the footer is near `y: 0`, so "upward"
|
||||||
|
// pushed the menu entirely off-screen above the viewport. Flip it to
|
||||||
|
// open downward here instead.
|
||||||
|
&__account-menu {
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
bottom: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,23 @@ export function ChevronLeftIcon() {
|
||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
|
||||||
|
export function FavoriteIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
|
||||||
|
export function PublicIcon() {
|
||||||
|
return (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@
|
||||||
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
||||||
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
|
"ALREADY_HAS_HOUSE": "Vous appartenez déjà à un foyer",
|
||||||
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
|
"NOT_HOUSE_ADMIN": "Seul l'administrateur du foyer peut faire ça",
|
||||||
|
"NOT_RECIPE_AUTHOR": "Seul l'auteur de la recette peut faire ça",
|
||||||
"NOT_FOUND": "Ressource introuvable",
|
"NOT_FOUND": "Ressource introuvable",
|
||||||
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
"HOUSE_NOT_FOUND": "Votre profil n'a pas de foyer",
|
||||||
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
"DIET_NOT_FOUND": "Ce régime alimentaire n'existe pas",
|
||||||
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
|
"ALLERGY_NOT_FOUND": "Un des allergènes sélectionnés n'existe pas",
|
||||||
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
|
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
|
||||||
|
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
||||||
|
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
||||||
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -119,7 +123,102 @@
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Recettes",
|
"title": "Recettes",
|
||||||
"comingSoon": "Cette section arrive bientôt."
|
"searchPlaceholder": "Rechercher une recette…",
|
||||||
|
"newButton": "Nouvelle recette",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"empty": "Aucune recette pour le moment.",
|
||||||
|
"notFound": "Cette recette n'existe pas.",
|
||||||
|
"editButton": "Modifier",
|
||||||
|
"deleteButton": "Supprimer",
|
||||||
|
"confirmDeleteButton": "Confirmer la suppression",
|
||||||
|
"cancelDeleteButton": "Annuler",
|
||||||
|
"ingredientsTitle": "Ingrédients",
|
||||||
|
"stepsTitle": "Préparation",
|
||||||
|
"tabs": {
|
||||||
|
"favoris": "Favoris",
|
||||||
|
"perso": "Perso",
|
||||||
|
"foyer": "Foyer",
|
||||||
|
"publique": "Publique",
|
||||||
|
"sourcesSoon": "Sources (bientôt)",
|
||||||
|
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "Nom",
|
||||||
|
"allergens": "Allergènes / intolérances",
|
||||||
|
"diets": "Régime associé"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
||||||
|
"favorite": "Ajouter aux favoris",
|
||||||
|
"unfavorite": "Retirer des favoris"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"newTitle": "Nouvelle recette",
|
||||||
|
"editTitle": "Modifier la recette",
|
||||||
|
"nameLabel": "Nom de la recette",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"pictureLabel": "Photo (URL)",
|
||||||
|
"visibilityLabel": "Visible par",
|
||||||
|
"visibility": {
|
||||||
|
"PERSONAL": "Moi uniquement",
|
||||||
|
"HOUSE": "Mon foyer",
|
||||||
|
"PUBLIC": "Tout le monde"
|
||||||
|
},
|
||||||
|
"dietsLabel": "Régime(s) associé(s)",
|
||||||
|
"searchIngredientPlaceholder": "Rechercher un ingrédient…",
|
||||||
|
"displayOptions": "Options d'affichage",
|
||||||
|
"showDietsLabel": "Régimes alimentaires",
|
||||||
|
"showAllergensLabel": "Allergènes",
|
||||||
|
"allCategories": "Tout",
|
||||||
|
"allSubcategories": "Tout",
|
||||||
|
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||||
|
"category": {
|
||||||
|
"PRODUITS_FRAIS": "Produits frais",
|
||||||
|
"BOUCHERIE_POISSONNERIE": "Boucherie & poissonnerie",
|
||||||
|
"EPICERIE_SECHE": "Épicerie sèche",
|
||||||
|
"BOULANGERIE": "Boulangerie",
|
||||||
|
"CREMERIE_FROMAGE": "Crémerie & fromage",
|
||||||
|
"CONDIMENTS_EPICES": "Condiments & épices",
|
||||||
|
"AIDES_CULINAIRES": "Aides culinaires"
|
||||||
|
},
|
||||||
|
"subcategory": {
|
||||||
|
"LEGUMES": "Légumes",
|
||||||
|
"FRUITS": "Fruits",
|
||||||
|
"HERBES_FRAICHES": "Herbes fraîches",
|
||||||
|
"VIANDES": "Viandes",
|
||||||
|
"VOLAILLES": "Volailles",
|
||||||
|
"POISSONS": "Poissons",
|
||||||
|
"CRUSTACES_FRUITS_DE_MER": "Crustacés & fruits de mer",
|
||||||
|
"FECULENTS": "Féculents",
|
||||||
|
"LEGUMINEUSES": "Légumineuses",
|
||||||
|
"GRAINES_FRUITS_SECS": "Graines & fruits secs",
|
||||||
|
"AUTRES": "Autres",
|
||||||
|
"PAINS": "Pains",
|
||||||
|
"PATES_A_CUIRE": "Pâtes à cuire",
|
||||||
|
"PRODUITS_LAITIERS": "Produits laitiers",
|
||||||
|
"OEUFS": "Œufs",
|
||||||
|
"ALTERNATIVES": "Alternatives végétales",
|
||||||
|
"EPICES": "Épices",
|
||||||
|
"SAUCES": "Sauces",
|
||||||
|
"ASSAISONNEMENTS": "Assaisonnements",
|
||||||
|
"BASES": "Bases",
|
||||||
|
"EPAISSISSANTS": "Épaississants",
|
||||||
|
"SUCRES": "Sucres"
|
||||||
|
},
|
||||||
|
"quantityLabel": "Quantité",
|
||||||
|
"unitLabel": "Unité",
|
||||||
|
"unitPlaceholder": "g, ml, unité…",
|
||||||
|
"removeIngredient": "Retirer cet ingrédient",
|
||||||
|
"stepDescriptionPlaceholder": "Décrivez cette étape…",
|
||||||
|
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
|
||||||
|
"moveStepUp": "Monter cette étape",
|
||||||
|
"moveStepDown": "Descendre cette étape",
|
||||||
|
"removeStep": "Supprimer cette étape",
|
||||||
|
"addStep": "Ajouter une étape",
|
||||||
|
"submit": "Enregistrer",
|
||||||
|
"submitting": "Enregistrement…",
|
||||||
|
"genericError": "Le formulaire contient des erreurs"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"shoppingList": {
|
"shoppingList": {
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
|
|
@ -147,7 +246,9 @@
|
||||||
"dietLabel": "Régime alimentaire",
|
"dietLabel": "Régime alimentaire",
|
||||||
"dietNone": "Aucun régime particulier",
|
"dietNone": "Aucun régime particulier",
|
||||||
"allergiesLabel": "Allergies",
|
"allergiesLabel": "Allergies",
|
||||||
"intolerancesLabel": "Intolérances"
|
"intolerancesLabel": "Intolérances",
|
||||||
|
"dislikedIngredientsLabel": "Aliments que vous n'aimez pas",
|
||||||
|
"removeDislikedIngredient": "Retirer cet aliment"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userPreferences": {
|
"userPreferences": {
|
||||||
|
|
|
||||||
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 { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||||
|
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||||
|
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||||
|
import "../features/recipes/recipes.scss";
|
||||||
|
import { errorMessageService } from "../services/error-message.service";
|
||||||
|
|
||||||
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||||
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
|
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. */
|
||||||
|
interface IngredientLine {
|
||||||
|
key: string;
|
||||||
|
ingredient: IngredientView;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
||||||
|
type LoadState = "loading" | "loaded" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/edit form for one recipe — routed at `/recettes/nouvelle` and
|
||||||
|
* `/recettes/:id/modifier`. Same component for both: edit mode is just
|
||||||
|
* "there's an `:id` param", which also drives preloading the existing
|
||||||
|
* recipe's fields. Saving always sends the recipe's *whole* content (name,
|
||||||
|
* ingredients, steps) — there's no partial-field save here, matching the
|
||||||
|
* API's `PATCH /recipes/:id` contract (see `recipe.service.ts`).
|
||||||
|
*/
|
||||||
|
export function RecipeFormPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const recipeId = id !== undefined ? Number(id) : null;
|
||||||
|
const isEditing = recipeId !== null;
|
||||||
|
|
||||||
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [picture, setPicture] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
|
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||||
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadState("loading");
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
apiClient.getIngredients(),
|
||||||
|
apiClient.getDiets(),
|
||||||
|
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
.then(([ingredients, diets, recipe]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIngredientsCatalog(ingredients);
|
||||||
|
setDietsCatalog(diets);
|
||||||
|
if (recipe) {
|
||||||
|
setName(recipe.name);
|
||||||
|
setDescription(recipe.description ?? "");
|
||||||
|
setPicture(recipe.picture ?? "");
|
||||||
|
setVisibility(recipe.visibility);
|
||||||
|
setDietIds(recipe.diets.map((diet) => diet.id));
|
||||||
|
setIngredientLines(
|
||||||
|
recipe.ingredients.map((line) => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
ingredient: line.ingredient,
|
||||||
|
quantity: String(line.quantity),
|
||||||
|
unit: line.unit,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setSteps(
|
||||||
|
recipe.steps.map((step) => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setLoadState("loaded");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadState("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [recipeId]);
|
||||||
|
|
||||||
|
function addIngredient(ingredient: IngredientView) {
|
||||||
|
setIngredientLines((lines) => [
|
||||||
|
...lines,
|
||||||
|
{ key: crypto.randomUUID(), ingredient, quantity: "", unit: "" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredientLine(
|
||||||
|
key: string,
|
||||||
|
patch: Partial<Pick<IngredientLine, "quantity" | "unit">>,
|
||||||
|
) {
|
||||||
|
setIngredientLines((lines) =>
|
||||||
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredientLine(key: string) {
|
||||||
|
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gates the submit button — the schema (checked again on submit, see
|
||||||
|
// `handleSubmit`) is the source of truth, this is just instant feedback
|
||||||
|
// that doesn't need a round trip through zod on every keystroke.
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
ingredientLines.length > 0 &&
|
||||||
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
|
||||||
|
steps.length > 0 &&
|
||||||
|
steps.every((step) => step.description.trim().length > 0);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const payload: CreateRecipeInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
picture: picture.trim() || null,
|
||||||
|
visibility,
|
||||||
|
dietIds,
|
||||||
|
ingredients: ingredientLines.map((line) => ({
|
||||||
|
ingredientId: line.ingredient.id,
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
unit: line.unit.trim(),
|
||||||
|
})),
|
||||||
|
steps: steps.map((step) => ({
|
||||||
|
description: step.description.trim(),
|
||||||
|
picture: step.picture.trim() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createRecipeSchema.safeParse(payload);
|
||||||
|
if (!result.success) {
|
||||||
|
setFormError(result.error.issues[0]?.message ?? t("recipes.form.genericError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
recipeId !== null
|
||||||
|
? await apiClient.updateRecipe(recipeId, result.data)
|
||||||
|
: await apiClient.createRecipe(result.data);
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "error") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<h1>{isEditing ? t("recipes.form.editTitle") : t("recipes.form.newTitle")}</h1>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
||||||
|
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
|
||||||
|
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
||||||
|
<textarea
|
||||||
|
id="recipe-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="recipe-picture"
|
||||||
|
type="url"
|
||||||
|
value={picture}
|
||||||
|
onChange={(e) => setPicture(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
||||||
|
<select
|
||||||
|
id="recipe-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{t(`recipes.form.visibility.${option}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||||
|
<ul className="recipe-form__ingredient-list">
|
||||||
|
{ingredientLines.map((line) => (
|
||||||
|
<IngredientRow
|
||||||
|
key={line.key}
|
||||||
|
ingredient={line.ingredient}
|
||||||
|
quantity={line.quantity}
|
||||||
|
unit={line.unit}
|
||||||
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
|
onUnitChange={(unit) => updateIngredientLine(line.key, { unit })}
|
||||||
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onSelect={addIngredient}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.stepsTitle")}</h2>
|
||||||
|
<StepListEditor steps={steps} onChange={setSteps} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="recipe-form__actions">
|
||||||
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||||
|
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,174 @@
|
||||||
|
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ComingSoonPage } from "./ComingSoonPage";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ApiError, apiClient } from "../api/client";
|
||||||
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||||
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||||
|
import { RecipeTabs } from "../features/recipes/RecipeTabs";
|
||||||
|
import "../features/recipes/recipes.scss";
|
||||||
|
|
||||||
/** Recipes section — routed at `/recettes`. No backend yet (see README's "Planning" section), stub for now. */
|
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
|
||||||
|
type RecipeListState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; recipes: RecipeSummaryView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipe catalog — routed at both `/recettes` and `/recettes/:id` (the same
|
||||||
|
* component either way, see `App.tsx`): a tab bar + table on the left stay
|
||||||
|
* mounted at all times, only the right-hand detail panel changes with the
|
||||||
|
* `:id` param — a master-detail layout, not a navigation to a separate
|
||||||
|
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
|
||||||
|
* `RecipeDetailPage`).
|
||||||
|
*/
|
||||||
export function RecipesPage() {
|
export function RecipesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <ComingSoonPage title={t("recipes.title")} description={t("recipes.comingSoon")} />;
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const selectedId = id !== undefined ? Number(id) : null;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<RecipeTab>("publique");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
||||||
|
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
||||||
|
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.listRecipes(activeTab, debouncedSearch.trim() || undefined)
|
||||||
|
.then((recipes) => {
|
||||||
|
if (!cancelled) setListState({ status: "loaded", recipes });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setListState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeTab, debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === null) {
|
||||||
|
setDetailState({ status: "empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setDetailState({ status: "loading" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getRecipe(selectedId)
|
||||||
|
.then((recipe) => {
|
||||||
|
if (!cancelled) setDetailState({ status: "loaded", recipe });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
|
||||||
|
setDetailState({ status: "not-found" });
|
||||||
|
} else {
|
||||||
|
setDetailState({ status: "error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
// The viewer's personal "disliked" list only changes from the
|
||||||
|
// preferences page, never from here — loaded once, not re-fetched on
|
||||||
|
// every tab/selection change.
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getDislikedIngredientIds()
|
||||||
|
.then(setDislikedIngredientIds)
|
||||||
|
.catch(() => setDislikedIngredientIds([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
|
||||||
|
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
|
||||||
|
setDetailState((prev) =>
|
||||||
|
prev.status === "loaded" && prev.recipe.id === recipeId
|
||||||
|
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setListState((prev) => {
|
||||||
|
if (prev.status !== "loaded") return prev;
|
||||||
|
const recipes = prev.recipes
|
||||||
|
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
|
||||||
|
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
|
||||||
|
return { status: "loaded", recipes };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
|
||||||
|
function handleDeleted(recipeId: number) {
|
||||||
|
navigate("/recettes");
|
||||||
|
setListState((prev) =>
|
||||||
|
prev.status === "loaded"
|
||||||
|
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recipes-page">
|
||||||
|
<div className="recipes-page__header">
|
||||||
|
<h1>{t("recipes.title")}</h1>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="recipes-page__search"
|
||||||
|
placeholder={t("recipes.searchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||||
|
{t("recipes.newButton")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
<div className="recipes-page__catalog">
|
||||||
|
{listState.status === "loading" && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("common.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
||||||
|
<RecipeTable
|
||||||
|
recipes={listState.recipes}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecipeDetailPanel
|
||||||
|
state={detailState}
|
||||||
|
dislikedIngredientIds={dislikedIngredientIds}
|
||||||
|
onFavoriteToggled={handleFavoriteToggled}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import { type AllergyView, type DietView, ErrorCode } from "@batch-cooking/shared";
|
import {
|
||||||
|
type AllergyView,
|
||||||
|
type DietView,
|
||||||
|
ErrorCode,
|
||||||
|
type IngredientView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { useAuth } from "../../features/auth/AuthContext";
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
import { AllergySelect } from "../../features/profile/AllergySelect";
|
import { AllergySelect } from "../../features/profile/AllergySelect";
|
||||||
import { DietSelect } from "../../features/profile/DietSelect";
|
import { DietSelect } from "../../features/profile/DietSelect";
|
||||||
|
import { DislikedIngredientsField } from "../../features/profile/DislikedIngredientsField";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./settings-pages.scss";
|
import "./settings-pages.scss";
|
||||||
|
|
||||||
|
|
@ -44,6 +50,12 @@ export function PreferencesPage() {
|
||||||
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
|
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
|
||||||
const allergiesTimeout = useRef<number | undefined>(undefined);
|
const allergiesTimeout = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const [ingredients, setIngredients] = useState<IngredientView[]>([]);
|
||||||
|
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
||||||
|
const [dislikedSaveState, setDislikedSaveState] = useState<SaveState>("idle");
|
||||||
|
const [dislikedSaveError, setDislikedSaveError] = useState<string | null>(null);
|
||||||
|
const dislikedTimeout = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
|
// `apiClient.me()` here (not `useAuth().user.dietId`) — this page can be
|
||||||
|
|
@ -55,15 +67,28 @@ export function PreferencesPage() {
|
||||||
apiClient.getDiets(),
|
apiClient.getDiets(),
|
||||||
apiClient.getAllergies(),
|
apiClient.getAllergies(),
|
||||||
apiClient.getAllergyIds(),
|
apiClient.getAllergyIds(),
|
||||||
|
apiClient.getIngredients(),
|
||||||
|
apiClient.getDislikedIngredientIds(),
|
||||||
apiClient.me(),
|
apiClient.me(),
|
||||||
])
|
])
|
||||||
.then(([dietsResult, allergiesResult, allergyIdsResult, profile]) => {
|
.then(
|
||||||
if (cancelled) return;
|
([
|
||||||
setDiets(dietsResult);
|
dietsResult,
|
||||||
setAllergies(allergiesResult);
|
allergiesResult,
|
||||||
setAllergyIds(allergyIdsResult);
|
allergyIdsResult,
|
||||||
setDietId(profile.dietId);
|
ingredientsResult,
|
||||||
})
|
dislikedResult,
|
||||||
|
profile,
|
||||||
|
]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDiets(dietsResult);
|
||||||
|
setAllergies(allergiesResult);
|
||||||
|
setAllergyIds(allergyIdsResult);
|
||||||
|
setIngredients(ingredientsResult);
|
||||||
|
setDislikedIngredientIds(dislikedResult);
|
||||||
|
setDietId(profile.dietId);
|
||||||
|
},
|
||||||
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setLoadError(true);
|
if (!cancelled) setLoadError(true);
|
||||||
})
|
})
|
||||||
|
|
@ -80,6 +105,7 @@ export function PreferencesPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(allergiesTimeout.current);
|
window.clearTimeout(allergiesTimeout.current);
|
||||||
|
window.clearTimeout(dislikedTimeout.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -116,6 +142,22 @@ export function PreferencesPage() {
|
||||||
}, ALLERGIES_DEBOUNCE_MS);
|
}, ALLERGIES_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDislikedIngredientIdsChange(newIds: number[]) {
|
||||||
|
setDislikedIngredientIds(newIds);
|
||||||
|
window.clearTimeout(dislikedTimeout.current);
|
||||||
|
setDislikedSaveState("saving");
|
||||||
|
dislikedTimeout.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.updateDislikedIngredientIds(newIds);
|
||||||
|
setDislikedSaveState("saved");
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setDislikedSaveError(errorMessageService.getLabel(code));
|
||||||
|
setDislikedSaveState("error");
|
||||||
|
}
|
||||||
|
}, ALLERGIES_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
|
|
@ -160,6 +202,15 @@ export function PreferencesPage() {
|
||||||
/>
|
/>
|
||||||
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
|
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-page__section">
|
||||||
|
<DislikedIngredientsField
|
||||||
|
ingredients={ingredients}
|
||||||
|
value={dislikedIngredientIds}
|
||||||
|
onChange={handleDislikedIngredientIdsChange}
|
||||||
|
/>
|
||||||
|
<SaveStatus state={dislikedSaveState} error={dislikedSaveError} t={t} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,12 @@ export enum ErrorCode {
|
||||||
NOT_AUTHENTICATED = 4011,
|
NOT_AUTHENTICATED = 4011,
|
||||||
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
|
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
|
||||||
ALREADY_HAS_HOUSE = 4020,
|
ALREADY_HAS_HOUSE = 4020,
|
||||||
|
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
||||||
|
RECIPE_IN_USE = 4021,
|
||||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||||
NOT_HOUSE_ADMIN = 4030,
|
NOT_HOUSE_ADMIN = 4030,
|
||||||
|
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||||||
|
NOT_RECIPE_AUTHOR = 4031,
|
||||||
/** No route/resource matches the request. */
|
/** No route/resource matches the request. */
|
||||||
NOT_FOUND = 4040,
|
NOT_FOUND = 4040,
|
||||||
/** The profile making the request has no household yet (`houseId` is `null`). */
|
/** The profile making the request has no household yet (`houseId` is `null`). */
|
||||||
|
|
@ -48,6 +52,10 @@ export enum ErrorCode {
|
||||||
ALLERGY_NOT_FOUND = 4043,
|
ALLERGY_NOT_FOUND = 4043,
|
||||||
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
|
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
|
||||||
INVITE_CODE_NOT_FOUND = 4044,
|
INVITE_CODE_NOT_FOUND = 4044,
|
||||||
|
/** `GET /recipes/:id`, `PATCH /recipes/:id` or `DELETE /recipes/:id` given an id that doesn't match any recipe. */
|
||||||
|
RECIPE_NOT_FOUND = 4045,
|
||||||
|
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
||||||
|
INGREDIENT_NOT_FOUND = 4046,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ export * from "./schemas/household.js";
|
||||||
export * from "./schemas/planning.js";
|
export * from "./schemas/planning.js";
|
||||||
export * from "./schemas/preferences.js";
|
export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
|
export * from "./schemas/recipe.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
export * from "./types/preferences.js";
|
export * from "./types/preferences.js";
|
||||||
|
export * from "./types/recipe.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,15 @@ export const updateAllergiesSchema = z.object({
|
||||||
});
|
});
|
||||||
/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
|
/** Inferred TS type for {@link updateAllergiesSchema}'s validated output. */
|
||||||
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>;
|
export type UpdateAllergiesInput = z.infer<typeof updateAllergiesSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `PATCH /profile/disliked-ingredients`. Replaces the
|
||||||
|
* profile's full "disliked" set — a personal taste preference, not a
|
||||||
|
* medical restriction (see `updateAllergiesSchema` above for that distinct
|
||||||
|
* list) — an empty array clears it.
|
||||||
|
*/
|
||||||
|
export const updateDislikedIngredientsSchema = z.object({
|
||||||
|
dislikedIngredientIds: z.array(z.number().int().positive()),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link updateDislikedIngredientsSchema}'s validated output. */
|
||||||
|
export type UpdateDislikedIngredientsInput = z.infer<typeof updateDislikedIngredientsSchema>;
|
||||||
|
|
|
||||||
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,146 @@ export interface AllergyView {
|
||||||
name: string;
|
name: string;
|
||||||
kind: AllergenKind;
|
kind: AllergenKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supermarket-aisle grouping ("rayons") — mirrors `IngredientCategory` in
|
||||||
|
* schema.prisma, declared by hand for the same reason as
|
||||||
|
* {@link AllergenKind}. Lets the ingredient picker (`apps/web`'s
|
||||||
|
* `IngredientPicker`) offer category browsing, not just free-text search:
|
||||||
|
* with 400+ reference ingredients, search alone doesn't scale to actually
|
||||||
|
* *finding* one. See {@link INGREDIENT_SUBCATEGORIES} for the finer-grained
|
||||||
|
* rack within each aisle.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_CATEGORIES = [
|
||||||
|
"PRODUITS_FRAIS",
|
||||||
|
"BOUCHERIE_POISSONNERIE",
|
||||||
|
"EPICERIE_SECHE",
|
||||||
|
"BOULANGERIE",
|
||||||
|
"CREMERIE_FROMAGE",
|
||||||
|
"CONDIMENTS_EPICES",
|
||||||
|
"AIDES_CULINAIRES",
|
||||||
|
] as const;
|
||||||
|
/** Inferred TS type for one {@link INGREDIENT_CATEGORIES} member. */
|
||||||
|
export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finer-grained rack within one {@link IngredientCategory} aisle — mirrors
|
||||||
|
* `IngredientSubcategory` in schema.prisma. See
|
||||||
|
* {@link INGREDIENT_CATEGORY_SUBCATEGORIES} for which of these belongs to
|
||||||
|
* which category, and in what display order.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_SUBCATEGORIES = [
|
||||||
|
"LEGUMES",
|
||||||
|
"FRUITS",
|
||||||
|
"HERBES_FRAICHES",
|
||||||
|
"VIANDES",
|
||||||
|
"VOLAILLES",
|
||||||
|
"POISSONS",
|
||||||
|
"CRUSTACES_FRUITS_DE_MER",
|
||||||
|
"FECULENTS",
|
||||||
|
"LEGUMINEUSES",
|
||||||
|
"GRAINES_FRUITS_SECS",
|
||||||
|
"AUTRES",
|
||||||
|
"PAINS",
|
||||||
|
"PATES_A_CUIRE",
|
||||||
|
"PRODUITS_LAITIERS",
|
||||||
|
"OEUFS",
|
||||||
|
"ALTERNATIVES",
|
||||||
|
"EPICES",
|
||||||
|
"SAUCES",
|
||||||
|
"ASSAISONNEMENTS",
|
||||||
|
"BASES",
|
||||||
|
"EPAISSISSANTS",
|
||||||
|
"SUCRES",
|
||||||
|
] as const;
|
||||||
|
/** Inferred TS type for one {@link INGREDIENT_SUBCATEGORIES} member. */
|
||||||
|
export type IngredientSubcategory = (typeof INGREDIENT_SUBCATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which {@link INGREDIENT_SUBCATEGORIES} belong to which
|
||||||
|
* {@link INGREDIENT_CATEGORIES} aisle, in the order the picker should list
|
||||||
|
* them — the two-level drill-down `IngredientPicker` (`apps/web`) needs
|
||||||
|
* this to know which subcategory chips to offer once a category is picked.
|
||||||
|
* Mirrors `apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`,
|
||||||
|
* which is the actual source of truth for which *ingredient* belongs to
|
||||||
|
* which pair — this is just the category→subcategory shape of that data,
|
||||||
|
* duplicated here since the seed file isn't reachable from `apps/web`.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_CATEGORY_SUBCATEGORIES: Record<
|
||||||
|
IngredientCategory,
|
||||||
|
readonly IngredientSubcategory[]
|
||||||
|
> = {
|
||||||
|
PRODUITS_FRAIS: ["LEGUMES", "FRUITS", "HERBES_FRAICHES"],
|
||||||
|
BOUCHERIE_POISSONNERIE: ["VIANDES", "VOLAILLES", "POISSONS", "CRUSTACES_FRUITS_DE_MER"],
|
||||||
|
EPICERIE_SECHE: ["FECULENTS", "LEGUMINEUSES", "GRAINES_FRUITS_SECS", "AUTRES"],
|
||||||
|
BOULANGERIE: ["PAINS", "PATES_A_CUIRE"],
|
||||||
|
CREMERIE_FROMAGE: ["PRODUITS_LAITIERS", "OEUFS", "ALTERNATIVES"],
|
||||||
|
CONDIMENTS_EPICES: ["EPICES", "SAUCES", "ASSAISONNEMENTS"],
|
||||||
|
AIDES_CULINAIRES: ["BASES", "EPAISSISSANTS", "SUCRES"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic pictogram *type* for an ingredient — mirrors `IngredientIcon` in
|
||||||
|
* schema.prisma, declared by hand for the same reason as
|
||||||
|
* {@link AllergenKind}. Was a free-text emoji per ingredient (437 distinct
|
||||||
|
* characters); replaced with this small, shared vocabulary of ~20 generic
|
||||||
|
* shapes ("a vegetable", "a bottle", "a wedge of cheese"…) so
|
||||||
|
* `apps/web`'s `features/recipes/ingredient-icons.tsx` can render a real
|
||||||
|
* SVG line icon per ingredient instead — see that file for the actual
|
||||||
|
* pictograms and the full reasoning.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_ICONS = [
|
||||||
|
"VEGETABLE",
|
||||||
|
"FRUIT",
|
||||||
|
"HERB",
|
||||||
|
"MEAT",
|
||||||
|
"POULTRY",
|
||||||
|
"FISH",
|
||||||
|
"SHELLFISH",
|
||||||
|
"GRAIN",
|
||||||
|
"LEGUME",
|
||||||
|
"NUT_SEED",
|
||||||
|
"BREAD",
|
||||||
|
"DOUGH",
|
||||||
|
"MILK",
|
||||||
|
"CHEESE",
|
||||||
|
"EGG",
|
||||||
|
"SPROUT",
|
||||||
|
"SPICE",
|
||||||
|
"JAR",
|
||||||
|
"BOTTLE",
|
||||||
|
"DRINK",
|
||||||
|
"STOCK_POT",
|
||||||
|
"SUGAR",
|
||||||
|
] as const;
|
||||||
|
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
|
||||||
|
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||||
|
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||||
|
* reference-seed-data.ts`), same "static, non-administrable" status as
|
||||||
|
* {@link DietView}/{@link AllergyView}: no create/update/delete endpoint
|
||||||
|
* exists for it, only the seed populates it.
|
||||||
|
*
|
||||||
|
* `allergens` is resolved server-side from the `IngredientAllergy` join
|
||||||
|
* table — empty for an ingredient that carries none of the 14 EU-regulated
|
||||||
|
* allergens. `diets` is resolved from `IngredientDiet` the same way — the
|
||||||
|
* regimes this ingredient is compatible with (e.g. `Végétarien`, `Végan`),
|
||||||
|
* so the picker can flag it without the user opening its packaging. Omits
|
||||||
|
* `Omnivore` (every ingredient qualifies, so it's never stored) and
|
||||||
|
* `Sans gluten` (already derivable from whether `allergens` contains
|
||||||
|
* `Gluten` — see `IngredientDiet` in schema.prisma). Used by the recipe
|
||||||
|
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
|
||||||
|
* to surface which allergens/regimes a recipe contains, aggregated across
|
||||||
|
* its ingredients.
|
||||||
|
*/
|
||||||
|
export interface IngredientView {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon: IngredientIcon;
|
||||||
|
category: IngredientCategory;
|
||||||
|
subcategory: IngredientSubcategory;
|
||||||
|
allergens: AllergyView[];
|
||||||
|
diets: DietView[];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue