Etend le flux de correction existant (TechStepCorrectionPopover) pour que l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et des ustensiles a la technique qu'il corrige, avec le meme marquage source: "manual" que la technique elle-meme. Backend : - submitTechStepCorrectionSchema (packages/shared) accepte des tableaux ingredients/utensils optionnels, chacun avec son propre span [start,end) selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees existantes ; tableau (meme vide) = remplace tout ce qui existait sur cette occurrence (auto ET manuel precedent - decision validee avec l'utilisateur). - applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres avoir vide celles de l'occurrence via deleteMany - meme chemin de code que ce soit une creation ou une mise a jour de la technique. - Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation de span, nouveau code d'erreur UTENSIL_NOT_FOUND. - source ajoute a StepTechStepIngredientView/StepTechStepUtensilView (le calque manquait ce que la colonne DB portait deja). Frontend : - TechStepCorrectionPopover passe d'un clic = soumission immediate a un flux selection-puis-confirmation, avec deux nouvelles sections Ingredients/Ustensiles pre-remplies avec l'existant. - Ajouter un ingredient/ustensile demande une selection de texte dediee dans la description encore visible (StepDescription geree via un nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci sur le span de la correction elle-meme. - Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour ce contexte de popover, reutilise pour les deux catalogues. - getUtensils() ajoute a apiClient. Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider supplementaire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
11 KiB
TypeScript
212 lines
11 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`.
|
|
* `ingredientId` must reference an existing reference `Ingredient` (see
|
|
* `GET /reference/ingredients`) — there is no way to create one from here,
|
|
* ingredients are static reference data. An unknown id is rejected
|
|
* service-side with `INGREDIENT_NOT_FOUND`, not here — this schema only
|
|
* checks shape.
|
|
*/
|
|
const recipeIngredientInputSchema = z.object({
|
|
ingredientId: z.number().int().positive(),
|
|
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(),
|
|
});
|
|
|
|
/**
|
|
* 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).
|
|
.refine(
|
|
(input) => {
|
|
const ingredientIds = input.ingredients.map((ingredient) => ingredient.ingredientId);
|
|
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"],
|
|
});
|
|
|
|
/**
|
|
* 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` let the user attach metadata to the technique
|
|
* they're asserting (`correctedTechStepId`), same `source: "manual"`
|
|
* distinction the technique itself gets. **Omitted (`undefined`) means
|
|
* "leave whatever metadata already exists on this occurrence alone" —
|
|
* an explicit array, even `[]`, means "this is now the complete set,
|
|
* replace everything that was there" (auto-detected included; see
|
|
* `applyManualCorrection`'s own doc comment). This is why neither field
|
|
* has a `.default([])`: that would silently turn every plain relabel into
|
|
* a metadata wipe.** 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(),
|
|
})
|
|
.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.correctedTechStepId ?? null) !== null,
|
|
{
|
|
message: "ingredients/utensils require a correctedTechStepId to attach to",
|
|
path: ["correctedTechStepId"],
|
|
},
|
|
);
|
|
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
|
|
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
|