diff --git a/apps/api/prisma/migrations/20260819201838_ingredient_unit_catalog/migration.sql b/apps/api/prisma/migrations/20260819201838_ingredient_unit_catalog/migration.sql new file mode 100644 index 0000000..fac5dd3 --- /dev/null +++ b/apps/api/prisma/migrations/20260819201838_ingredient_unit_catalog/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 57985d8..23bfef3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -500,6 +500,44 @@ model IngredientAllergy { @@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.` (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 /// many-to-many, but a shopping list / batch-cooking calculation needs a /// quantity per recipe, so this join table carries quantity + unit @@ -508,10 +546,11 @@ model RecipeIngredient { recipeId Int @map("recipe_id") ingredientId Int @map("ingredient_id") quantity Decimal @db.Decimal(10, 2) - unit String + unitId Int @map("unit_id") recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) + unit Unit @relation(fields: [unitId], references: [id]) @@id([recipeId, ingredientId]) @@map("recipe_ingredient") diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index da8d74f..adb2998 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -4,6 +4,7 @@ import type { IngredientIcon, IngredientSubcategory, PrismaClient, + UnitType, } from "@prisma/client"; // 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. 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.`. +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 // businesses to declare — a standard, defensible reference list rather than // an invented one. Split into ALLERGY (classic IgE-mediated immune @@ -1086,6 +1114,17 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { 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 // keyed `Category` (see schema.prisma) — so seeding an allergen means one // Category (upserted by key) plus exactly one Allergy row under it, diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 324da54..0dfe196 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -8,6 +8,7 @@ import { type RecipeSummaryView, type RecipeTab, type RecipeView, + type UnitView, type UpdateRecipeInput, } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; @@ -24,6 +25,7 @@ function recipeInclude(viewerId: number) { diets: { include: { diet: true } }, }, }, + unit: true, }, }, steps: { orderBy: { order: "asc" } }, @@ -34,6 +36,12 @@ function recipeInclude(viewerId: number) { type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; 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`. */ function toIngredientView(ingredient: IngredientWithDetails): IngredientView { @@ -92,7 +100,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ ingredient: toIngredientView(recipeIngredient.ingredient), quantity: Number(recipeIngredient.quantity), - unit: recipeIngredient.unit, + unit: toUnitView(recipeIngredient.unit), })); return { ...toRecipeSummaryView(recipe), @@ -293,6 +301,7 @@ export async function getRecipe( * later edits (see {@link updateRecipe}). * * @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. */ export async function createRecipe( @@ -301,6 +310,7 @@ export async function createRecipe( authorHouseId: number | null, ): Promise { await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); + await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); const created = await prisma.recipe.create({ @@ -316,7 +326,7 @@ export async function createRecipe( create: input.ingredients.map((ingredient) => ({ ingredientId: ingredient.ingredientId, quantity: ingredient.quantity, - unit: ingredient.unit, + unitId: ingredient.unitId, })), }, 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} `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 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. */ export async function updateRecipe( @@ -353,6 +364,7 @@ export async function updateRecipe( ): Promise { await assertIsAuthor(id, viewerId, viewerHouseId); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); + await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); await prisma.$transaction([ @@ -371,7 +383,7 @@ export async function updateRecipe( create: input.ingredients.map((ingredient) => ({ ingredientId: ingredient.ingredientId, quantity: ingredient.quantity, - unit: ingredient.unit, + unitId: ingredient.unitId, })), }, steps: { @@ -507,6 +519,20 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise { } } +/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */ +async function assertUnitsExist(unitIds: number[]): Promise { + 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. */ async function assertDietsExist(dietIds: number[]): Promise { const uniqueIds = [...new Set(dietIds)]; diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts index f617c9e..46f3fe3 100644 --- a/apps/api/src/modules/reference/reference.routes.ts +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -1,6 +1,6 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; 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 @@ -33,3 +33,10 @@ referenceRouter.get( res.status(200).json(await getIngredients()); }), ); + +referenceRouter.get( + "/units", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getUnits()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 749285a..3e212dd 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,4 +1,4 @@ -import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; +import type { AllergyView, DietView, IngredientView, UnitView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** @@ -32,6 +32,23 @@ export async function getAllergies(): Promise { })); } +/** + * 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 { + 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), * each resolved to its allergens (see `IngredientAllergy` in schema.prisma) diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index c758f49..3d6c793 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -3,17 +3,17 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; // Single TRUNCATE ... CASCADE covers FK ordering and resets identity // sequences — used between tests/scenarios to start from a clean slate. -// Re-seeds the Diet/Category/Allergy reference data right after truncating -// it, so every test starts from the same realistic reference data the real -// app seeds (`prisma/seed.ts`) rather than empty tables — tests exercising -// dietId/allergyIds need real rows to reference. +// Re-seeds the Diet/Category/Allergy/Unit reference data right after +// truncating it, so every test starts from the same realistic reference +// data the real app seeds (`prisma/seed.ts`) rather than empty tables — +// tests exercising dietId/allergyIds/unitId need real rows to reference. export async function resetDatabase() { await prisma.$executeRawUnsafe(` TRUNCATE TABLE "user_profile_allergy", "user_preference", "allergy", "category", "planning_item", "planning", "recipe_ingredient", "step", "tech_step_mapping", "tech_step", - "recipe", "ingredients", "sources", + "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house" RESTART IDENTITY CASCADE; `); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 3b51b25..3528c10 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -25,6 +25,12 @@ async function ingredientId(key: string): Promise { 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 { + const unit = await prisma.unit.findFirstOrThrow({ where: { key } }); + return unit.id; +} + describe("Recipes", () => { const app = createApp(); @@ -167,6 +173,7 @@ describe("Recipes", () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const oeuf = await ingredientId("egg"); + const piece = await unitId("piece"); const vegetarien = await prisma.diet.findFirstOrThrow({ where: { key: "vegetarian" }, }); @@ -177,8 +184,8 @@ describe("Recipes", () => { portions: 2, dietIds: [vegetarien.id], ingredients: [ - { ingredientId: tomate, quantity: 2, unit: "unité" }, - { ingredientId: oeuf, quantity: 3, unit: "unité" }, + { ingredientId: tomate, quantity: 2, unitId: piece }, + { ingredientId: oeuf, quantity: 3, unitId: piece }, ], 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.portions).to.equal(2); expect(res.body.ingredients).to.have.length(2); + expect(res.body.ingredients[0].unit.key).to.equal("piece"); expect( res.body.steps.map((s: { description: string; order: number }) => s.order), ).to.deep.equal([0, 1]); @@ -199,12 +207,13 @@ describe("Recipes", () => { const { agent } = await signup(); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); const res = await agent.post("/recipes").send({ name: "Test", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -216,7 +225,7 @@ describe("Recipes", () => { visibility: "HOUSE", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); 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 () => { const { agent } = await signup(); + const piece = await unitId("piece"); const res = await agent.post("/recipes").send({ name: "Test", portions: 4, dietIds: [], - ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }], + ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -239,15 +249,32 @@ describe("Recipes", () => { 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 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({ name: "Test", portions: 4, dietIds: [999_999], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -273,10 +300,11 @@ describe("Recipes", () => { it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); const basePayload = { name: "Test", dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }; @@ -303,11 +331,12 @@ describe("Recipes", () => { it("returns the full recipe detail", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -317,6 +346,7 @@ describe("Recipes", () => { expect(res.body.name).to.equal("Salade"); expect(res.body.portions).to.equal(4); 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); }); @@ -386,11 +416,13 @@ describe("Recipes", () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const oignon = await ingredientId("onion"); + const piece = await unitId("piece"); + const gram = await unitId("gram"); const created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -399,7 +431,7 @@ describe("Recipes", () => { portions: 6, visibility: "PUBLIC", dietIds: [], - ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }], + ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }], steps: [{ description: "Émincer" }, { description: "Mélanger" }], }); @@ -409,18 +441,20 @@ describe("Recipes", () => { expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.ingredients).to.have.length(1); 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); }); it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); const res = await agent.patch("/recipes/999999").send({ name: "Test", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); @@ -431,11 +465,12 @@ describe("Recipes", () => { 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 created = await agent.post("/recipes").send({ name: "Salade", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -443,7 +478,7 @@ describe("Recipes", () => { name: "Salade", portions: 4, dietIds: [999_999], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Couper" }], }); @@ -455,6 +490,7 @@ describe("Recipes", () => { const { agent, profileId } = await signup(); const { agent: otherAgent } = await signup(); const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); const recipe = await prisma.recipe.create({ data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); @@ -463,7 +499,7 @@ describe("Recipes", () => { name: "Hack", portions: 4, dietIds: [], - ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], steps: [{ description: "Étape" }], }); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index ce101d7..dd8bb46 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -74,4 +74,26 @@ describe("Reference data", () => { 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 }); + }); + }); }); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index 3d1db3c..cefaaa3 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -21,7 +21,7 @@ Feature: Recipe form — associating ingredients And I fill in the step description with "Couper les tomates." Then the "Enregistrer" button should not be disabled 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" # 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é" And I click the button "Enregistrer" Then the recipe update request should have included these ingredients: - | ingredientId | quantity | unit | - | 2 | 3 | unité | - | 1 | 1 | unité | + | ingredientId | quantity | unitId | + | 2 | 3 | 1 | + | 1 | 1 | 1 | diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 469a726..2d22a99 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -33,9 +33,13 @@ const diets = [ { 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", () => { cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] }); 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) => { @@ -85,7 +89,7 @@ When( "I fill in the ingredient's quantity with {string} and unit {string}", (quantity: string, unit: string) => { 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}", (quantity: string, unit: string) => { 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( - "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}", - (name: string, portions: number, ingredientId: number, quantity: number, 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, unitId: number) => { cy.wait("@createRecipe") .its("request.body") .should("deep.include", { name, 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" }], diets: [], 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 }], }; cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); @@ -154,7 +158,7 @@ Then( const expected = dataTable.hashes().map((row) => ({ ingredientId: Number(row.ingredientId), quantity: Number(row.quantity), - unit: row.unit, + unitId: Number(row.unitId), })); cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected); }, diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index 39f233f..2bdb266 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -60,7 +60,7 @@ const omeletteDetail = { diets: [], }, quantity: 3, - unit: "unité", + unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }, }, ], steps: [ diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index b162bae..275eb90 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -30,7 +30,7 @@ const omeletteDetail = { diets: [], }, quantity: 3, - unit: "unité", + unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }, }, ], steps: [ diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6776771..374cbc1 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import { type SafeUserProfile, type SignupInput, type ThemePreference, + type UnitView, type UpdateRecipeInput, } from "@batch-cooking/shared"; @@ -161,6 +162,11 @@ export class ApiClient { 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 { + return this.request("/reference/units"); + } + /** * One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), * optionally narrowed further — `search` (name substring), diff --git a/apps/web/src/features/recipes/IngredientRow.tsx b/apps/web/src/features/recipes/IngredientRow.tsx index 7410f52..5fc47e1 100644 --- a/apps/web/src/features/recipes/IngredientRow.tsx +++ b/apps/web/src/features/recipes/IngredientRow.tsx @@ -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 { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; @@ -6,20 +6,22 @@ import { ReproducibleBadge } from "./ReproducibleBadge"; 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. */ +/** 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({ ingredient, quantity, - unit, + unitId, + unitsCatalog, onQuantityChange, onUnitChange, onRemove, }: { ingredient: IngredientView; quantity: string; - unit: string; + unitId: number | null; + unitsCatalog: UnitView[]; onQuantityChange: (quantity: string) => void; - onUnitChange: (unit: string) => void; + onUnitChange: (unitId: number) => void; onRemove: () => void; }) { const { t } = useTranslation(); @@ -39,14 +41,21 @@ export function IngredientRow({ onChange={(e) => onQuantityChange(e.target.value)} aria-label={t("recipes.form.quantityLabel")} /> - onUnitChange(e.target.value)} - placeholder={t("recipes.form.unitPlaceholder")} + value={unitId ?? ""} + onChange={(e) => onUnitChange(Number(e.target.value))} aria-label={t("recipes.form.unitLabel")} - /> + > + + {unitsCatalog.map((unit) => ( + + ))} + over the reference catalog + // (see IngredientRow.tsx), left too narrow for that one. &__unit { - width: 6rem; + width: 11rem; } &__remove { diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index d0e5b6c..6a444ac 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -20,6 +20,7 @@ "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", + "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" }, "auth": { @@ -228,7 +229,7 @@ }, "quantityLabel": "Quantité", "unitLabel": "Unité", - "unitPlaceholder": "g, ml, unité…", + "unitPlaceholder": "Choisir une unité", "removeIngredient": "Retirer cet ingrédient", "stepDescriptionPlaceholder": "Décrivez cette étape…", "stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)", @@ -319,6 +320,22 @@ } }, "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": { "omnivore": "Omnivore", "vegetarian": "Végétarien", diff --git a/apps/web/src/pages/RecipeFormPage.tsx b/apps/web/src/pages/RecipeFormPage.tsx index e279d1d..b44878d 100644 --- a/apps/web/src/pages/RecipeFormPage.tsx +++ b/apps/web/src/pages/RecipeFormPage.tsx @@ -4,6 +4,7 @@ import { ErrorCode, type IngredientView, type RecipeVisibility, + type UnitView, createRecipeSchema, } from "@batch-cooking/shared"; 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). */ 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 { key: string; ingredient: IngredientView; 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. */ @@ -50,6 +51,7 @@ export function RecipeFormPage() { const [loadState, setLoadState] = useState("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); + const [unitsCatalog, setUnitsCatalog] = useState([]); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -74,12 +76,14 @@ export function RecipeFormPage() { Promise.all([ apiClient.getIngredients(), apiClient.getDiets(), + apiClient.getUnits(), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), ]) - .then(([ingredients, diets, recipe]) => { + .then(([ingredients, diets, units, recipe]) => { if (cancelled) return; setIngredientsCatalog(ingredients); setDietsCatalog(diets); + setUnitsCatalog(units); if (recipe) { setName(recipe.name); setDescription(recipe.description ?? ""); @@ -92,7 +96,7 @@ export function RecipeFormPage() { key: makeClientKey(), ingredient: line.ingredient, quantity: String(line.quantity), - unit: line.unit, + unitId: line.unit.id, })), ); setSteps( @@ -117,13 +121,13 @@ export function RecipeFormPage() { function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, - { key: makeClientKey(), ingredient, quantity: "", unit: "" }, + { key: makeClientKey(), ingredient, quantity: "", unitId: null }, ]); } function updateIngredientLine( key: string, - patch: Partial>, + patch: Partial>, ) { setIngredientLines((lines) => lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), @@ -142,7 +146,7 @@ export function RecipeFormPage() { Number.isInteger(Number(portions)) && Number(portions) > 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.every((step) => step.description.trim().length > 0); @@ -160,7 +164,12 @@ export function RecipeFormPage() { ingredients: ingredientLines.map((line) => ({ ingredientId: line.ingredient.id, 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) => ({ description: step.description.trim(), @@ -264,9 +273,10 @@ export function RecipeFormPage() { key={line.key} ingredient={line.ingredient} quantity={line.quantity} - unit={line.unit} + unitId={line.unitId} + unitsCatalog={unitsCatalog} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} - onUnitChange={(unit) => updateIngredientLine(line.key, { unit })} + onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onRemove={() => removeIngredientLine(line.key)} /> ))} diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 2f6f409..b421f4b 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -58,6 +58,8 @@ export enum ErrorCode { INGREDIENT_NOT_FOUND = 4046, /** `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, + /** 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. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index f18103e..de31a28 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -13,7 +13,8 @@ import { z } from "zod"; 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), + /** 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(), }); /** diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index 7d1c554..336a2b3 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -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. @@ -12,13 +12,16 @@ 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). + * allergens), plus the quantity specific to this recipe (carried by + * `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 { ingredient: IngredientView; quantity: number; - unit: string; + unit: UnitView; } /** diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index adb56ed..5af48e3 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -157,6 +157,36 @@ export const INGREDIENT_ICONS = [ /** Inferred TS type for one {@link INGREDIENT_ICONS} member. */ 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` — * reference data (`Ingredient`, seeded via `apps/api/src/db/