batchCooking/packages/shared/src/schemas/recipe.ts
Nicolas 8bdbfda3ae feat(planning): dialog de sélection de recette, création de planning, assignation avec portions
Ajoute le chaînon manquant entre le catalogue de recettes et le planning
hebdomadaire :

- Backend : `PlanningItem.portions` (nouvelle colonne + migration),
  `POST /planning/items` / `DELETE /planning/items/:id` (créent la
  semaine de planning à la volée si besoin), `GET /recipes` gagne les
  filtres `ingredientIds`/`dietIds` (ET) en plus de `suitableForHousehold`
  (déjà préparé).
- Frontend : nouveau `Dialog` générique (premier modal de l'app),
  `RecipePickerDialog` qui réutilise le même affichage que le catalogue
  (`RecipeTabs`/`RecipeTable`) avec recherche par nom, filtre ingrédients,
  filtre régime alimentaire, toggle "convient à tout le foyer", puis une
  étape de saisie du nombre de portions.
- `PlanningPage` : le bouton "+" de chaque case ouvre le dialog, le
  bouton "✕" retire la recette (optimiste, avec rollback si l'appel
  échoue), les portions s'affichent sur chaque chip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:44:02 +02:00

91 lines
5 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"),
unit: z.string().trim().min(1, "L'unité est requise").max(20),
});
/**
* 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(),
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>;