Merge pull request #33 from kyuno053/feat/ingredient-unit-catalog

feat(recipes): catalogue de référence pour les unités d'ingrédients
This commit is contained in:
kyuno053 2026-08-19 22:47:28 +02:00 committed by GitHub
commit 8884d28341
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 381 additions and 67 deletions

View file

@ -0,0 +1,42 @@
-- Adds the `unit` reference catalog (key/type/to_base_factor) so recipe
-- ingredient units are a closed, normalized set instead of free text —
-- groundwork for a future unit-conversion feature (e.g. a shopping list
-- summing "500g" + "0.5kg" of the same ingredient), not that feature
-- itself. Seeded by reference-seed-data.ts's UNITS, same "SQL creates the
-- shape, application code seeds the rows" split as Diet/Allergy/Ingredient.
--
-- `recipe_ingredient.unit` (free text) is replaced by `unit_id` (FK), with
-- no backfill: a free-text value like "cas" or "grammes" can't be reliably
-- mapped to a catalog key without a human in the loop. Acceptable as a
-- straight breaking change here — the app has no real recipes yet
-- (pre-launch) — rather than staging `unit_id` as nullable across a
-- transition nothing will ever populate.
/*
Warnings:
- You are about to drop the column `unit` on the `recipe_ingredient` table. All the data in the column will be lost.
- Added the required column `unit_id` to the `recipe_ingredient` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "UnitType" AS ENUM ('MASS', 'VOLUME', 'COUNT');
-- AlterTable
ALTER TABLE "recipe_ingredient" DROP COLUMN "unit",
ADD COLUMN "unit_id" INTEGER NOT NULL;
-- CreateTable
CREATE TABLE "unit" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"type" "UnitType" NOT NULL,
"to_base_factor" DECIMAL(12,4) NOT NULL DEFAULT 1,
CONSTRAINT "unit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "unit_key_key" ON "unit"("key");
-- AddForeignKey
ALTER TABLE "recipe_ingredient" ADD CONSTRAINT "recipe_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -500,6 +500,44 @@ model IngredientAllergy {
@@map("ingredient_allergy") @@map("ingredient_allergy")
} }
/// Which physical quantity a {@link Unit} measures — only units of the same
/// type are ever mutually convertible via `toBaseFactor` (grams and
/// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT
/// but converting between *them* would need per-ingredient data no catalog
/// entry alone can provide, so COUNT units just don't convert to each
/// other, each stands alone with `toBaseFactor = 1`).
enum UnitType {
MASS
VOLUME
COUNT
}
/// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as
/// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the
/// display label — the label lives in `apps/web`'s
/// `locales/fr/translation.json` under `catalog.units.<key>` (see
/// `reference-seed-data.ts`'s `UNITS`).
///
/// Not in the original spec doc — `RecipeIngredient.unit` used to be free
/// text ("g", "grammes", "G"…), which can never be reliably summed/converted
/// (a future shopping list can't tell "g" and "grammes" are the same unit).
/// This closes that off: `unit` is now a normalized, finite catalog.
/// `toBaseFactor` is how many of this type's base unit (gram for MASS,
/// milliliter for VOLUME, itself for COUNT) one of this unit equals —
/// laying the groundwork for a future conversion feature (e.g. summing
/// "500g" + "0.5kg" of the same ingredient into "1kg") without building
/// that feature itself yet.
model Unit {
id Int @id @default(autoincrement())
key String @unique
type UnitType
toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor")
recipeIngredients RecipeIngredient[]
@@map("unit")
}
/// 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
@ -508,10 +546,11 @@ model RecipeIngredient {
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id") ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2) quantity Decimal @db.Decimal(10, 2)
unit String unitId Int @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId]) @@id([recipeId, ingredientId])
@@map("recipe_ingredient") @@map("recipe_ingredient")

View file

@ -4,6 +4,7 @@ import type {
IngredientIcon, IngredientIcon,
IngredientSubcategory, IngredientSubcategory,
PrismaClient, PrismaClient,
UnitType,
} from "@prisma/client"; } from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays // Short, optional-to-pick regime list — `UserProfile.dietId` stays
@ -15,6 +16,33 @@ import type {
// maintained independently, tied together only by this same uid string. // maintained independently, tied together only by this same uid string.
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"]; export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
// Recipe ingredient units — a closed, normalized set replacing what used to
// be free text (see `Unit`/`RecipeIngredient.unitId` in schema.prisma for
// why). `toBaseFactor` is how many of the type's base unit (gram for MASS,
// milliliter for VOLUME, itself for COUNT) one of this unit equals — MASS
// and VOLUME units convert against each other within their own type, COUNT
// units don't convert to one another at all (a "pincée" isn't a fixed
// fraction of a "gousse"), so each just gets `1`. Same "English camelCase
// uid, no French label" authoring as `DIETS`/`ALLERGENS` — the display
// label lives in `apps/web`'s `locales/fr/translation.json` under
// `catalog.units.<key>`.
export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> = [
{ uid: "gram", type: "MASS", toBaseFactor: 1 },
{ uid: "kilogram", type: "MASS", toBaseFactor: 1000 },
{ uid: "milliliter", type: "VOLUME", toBaseFactor: 1 },
{ uid: "centiliter", type: "VOLUME", toBaseFactor: 10 },
{ uid: "liter", type: "VOLUME", toBaseFactor: 1000 },
{ uid: "tablespoon", type: "VOLUME", toBaseFactor: 15 },
{ uid: "teaspoon", type: "VOLUME", toBaseFactor: 5 },
{ uid: "piece", type: "COUNT", toBaseFactor: 1 },
{ uid: "pinch", type: "COUNT", toBaseFactor: 1 },
{ uid: "slice", type: "COUNT", toBaseFactor: 1 },
{ uid: "clove", type: "COUNT", toBaseFactor: 1 },
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than // businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune // an invented one. Split into ALLERGY (classic IgE-mediated immune
@ -1086,6 +1114,17 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } }); await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
} }
// `update: { type, toBaseFactor }` (not `{}`) — same reasoning as
// `ALLERGENS`' `kind` below: a reseed must correct a unit's
// type/toBaseFactor if it's ever edited above, not just skip existing rows.
for (const { uid: key, type, toBaseFactor } of UNITS) {
await prisma.unit.upsert({
where: { key },
update: { type, toBaseFactor },
create: { key, type, toBaseFactor },
});
}
// `Allergy` itself carries no `key` — it's the selectable instance of a // `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one // keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it, // Category (upserted by key) plus exactly one Allergy row under it,

View file

@ -8,6 +8,7 @@ import {
type RecipeSummaryView, type RecipeSummaryView,
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
@ -24,6 +25,7 @@ function recipeInclude(viewerId: number) {
diets: { include: { diet: true } }, diets: { include: { diet: true } },
}, },
}, },
unit: true,
}, },
}, },
steps: { orderBy: { order: "asc" } }, steps: { orderBy: { order: "asc" } },
@ -34,6 +36,12 @@ function recipeInclude(viewerId: number) {
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>; type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
function toUnitView(unit: UnitWithDetails): UnitView {
return { id: unit.id, key: unit.key, type: unit.type, toBaseFactor: Number(unit.toBaseFactor) };
}
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ /** 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 { function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
@ -92,7 +100,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
quantity: Number(recipeIngredient.quantity), quantity: Number(recipeIngredient.quantity),
unit: recipeIngredient.unit, unit: toUnitView(recipeIngredient.unit),
})); }));
return { return {
...toRecipeSummaryView(recipe), ...toRecipeSummaryView(recipe),
@ -293,6 +301,7 @@ export async function getRecipe(
* later edits (see {@link updateRecipe}). * later edits (see {@link updateRecipe}).
* *
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/ */
export async function createRecipe( export async function createRecipe(
@ -301,6 +310,7 @@ export async function createRecipe(
authorHouseId: number | null, authorHouseId: number | null,
): Promise<RecipeView> { ): Promise<RecipeView> {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
const created = await prisma.recipe.create({ const created = await prisma.recipe.create({
@ -316,7 +326,7 @@ export async function createRecipe(
create: input.ingredients.map((ingredient) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId, ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity, quantity: ingredient.quantity,
unit: ingredient.unit, unitId: ingredient.unitId,
})), })),
}, },
steps: { steps: {
@ -343,6 +353,7 @@ export async function createRecipe(
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`. * @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} `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 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/ */
export async function updateRecipe( export async function updateRecipe(
@ -353,6 +364,7 @@ export async function updateRecipe(
): Promise<RecipeView> { ): Promise<RecipeView> {
await assertIsAuthor(id, viewerId, viewerHouseId); await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
await prisma.$transaction([ await prisma.$transaction([
@ -371,7 +383,7 @@ export async function updateRecipe(
create: input.ingredients.map((ingredient) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId, ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity, quantity: ingredient.quantity,
unit: ingredient.unit, unitId: ingredient.unitId,
})), })),
}, },
steps: { steps: {
@ -507,6 +519,20 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
} }
} }
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
async function assertUnitsExist(unitIds: number[]): Promise<void> {
const uniqueIds = [...new Set(unitIds)];
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */ /** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> { async function assertDietsExist(dietIds: number[]): Promise<void> {
const uniqueIds = [...new Set(dietIds)]; const uniqueIds = [...new Set(dietIds)];

View file

@ -1,6 +1,6 @@
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, getIngredients } from "./reference.service.js"; import { getAllergies, getDiets, getIngredients, getUnits } from "./reference.service.js";
/** /**
* Router mounted at `/reference` in app.ts. Every route is deliberately * Router mounted at `/reference` in app.ts. Every route is deliberately
@ -33,3 +33,10 @@ referenceRouter.get(
res.status(200).json(await getIngredients()); res.status(200).json(await getIngredients());
}), }),
); );
referenceRouter.get(
"/units",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getUnits());
}),
);

View file

@ -1,4 +1,4 @@
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; import type { AllergyView, DietView, IngredientView, UnitView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
/** /**
@ -32,6 +32,23 @@ export async function getAllergies(): Promise<AllergyView[]> {
})); }));
} }
/**
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
* for why) small, static list (see `reference-seed-data.ts`'s `UNITS`).
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
* `number` here the same way `recipe.service.ts` does for
* `RecipeIngredient.quantity`.
*/
export async function getUnits(): Promise<UnitView[]> {
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
return units.map((unit) => ({
id: unit.id,
key: unit.key,
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
}));
}
/** /**
* All reference ingredients, ordered by key (see {@link getDiets} for why), * All reference ingredients, ordered by key (see {@link getDiets} for why),
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma) * each resolved to its allergens (see `IngredientAllergy` in schema.prisma)

View file

@ -3,17 +3,17 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity // Single TRUNCATE ... CASCADE covers FK ordering and resets identity
// sequences — used between tests/scenarios to start from a clean slate. // sequences — used between tests/scenarios to start from a clean slate.
// Re-seeds the Diet/Category/Allergy reference data right after truncating // Re-seeds the Diet/Category/Allergy/Unit reference data right after
// it, so every test starts from the same realistic reference data the real // truncating it, so every test starts from the same realistic reference
// app seeds (`prisma/seed.ts`) rather than empty tables — tests exercising // data the real app seeds (`prisma/seed.ts`) rather than empty tables —
// dietId/allergyIds need real rows to reference. // tests exercising dietId/allergyIds/unitId need real rows to reference.
export async function resetDatabase() { export async function resetDatabase() {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
TRUNCATE TABLE TRUNCATE TABLE
"user_profile_allergy", "user_preference", "allergy", "category", "user_profile_allergy", "user_preference", "allergy", "category",
"planning_item", "planning", "planning_item", "planning",
"recipe_ingredient", "step", "tech_step_mapping", "tech_step", "recipe_ingredient", "step", "tech_step_mapping", "tech_step",
"recipe", "ingredients", "sources", "recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house" "user_profiles", "diet", "house"
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);

View file

@ -25,6 +25,12 @@ async function ingredientId(key: string): Promise<number> {
return ingredient.id; return ingredient.id;
} }
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Recipes", () => { describe("Recipes", () => {
const app = createApp(); const app = createApp();
@ -167,6 +173,7 @@ describe("Recipes", () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const oeuf = await ingredientId("egg"); const oeuf = await ingredientId("egg");
const piece = await unitId("piece");
const vegetarien = await prisma.diet.findFirstOrThrow({ const vegetarien = await prisma.diet.findFirstOrThrow({
where: { key: "vegetarian" }, where: { key: "vegetarian" },
}); });
@ -177,8 +184,8 @@ describe("Recipes", () => {
portions: 2, portions: 2,
dietIds: [vegetarien.id], dietIds: [vegetarien.id],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 2, unit: "unité" }, { ingredientId: tomate, quantity: 2, unitId: piece },
{ ingredientId: oeuf, quantity: 3, unit: "unité" }, { ingredientId: oeuf, quantity: 3, unitId: piece },
], ],
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }], steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
}); });
@ -187,6 +194,7 @@ describe("Recipes", () => {
expect(res.body.name).to.equal("Omelette provençale"); expect(res.body.name).to.equal("Omelette provençale");
expect(res.body.portions).to.equal(2); expect(res.body.portions).to.equal(2);
expect(res.body.ingredients).to.have.length(2); expect(res.body.ingredients).to.have.length(2);
expect(res.body.ingredients[0].unit.key).to.equal("piece");
expect( expect(
res.body.steps.map((s: { description: string; order: number }) => s.order), res.body.steps.map((s: { description: string; order: number }) => s.order),
).to.deep.equal([0, 1]); ).to.deep.equal([0, 1]);
@ -199,12 +207,13 @@ describe("Recipes", () => {
const { agent } = await signup(); const { agent } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -216,7 +225,7 @@ describe("Recipes", () => {
visibility: "HOUSE", visibility: "HOUSE",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" }); const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
@ -226,12 +235,13 @@ describe("Recipes", () => {
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }], ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -239,15 +249,32 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
}); });
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [999_999], dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -273,10 +300,11 @@ describe("Recipes", () => {
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => { it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const basePayload = { const basePayload = {
name: "Test", name: "Test",
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}; };
@ -303,11 +331,12 @@ describe("Recipes", () => {
it("returns the full recipe detail", async () => { it("returns the full recipe detail", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -317,6 +346,7 @@ describe("Recipes", () => {
expect(res.body.name).to.equal("Salade"); expect(res.body.name).to.equal("Salade");
expect(res.body.portions).to.equal(4); expect(res.body.portions).to.equal(4);
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato"); expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
expect(res.body.ingredients[0].unit.key).to.equal("piece");
expect(res.body.isFavorite).to.equal(false); expect(res.body.isFavorite).to.equal(false);
}); });
@ -386,11 +416,13 @@ describe("Recipes", () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const oignon = await ingredientId("onion"); const oignon = await ingredientId("onion");
const piece = await unitId("piece");
const gram = await unitId("gram");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -399,7 +431,7 @@ describe("Recipes", () => {
portions: 6, portions: 6,
visibility: "PUBLIC", visibility: "PUBLIC",
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }], ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
steps: [{ description: "Émincer" }, { description: "Mélanger" }], steps: [{ description: "Émincer" }, { description: "Mélanger" }],
}); });
@ -409,18 +441,20 @@ describe("Recipes", () => {
expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1); expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.key).to.equal("onion"); expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
expect(res.body.ingredients[0].unit.key).to.equal("gram");
expect(res.body.steps).to.have.length(2); expect(res.body.steps).to.have.length(2);
}); });
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.patch("/recipes/999999").send({ const res = await agent.patch("/recipes/999999").send({
name: "Test", name: "Test",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });
@ -431,11 +465,12 @@ describe("Recipes", () => {
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => { it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -443,7 +478,7 @@ describe("Recipes", () => {
name: "Salade", name: "Salade",
portions: 4, portions: 4,
dietIds: [999_999], dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
}); });
@ -455,6 +490,7 @@ describe("Recipes", () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 }, data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
@ -463,7 +499,7 @@ describe("Recipes", () => {
name: "Hack", name: "Hack",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
}); });

View file

@ -74,4 +74,26 @@ describe("Reference data", () => {
expect(byKey("tomato").allergens).to.deep.equal([]); expect(byKey("tomato").allergens).to.deep.equal([]);
}); });
}); });
describe("GET /reference/units", () => {
it("returns the seeded units, no session required", async () => {
const res = await request(app).get("/reference/units");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(14);
expect(res.body.map((u: { key: string }) => u.key)).to.include("gram");
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
});
it("resolves MASS/VOLUME toBaseFactor against their type's base unit, COUNT units all at 1", async () => {
const res = await request(app).get("/reference/units");
const byKey = (key: string) => res.body.find((u: { key: string }) => u.key === key);
expect(byKey("gram")).to.include({ type: "MASS", toBaseFactor: 1 });
expect(byKey("kilogram")).to.include({ type: "MASS", toBaseFactor: 1000 });
expect(byKey("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
});
});
}); });

View file

@ -21,7 +21,7 @@ Feature: Recipe form — associating ingredients
And I fill in the step description with "Couper les tomates." And I fill in the step description with "Couper les tomates."
Then the "Enregistrer" button should not be disabled Then the "Enregistrer" button should not be disabled
When I click the button "Enregistrer" When I click the button "Enregistrer"
Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unit "unité" Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unitId 1
And the URL should include "/recettes/42" And the URL should include "/recettes/42"
# Regression test for the exact bug reported: `crypto.randomUUID()` (used # Regression test for the exact bug reported: `crypto.randomUUID()` (used
@ -64,6 +64,6 @@ Feature: Recipe form — associating ingredients
When I fill in the last ingredient's quantity with "1" and unit "unité" When I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Enregistrer" And I click the button "Enregistrer"
Then the recipe update request should have included these ingredients: Then the recipe update request should have included these ingredients:
| ingredientId | quantity | unit | | ingredientId | quantity | unitId |
| 2 | 3 | unité | | 2 | 3 | 1 |
| 1 | 1 | unité | | 1 | 1 | 1 |

View file

@ -33,9 +33,13 @@ const diets = [
{ id: 2, key: "vegetarian" }, { id: 2, key: "vegetarian" },
]; ];
const pieceUnit = { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 };
const gramUnit = { id: 2, key: "gram", type: "MASS", toBaseFactor: 1 };
Given("the ingredient and diet catalog is available", () => { Given("the ingredient and diet catalog is available", () => {
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] }); cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] });
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [pieceUnit, gramUnit] });
}); });
Given("creating the recipe will succeed and return id {int}", (id: number) => { Given("creating the recipe will succeed and return id {int}", (id: number) => {
@ -85,7 +89,7 @@ When(
"I fill in the ingredient's quantity with {string} and unit {string}", "I fill in the ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => { (quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").type(unit); cy.get(".ingredient-row .ingredient-row__unit").select(unit);
}, },
); );
@ -97,7 +101,7 @@ When(
"I fill in the last ingredient's quantity with {string} and unit {string}", "I fill in the last ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => { (quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").last().type(unit); cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
}, },
); );
@ -114,14 +118,14 @@ Then("there should be {int} step editor items", (count: number) => {
}); });
Then( Then(
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}", "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
(name: string, portions: number, ingredientId: number, quantity: number, unit: string) => { (name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@createRecipe") cy.wait("@createRecipe")
.its("request.body") .its("request.body")
.should("deep.include", { .should("deep.include", {
name, name,
portions, portions,
ingredients: [{ ingredientId, quantity, unit }], ingredients: [{ ingredientId, quantity, unitId }],
}); });
}, },
); );
@ -138,7 +142,7 @@ Given("recipe 7 exists with an egg omelette", () => {
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [], diets: [],
isFavorite: false, isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
}; };
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
@ -154,7 +158,7 @@ Then(
const expected = dataTable.hashes().map((row) => ({ const expected = dataTable.hashes().map((row) => ({
ingredientId: Number(row.ingredientId), ingredientId: Number(row.ingredientId),
quantity: Number(row.quantity), quantity: Number(row.quantity),
unit: row.unit, unitId: Number(row.unitId),
})); }));
cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected); cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected);
}, },

View file

@ -60,7 +60,7 @@ const omeletteDetail = {
diets: [], diets: [],
}, },
quantity: 3, quantity: 3,
unit: "unité", unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
}, },
], ],
steps: [ steps: [

View file

@ -30,7 +30,7 @@ const omeletteDetail = {
diets: [], diets: [],
}, },
quantity: 3, quantity: 3,
unit: "unité", unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
}, },
], ],
steps: [ steps: [

View file

@ -17,6 +17,7 @@ import {
type SafeUserProfile, type SafeUserProfile,
type SignupInput, type SignupInput,
type ThemePreference, type ThemePreference,
type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -161,6 +162,11 @@ export class ApiClient {
return this.request("/reference/ingredients"); return this.request("/reference/ingredients");
} }
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
public getUnits(): Promise<UnitView[]> {
return this.request("/reference/units");
}
/** /**
* One catalog tab (favoris/perso/foyer/publique see `RecipeTab`), * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring), * optionally narrowed further `search` (name substring),

View file

@ -1,4 +1,4 @@
import type { IngredientView } from "@batch-cooking/shared"; import type { IngredientView, UnitView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges"; import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges"; import { DietBadges } from "./DietBadges";
@ -6,20 +6,22 @@ import { ReproducibleBadge } from "./ReproducibleBadge";
import { IngredientTypeIcon } from "./ingredient-icons"; import { IngredientTypeIcon } from "./ingredient-icons";
import "./recipes.scss"; 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. */ /** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
export function IngredientRow({ export function IngredientRow({
ingredient, ingredient,
quantity, quantity,
unit, unitId,
unitsCatalog,
onQuantityChange, onQuantityChange,
onUnitChange, onUnitChange,
onRemove, onRemove,
}: { }: {
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unit: string; unitId: number | null;
unitsCatalog: UnitView[];
onQuantityChange: (quantity: string) => void; onQuantityChange: (quantity: string) => void;
onUnitChange: (unit: string) => void; onUnitChange: (unitId: number) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -39,14 +41,21 @@ export function IngredientRow({
onChange={(e) => onQuantityChange(e.target.value)} onChange={(e) => onQuantityChange(e.target.value)}
aria-label={t("recipes.form.quantityLabel")} aria-label={t("recipes.form.quantityLabel")}
/> />
<input <select
type="text"
className="ingredient-row__unit" className="ingredient-row__unit"
value={unit} value={unitId ?? ""}
onChange={(e) => onUnitChange(e.target.value)} onChange={(e) => onUnitChange(Number(e.target.value))}
placeholder={t("recipes.form.unitPlaceholder")}
aria-label={t("recipes.form.unitLabel")} aria-label={t("recipes.form.unitLabel")}
/> >
<option value="" disabled>
{t("recipes.form.unitPlaceholder")}
</option>
{unitsCatalog.map((unit) => (
<option key={unit.id} value={unit.id}>
{t(`catalog.units.${unit.key}`)}
</option>
))}
</select>
<AllergenBadges allergens={ingredient.allergens} /> <AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} /> <DietBadges diets={ingredient.diets} />
<ReproducibleBadge <ReproducibleBadge

View file

@ -722,8 +722,12 @@
width: 5rem; width: 5rem;
} }
// Wide enough for the longest catalog label ("cuillère à soupe") not to
// clip was sized for the old free-text input's short values ("g",
// "unité") before unit became a <select> over the reference catalog
// (see IngredientRow.tsx), left too narrow for that one.
&__unit { &__unit {
width: 6rem; width: 11rem;
} }
&__remove { &__remove {

View file

@ -20,6 +20,7 @@
"RECIPE_NOT_FOUND": "Cette recette n'existe pas", "RECIPE_NOT_FOUND": "Cette recette n'existe pas",
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning", "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", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées 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": {
@ -228,7 +229,7 @@
}, },
"quantityLabel": "Quantité", "quantityLabel": "Quantité",
"unitLabel": "Unité", "unitLabel": "Unité",
"unitPlaceholder": "g, ml, unité…", "unitPlaceholder": "Choisir une unité",
"removeIngredient": "Retirer cet ingrédient", "removeIngredient": "Retirer cet ingrédient",
"stepDescriptionPlaceholder": "Décrivez cette étape…", "stepDescriptionPlaceholder": "Décrivez cette étape…",
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)", "stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
@ -319,6 +320,22 @@
} }
}, },
"catalog": { "catalog": {
"units": {
"gram": "g",
"kilogram": "kg",
"milliliter": "ml",
"centiliter": "cl",
"liter": "l",
"tablespoon": "cuillère à soupe",
"teaspoon": "cuillère à café",
"piece": "unité",
"pinch": "pincée",
"slice": "tranche",
"clove": "gousse",
"bunch": "botte",
"sachet": "sachet",
"sprig": "brin"
},
"diets": { "diets": {
"omnivore": "Omnivore", "omnivore": "Omnivore",
"vegetarian": "Végétarien", "vegetarian": "Végétarien",

View file

@ -4,6 +4,7 @@ import {
ErrorCode, ErrorCode,
type IngredientView, type IngredientView,
type RecipeVisibility, type RecipeVisibility,
type UnitView,
createRecipeSchema, createRecipeSchema,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
@ -21,12 +22,12 @@ import { errorMessageService } from "../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. */ /** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. `unitId` is `null` until the user picks one (no default — unlike `portions`, there's no single "usually right" unit across every ingredient); `canSubmit` gates on every line having one set before allowing save. */
interface IngredientLine { interface IngredientLine {
key: string; key: string;
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unit: string; unitId: number | null;
} }
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */ /** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
@ -50,6 +51,7 @@ export function RecipeFormPage() {
const [loadState, setLoadState] = useState<LoadState>("loading"); const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]); const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]); const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@ -74,12 +76,14 @@ export function RecipeFormPage() {
Promise.all([ Promise.all([
apiClient.getIngredients(), apiClient.getIngredients(),
apiClient.getDiets(), apiClient.getDiets(),
apiClient.getUnits(),
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
]) ])
.then(([ingredients, diets, recipe]) => { .then(([ingredients, diets, units, recipe]) => {
if (cancelled) return; if (cancelled) return;
setIngredientsCatalog(ingredients); setIngredientsCatalog(ingredients);
setDietsCatalog(diets); setDietsCatalog(diets);
setUnitsCatalog(units);
if (recipe) { if (recipe) {
setName(recipe.name); setName(recipe.name);
setDescription(recipe.description ?? ""); setDescription(recipe.description ?? "");
@ -92,7 +96,7 @@ export function RecipeFormPage() {
key: makeClientKey(), key: makeClientKey(),
ingredient: line.ingredient, ingredient: line.ingredient,
quantity: String(line.quantity), quantity: String(line.quantity),
unit: line.unit, unitId: line.unit.id,
})), })),
); );
setSteps( setSteps(
@ -117,13 +121,13 @@ export function RecipeFormPage() {
function addIngredient(ingredient: IngredientView) { function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [ setIngredientLines((lines) => [
...lines, ...lines,
{ key: makeClientKey(), ingredient, quantity: "", unit: "" }, { key: makeClientKey(), ingredient, quantity: "", unitId: null },
]); ]);
} }
function updateIngredientLine( function updateIngredientLine(
key: string, key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unit">>, patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
) { ) {
setIngredientLines((lines) => setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
@ -142,7 +146,7 @@ export function RecipeFormPage() {
Number.isInteger(Number(portions)) && Number.isInteger(Number(portions)) &&
Number(portions) > 0 && Number(portions) > 0 &&
ingredientLines.length > 0 && ingredientLines.length > 0 &&
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) && ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
steps.length > 0 && steps.length > 0 &&
steps.every((step) => step.description.trim().length > 0); steps.every((step) => step.description.trim().length > 0);
@ -160,7 +164,12 @@ export function RecipeFormPage() {
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
ingredientId: line.ingredient.id, ingredientId: line.ingredient.id,
quantity: Number(line.quantity), quantity: Number(line.quantity),
unit: line.unit.trim(), // `canSubmit` already requires every line to have a unit picked
// before the button is enabled — `?? 0` is just to satisfy the
// type here; if it's ever reached with no unit set, the schema's
// `positive()` check rejects it the same way an invalid quantity
// already does.
unitId: line.unitId ?? 0,
})), })),
steps: steps.map((step) => ({ steps: steps.map((step) => ({
description: step.description.trim(), description: step.description.trim(),
@ -264,9 +273,10 @@ export function RecipeFormPage() {
key={line.key} key={line.key}
ingredient={line.ingredient} ingredient={line.ingredient}
quantity={line.quantity} quantity={line.quantity}
unit={line.unit} unitId={line.unitId}
unitsCatalog={unitsCatalog}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unit) => updateIngredientLine(line.key, { unit })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onRemove={() => removeIngredientLine(line.key)} onRemove={() => removeIngredientLine(line.key)}
/> />
))} ))}

View file

@ -58,6 +58,8 @@ export enum ErrorCode {
INGREDIENT_NOT_FOUND = 4046, INGREDIENT_NOT_FOUND = 4046,
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */ /** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
PLANNING_ITEM_NOT_FOUND = 4047, PLANNING_ITEM_NOT_FOUND = 4047,
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
UNIT_NOT_FOUND = 4048,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -13,7 +13,8 @@ import { z } from "zod";
const recipeIngredientInputSchema = z.object({ const recipeIngredientInputSchema = z.object({
ingredientId: z.number().int().positive(), ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive"), quantity: z.number().positive("La quantité doit être positive"),
unit: z.string().trim().min(1, "L'unité est requise").max(20), /** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
unitId: z.number().int().positive(),
}); });
/** /**

View file

@ -1,4 +1,4 @@
import type { AllergyView, DietView, IngredientView } from "./reference.js"; import type { AllergyView, DietView, IngredientView, UnitView } from "./reference.js";
/** /**
* Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma. * Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma.
@ -12,13 +12,16 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
/** /**
* One ingredient line within a recipe, as returned in {@link RecipeView} * One ingredient line within a recipe, as returned in {@link RecipeView}
* the ingredient resolved to its full reference data (name, icon, * the ingredient resolved to its full reference data (name, icon,
* allergens), plus the quantity/unit specific to this recipe (carried by * allergens), plus the quantity specific to this recipe (carried by
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). * `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
* is likewise resolved to its full reference data (`Unit`) rather than a
* raw key same "resolve at read time" treatment as `ingredient`, now that
* it's a catalog reference instead of free text (see `UnitView`).
*/ */
export interface RecipeIngredientView { export interface RecipeIngredientView {
ingredient: IngredientView; ingredient: IngredientView;
quantity: number; quantity: number;
unit: string; unit: UnitView;
} }
/** /**

View file

@ -157,6 +157,36 @@ export const INGREDIENT_ICONS = [
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */ /** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number]; export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
/**
* Which physical quantity a {@link UnitView} measures mirrors `UnitType`
* in schema.prisma, declared by hand for the same reason as
* {@link AllergenKind}. Only units of the same type are ever mutually
* convertible via `toBaseFactor` see {@link UnitView}.
*/
export type UnitType = "MASS" | "VOLUME" | "COUNT";
/**
* A recipe ingredient unit, as returned by `GET /reference/units`
* reference data (`Unit`, seeded via `reference-seed-data.ts`'s `UNITS`),
* same static/non-administrable status as {@link DietView}/
* {@link AllergyView}.
*
* `key` is a stable English camelCase uid (e.g. `"tablespoon"`), not a
* display label resolved via `t(\`catalog.units.${key}\`)`, same as
* {@link DietView.key}. `toBaseFactor` is how many of `type`'s base unit
* (gram for MASS, milliliter for VOLUME, itself for COUNT) one of this unit
* equals groundwork for a future conversion feature (e.g. a shopping list
* summing "500g" + "0.5kg" into "1kg"), not that feature itself: COUNT
* units all carry `toBaseFactor: 1` and don't convert to one another (a
* "pincée" isn't a fixed fraction of a "gousse").
*/
export interface UnitView {
id: number;
key: string;
type: UnitType;
toBaseFactor: number;
}
/** /**
* A selectable ingredient, as returned by `GET /reference/ingredients` * A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/ * reference data (`Ingredient`, seeded via `apps/api/src/db/