batchCooking/packages/shared/src/schemas/recipe.ts
Nicolas 5d4ed0dffb
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m23s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 6m17s
CI / test (push) Successful in 14m12s
feat(recipes): permet de corriger la température d'un tech-step
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/
corrections) acceptait ingredients/utensils pour compléter les métadonnées
d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature
n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update
complet de recette. La température ajoutée en #22 n'avait jamais eu son
propre circuit de correction manuelle.

- packages/shared/src/schemas/recipe.ts : nouveau
  manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative
  mutuellement exclusifs — exactement un des trois — même span start/end
  que ingredients/utensils). Le champ temperatures rejoint ingredients/
  utensils dans le même lot combiné (les trois voyagent ensemble : dès que
  l'un est présent, les trois sont résolus, un absent devient [] donc
  effacé — jamais une fusion partielle catégorie par catégorie).
- recipe-tech-step-correction.service.ts : applyManualCorrection écrit
  désormais StepTechStepTemperature (delete+recreate, même logique "le
  manuel remplace tout" que les deux autres tables). raw n'est jamais
  envoyé par le client — dérivé de description.slice(start, end), pour ne
  pas faire confiance à une copie cliente qui pourrait diverger du texte
  réel. loadVisibleStepOrThrow retourne désormais le texte complet de la
  description (pas seulement sa longueur), nécessaire pour cette dérivation.
- specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux
  routes de correction et step_tech_step_temperature n'étaient documentées
  nulle part — comblé au passage.
- Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou
  plusieurs champs renseignés, remplacement sans accumulation, dérivation
  de raw, span hors limites).

Périmètre : back-end uniquement (voir la maquette proposée précédemment).
Le popover de correction (apps/web) ne sera mis à jour que dans une PR
suivante, empilée sur celle-ci.

Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas
de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:53:14 +02:00

286 lines
15 KiB
TypeScript

