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>
91 lines
5 KiB
TypeScript
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>;
|