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; /** `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; /** * 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; /** * 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; /** * 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. */ 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(), }) .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"], }, ); /** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */ export type SubmitTechStepCorrectionInput = z.infer;