import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
*
* A line carries **exactly one** of:
* - `ingredientId` — references an existing reference `Ingredient` (see
* `GET /reference/ingredients`). An unknown id is rejected service-side
* with `INGREDIENT_NOT_FOUND`, not here — this schema only checks shape.
* (A placeholder that already exists, e.g. on a recipe being edited,
* round-trips through this branch by its real id.)
* - `placeholderName` — free text the user typed because nothing in the
* catalog matched. The API creates a dedicated placeholder `Ingredient`
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
*
* Requiring exactly one keeps `RecipeIngredient` unchanged (still a real
* `ingredientId` after the service resolves the line).
*/
const recipeIngredientInputSchema = z
.object({
ingredientId: z.number().int().positive().optional(),
/** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
placeholderName: z.string().trim().min(1).max(120).optional(),
quantity: z.number().positive("La quantité doit être positive"),
/** 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(),
})
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
path: ["ingredientId"],
});
/**
* One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `order` is deliberately not part of this shape — it's derived server-side
* from the step's position in the `steps` array, so the client (the
* step reorder UI) never has to keep an explicit order field in sync.
*/
const recipeStepInputSchema = z.object({
description: z.string().trim().min(1, "La description de l'étape est requise").max(2000),
picture: z.string().trim().url("URL invalide").nullable().optional(),
});
/**
* Who can read the recipe being created/edited — mirrors `RecipeVisibility`
* in schema.prisma. Defaults to `PERSONAL` (visible to its author only) —
* the author explicitly opens it up to `HOUSE`/`PUBLIC` if they want to
* share it, rather than the other way around.
*/
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);
/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
export const createRecipeSchema = z
.object({
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
description: z.string().trim().max(2000).nullable().optional(),
picture: z.string().trim().url("URL invalide").nullable().optional(),
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
portions: z.number().int().positive("Le nombre de portions doit être positif"),
visibility: recipeVisibilitySchema.default("PERSONAL"),
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
dietIds: z.array(z.number().int().positive()),
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
})
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — one row
// per ingredient per recipe (schema.prisma) — so two lines with the same
// `ingredientId` would otherwise reach `prisma.recipe.create()` and crash
// with an unhandled `P2002` unique-constraint 500, instead of the clean
// 400 every other invalid shape gets here. The manual recipe form can't
// produce this (`IngredientPicker`'s `excludeIds` hides an
// already-selected ingredient), but a source import can: two raw lines
// ("Egg Yolks", "Eggs") can independently resolve to the same catalog
// ingredient (see `RecipeImportForm.tsx`, issue #53's `matchUnit`
// fallback made this reachable in practice) — rejected here rather than
// silently summed, since two lines resolving to the same ingredient
// aren't necessarily interchangeable quantities (different units,
// different confidence in the match).
//
// Placeholder lines (`placeholderName`, no `ingredientId` yet) are
// skipped here: each one becomes its own fresh `Ingredient` row
// service-side, so two placeholder lines with the same text never
// collide on the `(recipeId, ingredientId)` key.
.refine(
(input) => {
const ingredientIds = input.ingredients
.map((ingredient) => ingredient.ingredientId)
.filter((id): id is number => id !== undefined);
return new Set(ingredientIds).size === ingredientIds.length;
},
{
message: "Un même ingrédient ne peut pas apparaître plusieurs fois dans une recette",
path: ["ingredients"],
},
);
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;
/** `PATCH /recipes/:id` shares the exact same shape as creation — see {@link createRecipeSchema}. */
export const updateRecipeSchema = createRecipeSchema;
/** Inferred TS type for {@link updateRecipeSchema}'s validated output. */
export type UpdateRecipeInput = z.infer<typeof updateRecipeSchema>;
/**
* Which catalog tab `GET /recipes` should filter for — see
* `recipe.service.ts`'s `listRecipes` for what each value actually
* queries. No "toutes" value on purpose: every recipe visible to a viewer
* falls under exactly one of `perso`/`foyer`/`publique` (its own
* visibility), `favoris` is an orthogonal, cross-cutting filter on top.
*/
export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]);
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
export type RecipeTab = z.infer<typeof recipeTabSchema>;
/**
* A repeated query param (`?ingredientIds=1&ingredientIds=2`) arrives via
* Express/`qs` as a plain string when there's exactly one, or a string
* array when there's more than one — never a bare array for a single
* value. Normalizes both (plus the "absent" case) into `number[] |
* undefined` before the real `z.array` check runs.
*/
function queryIdArray() {
return z.preprocess(
(value) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]),
z.array(z.coerce.number().int().positive()).optional(),
);
}
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring, `suitableForHousehold` (the planning recipe picker's "convient à tout le foyer" toggle) further restricts to recipes that avoid every household member's declared allergens and match every member's declared regime (see `recipe.service.ts`'s `listRecipes`) — a no-op if the caller has no household. `ingredientIds`/`dietIds` (the same picker's ingredient/regime filters) further restrict to recipes carrying *every* id listed (AND, not "any of") — see {@link queryIdArray}. `z.coerce.boolean()` since query params always arrive as strings. */
export const listRecipesSchema = z.object({
tab: recipeTabSchema,
search: z.string().trim().min(1).optional(),
suitableForHousehold: z.coerce.boolean().optional(),
ingredientIds: queryIdArray(),
dietIds: queryIdArray(),
});
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
/**
* One ingredient mention the user themselves points at while correcting a
* technique — `start`/`end` is *their own* selection of the exact passage
* of `description` that names it (a separate selection from the
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
* not derived from anything the classifier found. `quantity`/`unitId`
* are optional — a mention with no quantity attached ("ajouter le sel")
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
* doc comment for how `ingredients` as a whole behaves.
*/
const manualStepTechStepIngredientInputSchema = z
.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
unitId: z.number().int().positive().nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((ingredient) => ingredient.end > ingredient.start, {
message: "end must be greater than start",
path: ["end"],
});
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
const manualStepTechStepUtensilInputSchema = z
.object({
utensilId: z.number().int().positive(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((utensil) => utensil.end > utensil.start, {
message: "end must be greater than start",
path: ["end"],
});
/**
* A temperature mention the user points at while correcting a technique —
* same `start`/`end` span convention as
* {@link manualStepTechStepIngredientInputSchema}/
* {@link manualStepTechStepUtensilInputSchema}. Exactly one of
* `celsius`/`gasMark`/`qualitative` must be set: the same mutual-exclusivity
* invariant the auto-detection pipeline already produces (see
* `StepTechStepTemperatureView`'s doc comment, `packages/shared/src/types/recipe.ts`)
* — a manual entry can't represent "both 180 °C and Thermostat 6" any more
* than a detected one can.
*
* No `raw` field here, unlike `StepTechStepTemperatureView`/the Prisma
* column: an auto-detected mention's `raw` is the exact text a regex
* matched, but a manual one is built from a structured value the user
* picked (a number, or a qualitative level), not matched text — the
* service derives it from the step's own `description.slice(start, end)`
* instead of trusting a client-supplied copy that could drift from what's
* actually at that span.
*/
const manualStepTechStepTemperatureInputSchema = z
.object({
celsius: z.number().nullable().optional(),
gasMark: z.number().int().positive().nullable().optional(),
qualitative: z.enum(["low", "medium", "high"]).nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((temperature) => temperature.end > temperature.start, {
message: "end must be greater than start",
path: ["end"],
})
.refine(
(temperature) =>
[temperature.celsius, temperature.gasMark, temperature.qualitative].filter(
(value) => value !== null && value !== undefined,
).length === 1,
{
message: "exactly one of celsius/gasMark/qualitative is required",
path: ["celsius"],
},
);
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
* user asserting what technique a `[start, end)` span of a step's
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
* is the existing match being corrected (omit/`null` when the user is
* flagging a technique the classifier missed entirely — nothing to
* correct, just to add); `correctedTechStepId` is what they assert instead
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
* match). Rejecting both being absent at once happens service-side
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
* the technique they're asserting (`correctedTechStepId`), same
* `source: "manual"` distinction the technique itself gets. The three
* travel as **one combined bundle, not three independent ones**: **omitted
* (`undefined`) on all three at once** means "leave whatever metadata
* already exists on this occurrence alone" — as soon as *any one* of them
* is present, all three are resolved (an absent one defaults to `[]`) and
* **replace everything that was there** for that category, auto-detected
* included (see `applyManualCorrection`'s own doc comment). This is why
* none of the three has a `.default([])`: that would silently turn every
* plain relabel into a metadata wipe. A caller touching just one category
* (e.g. adding a temperature) must still resend the other two's current
* full state alongside it — same contract `TechStepCorrectionPopover.tsx`
* already follows for ingredients/utensils, just extended to a third
* category. Only meaningful alongside a real `correctedTechStepId` —
* enforced by this schema's own refine below, since there's no live
* `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set).
*/
export const submitTechStepCorrectionSchema = z
.object({
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
path: ["end"],
})
.refine(
(input) =>
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
{
message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"],
},
)
.refine(
(input) =>
(input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined) ||
(input.correctedTechStepId ?? null) !== null,
{
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
path: ["correctedTechStepId"],
},
);
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;