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.
94 lines
5.4 KiB
TypeScript
94 lines
5.4 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"),
|
|
});
|
|
/** 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>;
|