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:
commit
8884d28341
22 changed files with 381 additions and 67 deletions
|
|
@ -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;
|
||||
|
|
@ -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.<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
|
||||
/// 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")
|
||||
|
|
|
|||
|
|
@ -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.<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
|
||||
// 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<void> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<typeof recipeInclude> }>;
|
||||
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<RecipeView> {
|
||||
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<RecipeView> {
|
||||
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<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. */
|
||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(dietIds)];
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ async function ingredientId(key: string): Promise<number> {
|
|||
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", () => {
|
||||
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" }],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const omeletteDetail = {
|
|||
diets: [],
|
||||
},
|
||||
quantity: 3,
|
||||
unit: "unité",
|
||||
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const omeletteDetail = {
|
|||
diets: [],
|
||||
},
|
||||
quantity: 3,
|
||||
unit: "unité",
|
||||
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
|
|
|
|||
|
|
@ -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<UnitView[]> {
|
||||
return this.request("/reference/units");
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
||||
* optionally narrowed further — `search` (name substring),
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
className="ingredient-row__unit"
|
||||
value={unit}
|
||||
onChange={(e) => onUnitChange(e.target.value)}
|
||||
placeholder={t("recipes.form.unitPlaceholder")}
|
||||
value={unitId ?? ""}
|
||||
onChange={(e) => onUnitChange(Number(e.target.value))}
|
||||
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} />
|
||||
<DietBadges diets={ingredient.diets} />
|
||||
<ReproducibleBadge
|
||||
|
|
|
|||
|
|
@ -722,8 +722,12 @@
|
|||
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 {
|
||||
width: 6rem;
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<LoadState>("loading");
|
||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||
|
||||
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<Pick<IngredientLine, "quantity" | "unit">>,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||
) {
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue