feat(recipes): catalogue de référence pour les unités d'ingrédients
Remplace l'unité texte libre de RecipeIngredient (max 20 caractères, "g"/"grammes"/"G"... jamais fiable à additionner) par une référence vers un nouveau catalogue Unit (id/key/type/toBaseFactor), même traitement que Diet/Allergy/Ingredient : GET /reference/units, seedé par reference-seed-data.ts (14 unités : gram/kilogram/milliliter/ centiliter/liter/tablespoon/teaspoon/piece/pinch/slice/clove/bunch/ sachet/sprig), sélectionnable uniquement via un <select> dans le formulaire recette (plus de saisie libre). `toBaseFactor` (combien d'unités de base — gramme pour MASS, millilitre pour VOLUME — vaut une unité) pose les bases d'une future fonctionnalité de conversion (ex. liste de courses additionnant "500g" + "0.5kg") sans construire cette fonctionnalité elle-même — les unités COUNT restent à toBaseFactor=1, non convertibles entre elles (une "pincée" n'est pas une fraction fixe d'une "gousse"). Migration : recipe_ingredient.unit → unit_id (FK), breaking change sans backfill assumé (pas de recette réelle en prod actuellement, voir commentaire de migration) — mêmes garde-fous service-side que ingredientId (404 UNIT_NOT_FOUND) et mêmes tests de couverture.
This commit is contained in:
parent
102f6846d5
commit
de500e1a8a
21 changed files with 376 additions and 66 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")
|
@@map("ingredient_allergy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which physical quantity a {@link Unit} measures — only units of the same
|
||||||
|
/// type are ever mutually convertible via `toBaseFactor` (grams and
|
||||||
|
/// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT
|
||||||
|
/// but converting between *them* would need per-ingredient data no catalog
|
||||||
|
/// entry alone can provide, so COUNT units just don't convert to each
|
||||||
|
/// other, each stands alone with `toBaseFactor = 1`).
|
||||||
|
enum UnitType {
|
||||||
|
MASS
|
||||||
|
VOLUME
|
||||||
|
COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as
|
||||||
|
/// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the
|
||||||
|
/// display label — the label lives in `apps/web`'s
|
||||||
|
/// `locales/fr/translation.json` under `catalog.units.<key>` (see
|
||||||
|
/// `reference-seed-data.ts`'s `UNITS`).
|
||||||
|
///
|
||||||
|
/// Not in the original spec doc — `RecipeIngredient.unit` used to be free
|
||||||
|
/// text ("g", "grammes", "G"…), which can never be reliably summed/converted
|
||||||
|
/// (a future shopping list can't tell "g" and "grammes" are the same unit).
|
||||||
|
/// This closes that off: `unit` is now a normalized, finite catalog.
|
||||||
|
/// `toBaseFactor` is how many of this type's base unit (gram for MASS,
|
||||||
|
/// milliliter for VOLUME, itself for COUNT) one of this unit equals —
|
||||||
|
/// laying the groundwork for a future conversion feature (e.g. summing
|
||||||
|
/// "500g" + "0.5kg" of the same ingredient into "1kg") without building
|
||||||
|
/// that feature itself yet.
|
||||||
|
model Unit {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
type UnitType
|
||||||
|
toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor")
|
||||||
|
|
||||||
|
recipeIngredients RecipeIngredient[]
|
||||||
|
|
||||||
|
@@map("unit")
|
||||||
|
}
|
||||||
|
|
||||||
/// recipe <-> ingredients association. The spec documents this as a plain
|
/// recipe <-> ingredients association. The spec documents this as a plain
|
||||||
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
||||||
/// quantity per recipe, so this join table carries quantity + unit
|
/// quantity per recipe, so this join table carries quantity + unit
|
||||||
|
|
@ -508,10 +546,11 @@ model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
quantity Decimal @db.Decimal(10, 2)
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
unit String
|
unitId Int @map("unit_id")
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
|
unit Unit @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
IngredientIcon,
|
IngredientIcon,
|
||||||
IngredientSubcategory,
|
IngredientSubcategory,
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
|
UnitType,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
|
||||||
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
||||||
|
|
@ -15,6 +16,33 @@ import type {
|
||||||
// maintained independently, tied together only by this same uid string.
|
// maintained independently, tied together only by this same uid string.
|
||||||
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
|
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
|
||||||
|
|
||||||
|
// Recipe ingredient units — a closed, normalized set replacing what used to
|
||||||
|
// be free text (see `Unit`/`RecipeIngredient.unitId` in schema.prisma for
|
||||||
|
// why). `toBaseFactor` is how many of the type's base unit (gram for MASS,
|
||||||
|
// milliliter for VOLUME, itself for COUNT) one of this unit equals — MASS
|
||||||
|
// and VOLUME units convert against each other within their own type, COUNT
|
||||||
|
// units don't convert to one another at all (a "pincée" isn't a fixed
|
||||||
|
// fraction of a "gousse"), so each just gets `1`. Same "English camelCase
|
||||||
|
// uid, no French label" authoring as `DIETS`/`ALLERGENS` — the display
|
||||||
|
// label lives in `apps/web`'s `locales/fr/translation.json` under
|
||||||
|
// `catalog.units.<key>`.
|
||||||
|
export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> = [
|
||||||
|
{ uid: "gram", type: "MASS", toBaseFactor: 1 },
|
||||||
|
{ uid: "kilogram", type: "MASS", toBaseFactor: 1000 },
|
||||||
|
{ uid: "milliliter", type: "VOLUME", toBaseFactor: 1 },
|
||||||
|
{ uid: "centiliter", type: "VOLUME", toBaseFactor: 10 },
|
||||||
|
{ uid: "liter", type: "VOLUME", toBaseFactor: 1000 },
|
||||||
|
{ uid: "tablespoon", type: "VOLUME", toBaseFactor: 15 },
|
||||||
|
{ uid: "teaspoon", type: "VOLUME", toBaseFactor: 5 },
|
||||||
|
{ uid: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "pinch", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "slice", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "clove", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
// businesses to declare — a standard, defensible reference list rather than
|
// businesses to declare — a standard, defensible reference list rather than
|
||||||
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
||||||
|
|
@ -1086,6 +1114,17 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `update: { type, toBaseFactor }` (not `{}`) — same reasoning as
|
||||||
|
// `ALLERGENS`' `kind` below: a reseed must correct a unit's
|
||||||
|
// type/toBaseFactor if it's ever edited above, not just skip existing rows.
|
||||||
|
for (const { uid: key, type, toBaseFactor } of UNITS) {
|
||||||
|
await prisma.unit.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { type, toBaseFactor },
|
||||||
|
create: { key, type, toBaseFactor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
||||||
// Category (upserted by key) plus exactly one Allergy row under it,
|
// Category (upserted by key) plus exactly one Allergy row under it,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
type RecipeTab,
|
type RecipeTab,
|
||||||
type RecipeView,
|
type RecipeView,
|
||||||
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
@ -24,6 +25,7 @@ function recipeInclude(viewerId: number) {
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
unit: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
steps: { orderBy: { order: "asc" } },
|
steps: { orderBy: { order: "asc" } },
|
||||||
|
|
@ -34,6 +36,12 @@ function recipeInclude(viewerId: number) {
|
||||||
|
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
|
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||||
|
|
||||||
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
|
||||||
|
function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
|
return { id: unit.id, key: unit.key, type: unit.type, toBaseFactor: Number(unit.toBaseFactor) };
|
||||||
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||||
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
|
|
@ -92,7 +100,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
quantity: Number(recipeIngredient.quantity),
|
quantity: Number(recipeIngredient.quantity),
|
||||||
unit: recipeIngredient.unit,
|
unit: toUnitView(recipeIngredient.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
...toRecipeSummaryView(recipe),
|
...toRecipeSummaryView(recipe),
|
||||||
|
|
@ -293,6 +301,7 @@ export async function getRecipe(
|
||||||
* later edits (see {@link updateRecipe}).
|
* later edits (see {@link updateRecipe}).
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
*/
|
*/
|
||||||
export async function createRecipe(
|
export async function createRecipe(
|
||||||
|
|
@ -301,6 +310,7 @@ export async function createRecipe(
|
||||||
authorHouseId: number | null,
|
authorHouseId: number | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
|
|
||||||
const created = await prisma.recipe.create({
|
const created = await prisma.recipe.create({
|
||||||
|
|
@ -316,7 +326,7 @@ export async function createRecipe(
|
||||||
create: input.ingredients.map((ingredient) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: ingredient.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
quantity: ingredient.quantity,
|
quantity: ingredient.quantity,
|
||||||
unit: ingredient.unit,
|
unitId: ingredient.unitId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
|
|
@ -343,6 +353,7 @@ export async function createRecipe(
|
||||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
*/
|
*/
|
||||||
export async function updateRecipe(
|
export async function updateRecipe(
|
||||||
|
|
@ -353,6 +364,7 @@ export async function updateRecipe(
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
|
|
@ -371,7 +383,7 @@ export async function updateRecipe(
|
||||||
create: input.ingredients.map((ingredient) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: ingredient.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
quantity: ingredient.quantity,
|
quantity: ingredient.quantity,
|
||||||
unit: ingredient.unit,
|
unitId: ingredient.unitId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
|
|
@ -507,6 +519,20 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
|
||||||
|
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||||
|
const uniqueIds = [...new Set(unitIds)];
|
||||||
|
const found = await prisma.unit.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== uniqueIds.length) {
|
||||||
|
const foundIds = new Set(found.map((unit) => unit.id));
|
||||||
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||||
const uniqueIds = [...new Set(dietIds)];
|
const uniqueIds = [...new Set(dietIds)];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { getAllergies, getDiets, getIngredients } from "./reference.service.js";
|
import { getAllergies, getDiets, getIngredients, getUnits } from "./reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
||||||
|
|
@ -33,3 +33,10 @@ referenceRouter.get(
|
||||||
res.status(200).json(await getIngredients());
|
res.status(200).json(await getIngredients());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
referenceRouter.get(
|
||||||
|
"/units",
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getUnits());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared";
|
import type { AllergyView, DietView, IngredientView, UnitView } from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,6 +32,23 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All reference recipe-ingredient units, ordered by key (see {@link getDiets}
|
||||||
|
* for why) — small, static list (see `reference-seed-data.ts`'s `UNITS`).
|
||||||
|
* `toBaseFactor` comes back as a Prisma `Decimal`, converted to a plain
|
||||||
|
* `number` here the same way `recipe.service.ts` does for
|
||||||
|
* `RecipeIngredient.quantity`.
|
||||||
|
*/
|
||||||
|
export async function getUnits(): Promise<UnitView[]> {
|
||||||
|
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
||||||
|
return units.map((unit) => ({
|
||||||
|
id: unit.id,
|
||||||
|
key: unit.key,
|
||||||
|
type: unit.type,
|
||||||
|
toBaseFactor: Number(unit.toBaseFactor),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
||||||
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
|
|
||||||
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity
|
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity
|
||||||
// sequences — used between tests/scenarios to start from a clean slate.
|
// sequences — used between tests/scenarios to start from a clean slate.
|
||||||
// Re-seeds the Diet/Category/Allergy reference data right after truncating
|
// Re-seeds the Diet/Category/Allergy/Unit reference data right after
|
||||||
// it, so every test starts from the same realistic reference data the real
|
// truncating it, so every test starts from the same realistic reference
|
||||||
// app seeds (`prisma/seed.ts`) rather than empty tables — tests exercising
|
// data the real app seeds (`prisma/seed.ts`) rather than empty tables —
|
||||||
// dietId/allergyIds need real rows to reference.
|
// tests exercising dietId/allergyIds/unitId need real rows to reference.
|
||||||
export async function resetDatabase() {
|
export async function resetDatabase() {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
TRUNCATE TABLE
|
TRUNCATE TABLE
|
||||||
"user_profile_allergy", "user_preference", "allergy", "category",
|
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||||
"planning_item", "planning",
|
"planning_item", "planning",
|
||||||
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
||||||
"recipe", "ingredients", "sources",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house"
|
"user_profiles", "diet", "house"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
`);
|
`);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ async function ingredientId(key: string): Promise<number> {
|
||||||
return ingredient.id;
|
return ingredient.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
|
||||||
|
async function unitId(key: string): Promise<number> {
|
||||||
|
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
||||||
|
return unit.id;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Recipes", () => {
|
describe("Recipes", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -167,6 +173,7 @@ describe("Recipes", () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const oeuf = await ingredientId("egg");
|
const oeuf = await ingredientId("egg");
|
||||||
|
const piece = await unitId("piece");
|
||||||
const vegetarien = await prisma.diet.findFirstOrThrow({
|
const vegetarien = await prisma.diet.findFirstOrThrow({
|
||||||
where: { key: "vegetarian" },
|
where: { key: "vegetarian" },
|
||||||
});
|
});
|
||||||
|
|
@ -177,8 +184,8 @@ describe("Recipes", () => {
|
||||||
portions: 2,
|
portions: 2,
|
||||||
dietIds: [vegetarien.id],
|
dietIds: [vegetarien.id],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 2, unit: "unité" },
|
{ ingredientId: tomate, quantity: 2, unitId: piece },
|
||||||
{ ingredientId: oeuf, quantity: 3, unit: "unité" },
|
{ ingredientId: oeuf, quantity: 3, unitId: piece },
|
||||||
],
|
],
|
||||||
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
|
steps: [{ description: "Battre les œufs" }, { description: "Ajouter les tomates" }],
|
||||||
});
|
});
|
||||||
|
|
@ -187,6 +194,7 @@ describe("Recipes", () => {
|
||||||
expect(res.body.name).to.equal("Omelette provençale");
|
expect(res.body.name).to.equal("Omelette provençale");
|
||||||
expect(res.body.portions).to.equal(2);
|
expect(res.body.portions).to.equal(2);
|
||||||
expect(res.body.ingredients).to.have.length(2);
|
expect(res.body.ingredients).to.have.length(2);
|
||||||
|
expect(res.body.ingredients[0].unit.key).to.equal("piece");
|
||||||
expect(
|
expect(
|
||||||
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
||||||
).to.deep.equal([0, 1]);
|
).to.deep.equal([0, 1]);
|
||||||
|
|
@ -199,12 +207,13 @@ describe("Recipes", () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -216,7 +225,7 @@ describe("Recipes", () => {
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
|
const foyerRes = await agent.get("/recipes").query({ tab: "foyer" });
|
||||||
|
|
@ -226,12 +235,13 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
|
ingredients: [{ ingredientId: 999_999, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -239,15 +249,32 @@ describe("Recipes", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
|
||||||
|
const res = await agent.post("/recipes").send({
|
||||||
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: 999_999 }],
|
||||||
|
steps: [{ description: "Étape" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||||
|
const { agent } = await signup();
|
||||||
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [999_999],
|
dietIds: [999_999],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -273,10 +300,11 @@ describe("Recipes", () => {
|
||||||
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
|
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -303,11 +331,12 @@ describe("Recipes", () => {
|
||||||
it("returns the full recipe detail", async () => {
|
it("returns the full recipe detail", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -317,6 +346,7 @@ describe("Recipes", () => {
|
||||||
expect(res.body.name).to.equal("Salade");
|
expect(res.body.name).to.equal("Salade");
|
||||||
expect(res.body.portions).to.equal(4);
|
expect(res.body.portions).to.equal(4);
|
||||||
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
|
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
|
||||||
|
expect(res.body.ingredients[0].unit.key).to.equal("piece");
|
||||||
expect(res.body.isFavorite).to.equal(false);
|
expect(res.body.isFavorite).to.equal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -386,11 +416,13 @@ describe("Recipes", () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const oignon = await ingredientId("onion");
|
const oignon = await ingredientId("onion");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
const gram = await unitId("gram");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -399,7 +431,7 @@ describe("Recipes", () => {
|
||||||
portions: 6,
|
portions: 6,
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
|
ingredients: [{ ingredientId: oignon, quantity: 2, unitId: gram }],
|
||||||
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
steps: [{ description: "Émincer" }, { description: "Mélanger" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -409,18 +441,20 @@ describe("Recipes", () => {
|
||||||
expect(res.body.visibility).to.equal("PUBLIC");
|
expect(res.body.visibility).to.equal("PUBLIC");
|
||||||
expect(res.body.ingredients).to.have.length(1);
|
expect(res.body.ingredients).to.have.length(1);
|
||||||
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
|
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
|
||||||
|
expect(res.body.ingredients[0].unit.key).to.equal("gram");
|
||||||
expect(res.body.steps).to.have.length(2);
|
expect(res.body.steps).to.have.length(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
|
|
||||||
const res = await agent.patch("/recipes/999999").send({
|
const res = await agent.patch("/recipes/999999").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -431,11 +465,12 @@ describe("Recipes", () => {
|
||||||
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -443,7 +478,7 @@ describe("Recipes", () => {
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [999_999],
|
dietIds: [999_999],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -455,6 +490,7 @@ describe("Recipes", () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const { agent: otherAgent } = await signup();
|
const { agent: otherAgent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
const piece = await unitId("piece");
|
||||||
const recipe = await prisma.recipe.create({
|
const recipe = await prisma.recipe.create({
|
||||||
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
||||||
});
|
});
|
||||||
|
|
@ -463,7 +499,7 @@ describe("Recipes", () => {
|
||||||
name: "Hack",
|
name: "Hack",
|
||||||
portions: 4,
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,4 +74,26 @@ describe("Reference data", () => {
|
||||||
expect(byKey("tomato").allergens).to.deep.equal([]);
|
expect(byKey("tomato").allergens).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /reference/units", () => {
|
||||||
|
it("returns the seeded units, no session required", async () => {
|
||||||
|
const res = await request(app).get("/reference/units");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(14);
|
||||||
|
expect(res.body.map((u: { key: string }) => u.key)).to.include("gram");
|
||||||
|
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves MASS/VOLUME toBaseFactor against their type's base unit, COUNT units all at 1", async () => {
|
||||||
|
const res = await request(app).get("/reference/units");
|
||||||
|
|
||||||
|
const byKey = (key: string) => res.body.find((u: { key: string }) => u.key === key);
|
||||||
|
expect(byKey("gram")).to.include({ type: "MASS", toBaseFactor: 1 });
|
||||||
|
expect(byKey("kilogram")).to.include({ type: "MASS", toBaseFactor: 1000 });
|
||||||
|
expect(byKey("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
|
||||||
|
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
||||||
|
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ Feature: Recipe form — associating ingredients
|
||||||
And I fill in the step description with "Couper les tomates."
|
And I fill in the step description with "Couper les tomates."
|
||||||
Then the "Enregistrer" button should not be disabled
|
Then the "Enregistrer" button should not be disabled
|
||||||
When I click the button "Enregistrer"
|
When I click the button "Enregistrer"
|
||||||
Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unit "unité"
|
Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unitId 1
|
||||||
And the URL should include "/recettes/42"
|
And the URL should include "/recettes/42"
|
||||||
|
|
||||||
# Regression test for the exact bug reported: `crypto.randomUUID()` (used
|
# Regression test for the exact bug reported: `crypto.randomUUID()` (used
|
||||||
|
|
@ -64,6 +64,6 @@ Feature: Recipe form — associating ingredients
|
||||||
When I fill in the last ingredient's quantity with "1" and unit "unité"
|
When I fill in the last ingredient's quantity with "1" and unit "unité"
|
||||||
And I click the button "Enregistrer"
|
And I click the button "Enregistrer"
|
||||||
Then the recipe update request should have included these ingredients:
|
Then the recipe update request should have included these ingredients:
|
||||||
| ingredientId | quantity | unit |
|
| ingredientId | quantity | unitId |
|
||||||
| 2 | 3 | unité |
|
| 2 | 3 | 1 |
|
||||||
| 1 | 1 | unité |
|
| 1 | 1 | 1 |
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,13 @@ const diets = [
|
||||||
{ id: 2, key: "vegetarian" },
|
{ id: 2, key: "vegetarian" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const pieceUnit = { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 };
|
||||||
|
const gramUnit = { id: 2, key: "gram", type: "MASS", toBaseFactor: 1 };
|
||||||
|
|
||||||
Given("the ingredient and diet catalog is available", () => {
|
Given("the ingredient and diet catalog is available", () => {
|
||||||
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] });
|
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] });
|
||||||
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
|
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
|
||||||
|
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [pieceUnit, gramUnit] });
|
||||||
});
|
});
|
||||||
|
|
||||||
Given("creating the recipe will succeed and return id {int}", (id: number) => {
|
Given("creating the recipe will succeed and return id {int}", (id: number) => {
|
||||||
|
|
@ -85,7 +89,7 @@ When(
|
||||||
"I fill in the ingredient's quantity with {string} and unit {string}",
|
"I fill in the ingredient's quantity with {string} and unit {string}",
|
||||||
(quantity: string, unit: string) => {
|
(quantity: string, unit: string) => {
|
||||||
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
|
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
|
||||||
cy.get(".ingredient-row .ingredient-row__unit").type(unit);
|
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -97,7 +101,7 @@ When(
|
||||||
"I fill in the last ingredient's quantity with {string} and unit {string}",
|
"I fill in the last ingredient's quantity with {string} and unit {string}",
|
||||||
(quantity: string, unit: string) => {
|
(quantity: string, unit: string) => {
|
||||||
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
|
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
|
||||||
cy.get(".ingredient-row .ingredient-row__unit").last().type(unit);
|
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -114,14 +118,14 @@ Then("there should be {int} step editor items", (count: number) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Then(
|
Then(
|
||||||
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}",
|
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
|
||||||
(name: string, portions: number, ingredientId: number, quantity: number, unit: string) => {
|
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
|
||||||
cy.wait("@createRecipe")
|
cy.wait("@createRecipe")
|
||||||
.its("request.body")
|
.its("request.body")
|
||||||
.should("deep.include", {
|
.should("deep.include", {
|
||||||
name,
|
name,
|
||||||
portions,
|
portions,
|
||||||
ingredients: [{ ingredientId, quantity, unit }],
|
ingredients: [{ ingredientId, quantity, unitId }],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -138,7 +142,7 @@ Given("recipe 7 exists with an egg omelette", () => {
|
||||||
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
||||||
diets: [],
|
diets: [],
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }],
|
ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
|
||||||
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
|
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
|
||||||
};
|
};
|
||||||
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
|
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
|
||||||
|
|
@ -154,7 +158,7 @@ Then(
|
||||||
const expected = dataTable.hashes().map((row) => ({
|
const expected = dataTable.hashes().map((row) => ({
|
||||||
ingredientId: Number(row.ingredientId),
|
ingredientId: Number(row.ingredientId),
|
||||||
quantity: Number(row.quantity),
|
quantity: Number(row.quantity),
|
||||||
unit: row.unit,
|
unitId: Number(row.unitId),
|
||||||
}));
|
}));
|
||||||
cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected);
|
cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const omeletteDetail = {
|
||||||
diets: [],
|
diets: [],
|
||||||
},
|
},
|
||||||
quantity: 3,
|
quantity: 3,
|
||||||
unit: "unité",
|
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const omeletteDetail = {
|
||||||
diets: [],
|
diets: [],
|
||||||
},
|
},
|
||||||
quantity: 3,
|
quantity: 3,
|
||||||
unit: "unité",
|
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
|
@ -161,6 +162,11 @@ export class ApiClient {
|
||||||
return this.request("/reference/ingredients");
|
return this.request("/reference/ingredients");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */
|
||||||
|
public getUnits(): Promise<UnitView[]> {
|
||||||
|
return this.request("/reference/units");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
||||||
* optionally narrowed further — `search` (name substring),
|
* optionally narrowed further — `search` (name substring),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { IngredientView } from "@batch-cooking/shared";
|
import type { IngredientView, UnitView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import { DietBadges } from "./DietBadges";
|
import { DietBadges } from "./DietBadges";
|
||||||
|
|
@ -6,20 +6,22 @@ import { ReproducibleBadge } from "./ReproducibleBadge";
|
||||||
import { IngredientTypeIcon } from "./ingredient-icons";
|
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
|
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
||||||
export function IngredientRow({
|
export function IngredientRow({
|
||||||
ingredient,
|
ingredient,
|
||||||
quantity,
|
quantity,
|
||||||
unit,
|
unitId,
|
||||||
|
unitsCatalog,
|
||||||
onQuantityChange,
|
onQuantityChange,
|
||||||
onUnitChange,
|
onUnitChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unit: string;
|
unitId: number | null;
|
||||||
|
unitsCatalog: UnitView[];
|
||||||
onQuantityChange: (quantity: string) => void;
|
onQuantityChange: (quantity: string) => void;
|
||||||
onUnitChange: (unit: string) => void;
|
onUnitChange: (unitId: number) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -39,14 +41,21 @@ export function IngredientRow({
|
||||||
onChange={(e) => onQuantityChange(e.target.value)}
|
onChange={(e) => onQuantityChange(e.target.value)}
|
||||||
aria-label={t("recipes.form.quantityLabel")}
|
aria-label={t("recipes.form.quantityLabel")}
|
||||||
/>
|
/>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
className="ingredient-row__unit"
|
className="ingredient-row__unit"
|
||||||
value={unit}
|
value={unitId ?? ""}
|
||||||
onChange={(e) => onUnitChange(e.target.value)}
|
onChange={(e) => onUnitChange(Number(e.target.value))}
|
||||||
placeholder={t("recipes.form.unitPlaceholder")}
|
|
||||||
aria-label={t("recipes.form.unitLabel")}
|
aria-label={t("recipes.form.unitLabel")}
|
||||||
/>
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("recipes.form.unitPlaceholder")}
|
||||||
|
</option>
|
||||||
|
{unitsCatalog.map((unit) => (
|
||||||
|
<option key={unit.id} value={unit.id}>
|
||||||
|
{t(`catalog.units.${unit.key}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<AllergenBadges allergens={ingredient.allergens} />
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
<DietBadges diets={ingredient.diets} />
|
<DietBadges diets={ingredient.diets} />
|
||||||
<ReproducibleBadge
|
<ReproducibleBadge
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
||||||
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
||||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
|
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -228,7 +229,7 @@
|
||||||
},
|
},
|
||||||
"quantityLabel": "Quantité",
|
"quantityLabel": "Quantité",
|
||||||
"unitLabel": "Unité",
|
"unitLabel": "Unité",
|
||||||
"unitPlaceholder": "g, ml, unité…",
|
"unitPlaceholder": "Choisir une unité",
|
||||||
"removeIngredient": "Retirer cet ingrédient",
|
"removeIngredient": "Retirer cet ingrédient",
|
||||||
"stepDescriptionPlaceholder": "Décrivez cette étape…",
|
"stepDescriptionPlaceholder": "Décrivez cette étape…",
|
||||||
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
|
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",
|
||||||
|
|
@ -319,6 +320,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
|
"units": {
|
||||||
|
"gram": "g",
|
||||||
|
"kilogram": "kg",
|
||||||
|
"milliliter": "ml",
|
||||||
|
"centiliter": "cl",
|
||||||
|
"liter": "l",
|
||||||
|
"tablespoon": "cuillère à soupe",
|
||||||
|
"teaspoon": "cuillère à café",
|
||||||
|
"piece": "unité",
|
||||||
|
"pinch": "pincée",
|
||||||
|
"slice": "tranche",
|
||||||
|
"clove": "gousse",
|
||||||
|
"bunch": "botte",
|
||||||
|
"sachet": "sachet",
|
||||||
|
"sprig": "brin"
|
||||||
|
},
|
||||||
"diets": {
|
"diets": {
|
||||||
"omnivore": "Omnivore",
|
"omnivore": "Omnivore",
|
||||||
"vegetarian": "Végétarien",
|
"vegetarian": "Végétarien",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type RecipeVisibility,
|
type RecipeVisibility,
|
||||||
|
type UnitView,
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
|
@ -21,12 +22,12 @@ import { errorMessageService } from "../services/error-message.service";
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. */
|
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. `unitId` is `null` until the user picks one (no default — unlike `portions`, there's no single "usually right" unit across every ingredient); `canSubmit` gates on every line having one set before allowing save. */
|
||||||
interface IngredientLine {
|
interface IngredientLine {
|
||||||
key: string;
|
key: string;
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unit: string;
|
unitId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
|
||||||
|
|
@ -50,6 +51,7 @@ export function RecipeFormPage() {
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
|
@ -74,12 +76,14 @@ export function RecipeFormPage() {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiClient.getIngredients(),
|
apiClient.getIngredients(),
|
||||||
apiClient.getDiets(),
|
apiClient.getDiets(),
|
||||||
|
apiClient.getUnits(),
|
||||||
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
.then(([ingredients, diets, recipe]) => {
|
.then(([ingredients, diets, units, recipe]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setIngredientsCatalog(ingredients);
|
setIngredientsCatalog(ingredients);
|
||||||
setDietsCatalog(diets);
|
setDietsCatalog(diets);
|
||||||
|
setUnitsCatalog(units);
|
||||||
if (recipe) {
|
if (recipe) {
|
||||||
setName(recipe.name);
|
setName(recipe.name);
|
||||||
setDescription(recipe.description ?? "");
|
setDescription(recipe.description ?? "");
|
||||||
|
|
@ -92,7 +96,7 @@ export function RecipeFormPage() {
|
||||||
key: makeClientKey(),
|
key: makeClientKey(),
|
||||||
ingredient: line.ingredient,
|
ingredient: line.ingredient,
|
||||||
quantity: String(line.quantity),
|
quantity: String(line.quantity),
|
||||||
unit: line.unit,
|
unitId: line.unit.id,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setSteps(
|
setSteps(
|
||||||
|
|
@ -117,13 +121,13 @@ export function RecipeFormPage() {
|
||||||
function addIngredient(ingredient: IngredientView) {
|
function addIngredient(ingredient: IngredientView) {
|
||||||
setIngredientLines((lines) => [
|
setIngredientLines((lines) => [
|
||||||
...lines,
|
...lines,
|
||||||
{ key: makeClientKey(), ingredient, quantity: "", unit: "" },
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredientLine(
|
function updateIngredientLine(
|
||||||
key: string,
|
key: string,
|
||||||
patch: Partial<Pick<IngredientLine, "quantity" | "unit">>,
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||||
) {
|
) {
|
||||||
setIngredientLines((lines) =>
|
setIngredientLines((lines) =>
|
||||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
|
@ -142,7 +146,7 @@ export function RecipeFormPage() {
|
||||||
Number.isInteger(Number(portions)) &&
|
Number.isInteger(Number(portions)) &&
|
||||||
Number(portions) > 0 &&
|
Number(portions) > 0 &&
|
||||||
ingredientLines.length > 0 &&
|
ingredientLines.length > 0 &&
|
||||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||||
steps.length > 0 &&
|
steps.length > 0 &&
|
||||||
steps.every((step) => step.description.trim().length > 0);
|
steps.every((step) => step.description.trim().length > 0);
|
||||||
|
|
||||||
|
|
@ -160,7 +164,12 @@ export function RecipeFormPage() {
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
ingredientId: line.ingredient.id,
|
ingredientId: line.ingredient.id,
|
||||||
quantity: Number(line.quantity),
|
quantity: Number(line.quantity),
|
||||||
unit: line.unit.trim(),
|
// `canSubmit` already requires every line to have a unit picked
|
||||||
|
// before the button is enabled — `?? 0` is just to satisfy the
|
||||||
|
// type here; if it's ever reached with no unit set, the schema's
|
||||||
|
// `positive()` check rejects it the same way an invalid quantity
|
||||||
|
// already does.
|
||||||
|
unitId: line.unitId ?? 0,
|
||||||
})),
|
})),
|
||||||
steps: steps.map((step) => ({
|
steps: steps.map((step) => ({
|
||||||
description: step.description.trim(),
|
description: step.description.trim(),
|
||||||
|
|
@ -264,9 +273,10 @@ export function RecipeFormPage() {
|
||||||
key={line.key}
|
key={line.key}
|
||||||
ingredient={line.ingredient}
|
ingredient={line.ingredient}
|
||||||
quantity={line.quantity}
|
quantity={line.quantity}
|
||||||
unit={line.unit}
|
unitId={line.unitId}
|
||||||
|
unitsCatalog={unitsCatalog}
|
||||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
onUnitChange={(unit) => updateIngredientLine(line.key, { unit })}
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||||
onRemove={() => removeIngredientLine(line.key)}
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ export enum ErrorCode {
|
||||||
INGREDIENT_NOT_FOUND = 4046,
|
INGREDIENT_NOT_FOUND = 4046,
|
||||||
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
|
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
|
||||||
PLANNING_ITEM_NOT_FOUND = 4047,
|
PLANNING_ITEM_NOT_FOUND = 4047,
|
||||||
|
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
|
||||||
|
UNIT_NOT_FOUND = 4048,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import { z } from "zod";
|
||||||
const recipeIngredientInputSchema = z.object({
|
const recipeIngredientInputSchema = z.object({
|
||||||
ingredientId: z.number().int().positive(),
|
ingredientId: z.number().int().positive(),
|
||||||
quantity: z.number().positive("La quantité doit être positive"),
|
quantity: z.number().positive("La quantité doit être positive"),
|
||||||
unit: z.string().trim().min(1, "L'unité est requise").max(20),
|
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
|
||||||
|
unitId: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AllergyView, DietView, IngredientView } from "./reference.js";
|
import type { AllergyView, DietView, IngredientView, UnitView } from "./reference.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
||||||
|
|
@ -12,13 +12,16 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
|
||||||
/**
|
/**
|
||||||
* One ingredient line within a recipe, as returned in {@link RecipeView} —
|
* One ingredient line within a recipe, as returned in {@link RecipeView} —
|
||||||
* the ingredient resolved to its full reference data (name, icon,
|
* the ingredient resolved to its full reference data (name, icon,
|
||||||
* allergens), plus the quantity/unit specific to this recipe (carried by
|
* allergens), plus the quantity specific to this recipe (carried by
|
||||||
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself).
|
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
|
||||||
|
* is likewise resolved to its full reference data (`Unit`) rather than a
|
||||||
|
* raw key — same "resolve at read time" treatment as `ingredient`, now that
|
||||||
|
* it's a catalog reference instead of free text (see `UnitView`).
|
||||||
*/
|
*/
|
||||||
export interface RecipeIngredientView {
|
export interface RecipeIngredientView {
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: UnitView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,36 @@ export const INGREDIENT_ICONS = [
|
||||||
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
|
/** Inferred TS type for one {@link INGREDIENT_ICONS} member. */
|
||||||
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
|
export type IngredientIcon = (typeof INGREDIENT_ICONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which physical quantity a {@link UnitView} measures — mirrors `UnitType`
|
||||||
|
* in schema.prisma, declared by hand for the same reason as
|
||||||
|
* {@link AllergenKind}. Only units of the same type are ever mutually
|
||||||
|
* convertible via `toBaseFactor` — see {@link UnitView}.
|
||||||
|
*/
|
||||||
|
export type UnitType = "MASS" | "VOLUME" | "COUNT";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipe ingredient unit, as returned by `GET /reference/units` —
|
||||||
|
* reference data (`Unit`, seeded via `reference-seed-data.ts`'s `UNITS`),
|
||||||
|
* same static/non-administrable status as {@link DietView}/
|
||||||
|
* {@link AllergyView}.
|
||||||
|
*
|
||||||
|
* `key` is a stable English camelCase uid (e.g. `"tablespoon"`), not a
|
||||||
|
* display label — resolved via `t(\`catalog.units.${key}\`)`, same as
|
||||||
|
* {@link DietView.key}. `toBaseFactor` is how many of `type`'s base unit
|
||||||
|
* (gram for MASS, milliliter for VOLUME, itself for COUNT) one of this unit
|
||||||
|
* equals — groundwork for a future conversion feature (e.g. a shopping list
|
||||||
|
* summing "500g" + "0.5kg" into "1kg"), not that feature itself: COUNT
|
||||||
|
* units all carry `toBaseFactor: 1` and don't convert to one another (a
|
||||||
|
* "pincée" isn't a fixed fraction of a "gousse").
|
||||||
|
*/
|
||||||
|
export interface UnitView {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
type: UnitType;
|
||||||
|
toBaseFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue