Files
Cibello-app/packages/validation/src/recipes.ts
T
Sven (AAMOS AI) bbe7526ef3 fix(3b): delad kompletteringsväg för legacy /cook + deterministiska cooking-assumptions
- cookRecipeInputSchema utökas med actualPortionsEaten, leftoverEstimatePortions, leftoverNote
- completeCookingSession-wrapper i apps/api/src/lib/cooking.ts hanterar summavalidering,
  persistens av svar, profiluppdateringar och analytics (started+completed) för båda vägarna
- Legacy POST /v1/recipes/:id/cook använder wrappern med emitStartedEvent
- GET /v1/recipes/:id/cooking-assumptions väljer deterministiskt bland icke-valfria ingredienser
- i18n: nyckel cooked.portionsSumExceedsPlanned i samtliga 12 lokaler + server-i18n
- Nya tester för legacy /cook och cooking-assumptions med valfri första ingrediens

Refs: steg 3b-fix, granskningsrunda 2026-08-07
2026-08-07 05:13:35 +07:00

163 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof recipeQuerySchema>;
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<typeof createUserRecipeInputSchema>;
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<typeof rateRecipeInputSchema>;
/** "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<typeof cookRecipeInputSchema>;
export const substitutionQuerySchema = z.object({
fromIngredientId: z.string().min(1).max(80),
context: z.string().max(60).optional(),
});
export type SubstitutionQuery = z.infer<typeof substitutionQuerySchema>;
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<typeof cookingSessionStartInputSchema>;
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<typeof cookingSessionCompleteInputSchema>;