bbe7526ef3
- 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
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
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>;
|