import { z } from "zod"; import { ALLERGENS, COOKING_METHODS, CUISINES, EQUIPMENT, FEEDBACK_TAGS, MEAL_TYPES, RECIPE_DIFFICULTIES, RECIPE_TAGS, } from "@app/shared-types"; import { quantitySchema, unitSchema, uuidSchema } from "./common.js"; export const recipeQuerySchema = z.object({ search: z.string().max(120).optional(), cuisine: z.enum(CUISINES).optional(), mealType: z.enum(MEAL_TYPES).optional(), tags: z .union([z.enum(RECIPE_TAGS), z.array(z.enum(RECIPE_TAGS))]) .transform((v) => (Array.isArray(v) ? v : [v])) .optional(), method: z.enum(COOKING_METHODS).optional(), maxTotalMinutes: z.coerce.number().int().min(1).max(600).optional(), maxKcalPerPortion: z.coerce.number().int().min(50).max(5000).optional(), minProteinPerPortion: z.coerce.number().int().min(0).max(300).optional(), maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(), difficulty: z.enum(RECIPE_DIFFICULTIES).optional(), excludeAllergens: z .union([z.enum(ALLERGENS), z.array(z.enum(ALLERGENS))]) .transform((v) => (Array.isArray(v) ? v : [v])) .optional(), creatorUserId: uuidSchema.optional(), sort: z.enum(["relevance", "rating", "cooked", "newest", "time", "cost"]).default("relevance"), limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0), }); export type RecipeQuery = z.infer; const recipeIngredientInputSchema = z.object({ canonicalIngredientId: z.string().min(1).max(80), displayNameSv: z.string().min(1).max(120), quantity: quantitySchema, unit: unitSchema, note: z.string().max(120).optional(), optional: z.boolean().default(false), groupName: z.string().max(60).optional(), }); const recipeStepInputSchema = z.object({ instructionSv: z.string().min(3).max(1000), timerSeconds: z.number().int().min(5).max(86_400).optional(), temperatureC: z.number().int().min(30).max(350).optional(), tip: z.string().max(300).optional(), }); /** * Användarrecept (spec §35): antingen strukturerat direkt, eller fritext som * AI strukturerar (via AAMOS) och användaren sedan granskar. */ export const createUserRecipeInputSchema = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("structured"), titleSv: z.string().min(3).max(150), descriptionSv: z.string().max(2000).default(""), cuisine: z.enum(CUISINES).default("international"), mealTypes: z.array(z.enum(MEAL_TYPES)).min(1), tags: z.array(z.enum(RECIPE_TAGS)).default([]), methods: z.array(z.enum(COOKING_METHODS)).default([]), equipment: z.array(z.enum(EQUIPMENT)).default([]), difficulty: z.enum(RECIPE_DIFFICULTIES).default("easy"), prepTimeMinutes: z.number().int().min(0).max(600), cookTimeMinutes: z.number().int().min(0).max(1440), portions: z.number().int().min(1).max(24), spiceLevel: z.number().int().min(0).max(5).default(0), ingredients: z.array(recipeIngredientInputSchema).min(1).max(60), steps: z.array(recipeStepInputSchema).min(1).max(40), }), z.object({ mode: z.literal("free_text"), text: z.string().min(20).max(8000), }), ]); export type CreateUserRecipeInput = z.infer; export const rateRecipeInputSchema = z.object({ stars: z.number().int().min(1).max(5), feedbackTags: z.array(z.enum(FEEDBACK_TAGS)).max(8).default([]), comment: z.string().max(1000).optional(), }); export type RateRecipeInput = z.infer; /** "Jag har lagat detta" – kärnflödet som drar lager och loggar måltid (spec §23). */ export const cookRecipeInputSchema = z.object({ portionsCooked: z.number().int().min(1).max(24), /** Vilka i hushållet som åt, med portionsandel per person. */ eaters: z .array( z.object({ userId: uuidSchema, portionFraction: z.number().min(0.1).max(3).default(1), }), ) .default([]), /** Portioner som blev matlådor (spec §24). */ mealBoxPortions: z.number().int().min(0).max(24).default(0), mealBoxStorageLocationId: uuidSchema.optional(), mealBoxFrozen: z.boolean().default(false), /** Dra ingredienser från lagret? Förifyllt förslag visas i appen. */ deductInventory: z.boolean().default(true), /** Justeringar av vad som faktiskt användes. */ inventoryOverrides: z .array( z.object({ canonicalIngredientId: z.string().max(80), quantityUsed: z.number().min(0), unit: unitSchema, }), ) .default([]), date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), mealType: z.enum(MEAL_TYPES).default("dinner"), /** Antal portioner som faktiskt åts (steg 3c). */ actualPortionsEaten: z.number().int().min(0).max(24).optional(), /** Uppskattade restportioner efter måltiden (steg 3c). */ leftoverEstimatePortions: z.number().int().min(0).max(24).optional(), /** Fritextnotering om rester – aldrig i analytics. */ leftoverNote: z.string().max(200).optional(), }); export type CookRecipeInput = z.infer; export const substitutionQuerySchema = z.object({ fromIngredientId: z.string().min(1).max(80), context: z.string().max(60).optional(), }); export type SubstitutionQuery = z.infer; export const cookingSessionStartInputSchema = z.object({ portions: z.number().int().min(1).max(24).optional(), mealType: z.enum(MEAL_TYPES).optional(), startNow: z.boolean().default(false), }); export type CookingSessionStartInput = z.infer; export const cookingSessionCompleteInputSchema = cookRecipeInputSchema.partial().extend({ portionsCooked: z.number().int().min(1).max(24).optional(), mealBoxPortions: z.number().int().min(0).max(24).default(0), mealBoxFrozen: z.boolean().default(false), deductInventory: z.boolean().default(true), eaters: z .array( z.object({ userId: uuidSchema, portionFraction: z.number().min(0.1).max(3).default(1), }), ) .default([]), mealType: z.enum(MEAL_TYPES).optional(), }); export type CookingSessionCompleteInput = z.infer;