From bbe7526ef3cd17051f0e9631150d9d2da64d64a2 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 7 Aug 2026 05:13:35 +0700 Subject: [PATCH] =?UTF-8?q?fix(3b):=20delad=20kompletteringsv=C3=A4g=20f?= =?UTF-8?q?=C3=B6r=20legacy=20/cook=20+=20deterministiska=20cooking-assump?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/lib/cooking.ts | 153 ++++++++++++++- apps/api/src/lib/i18n.ts | 29 ++- apps/api/src/routes/cooking-sessions.ts | 142 +++----------- apps/api/src/routes/recipes.ts | 6 +- apps/api/test/cooking-sessions.test.ts | 244 ++++++++++++++++++++++-- apps/mobile/src/locales/da/common.json | 3 +- apps/mobile/src/locales/de/common.json | 3 +- apps/mobile/src/locales/en/common.json | 3 +- apps/mobile/src/locales/es/common.json | 3 +- apps/mobile/src/locales/fi/common.json | 3 +- apps/mobile/src/locales/fr/common.json | 3 +- apps/mobile/src/locales/it/common.json | 3 +- apps/mobile/src/locales/nb/common.json | 3 +- apps/mobile/src/locales/nl/common.json | 3 +- apps/mobile/src/locales/pl/common.json | 3 +- apps/mobile/src/locales/pt/common.json | 3 +- apps/mobile/src/locales/sv/common.json | 3 +- packages/validation/src/recipes.ts | 9 +- 18 files changed, 457 insertions(+), 162 deletions(-) diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index 77a4119..38f2438 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -1,12 +1,15 @@ import type { FastifyInstance } from "fastify"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { schema, markMilestone } from "@app/database"; -import { allocateFefo } from "@app/inventory-engine"; +import { allocateFefo, updateCookingAssumptionProfile } from "@app/inventory-engine"; import { scaleNutrition } from "@app/nutrition-engine"; import type { Unit } from "@app/shared-types"; -import { todayIso, emitEvent } from "./helpers.js"; +import { cookingSessionStarted, cookingSessionCompleted } from "@app/analytics"; +import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js"; import { errors } from "./errors.js"; import { loadFullRecipe } from "../routes/recipes.js"; +import { userLanguageTag } from "./contentLanguage.js"; +import { t } from "./i18n.js"; import { MEAL_TYPES } from "@app/shared-types"; export interface CompleteCookingInput { @@ -19,6 +22,14 @@ export interface CompleteCookingInput { date?: string; mealType?: string; inventoryOverrides?: Array<{ canonicalIngredientId: string; quantityUsed: number; unit: string }>; + actualPortionsEaten?: number; + leftoverEstimatePortions?: number; + leftoverNote?: string; +} + +export interface CompleteCookingSessionOptions { + /** Sant för legacy POST /v1/recipes/:id/cook där session skapas och startas i ett steg. */ + emitStartedEvent?: boolean; } export interface CompleteCookingResult { @@ -30,8 +41,138 @@ export interface CompleteCookingResult { } /** - * Gemensam kärna för att "jag har lagat". Används av både - * POST /v1/recipes/:id/cook (legacy shortcut) och + * Delad kompletteringsväg för både POST /v1/recipes/:id/cook (legacy) + * och POST /v1/cooking-sessions/:id/complete. Hanterar summavalidering, + * persistens av svar, profiluppdateringar och analytics. + */ +export async function completeCookingSession( + app: FastifyInstance, + session: typeof schema.cookingSessions.$inferSelect, + userId: string, + input: CompleteCookingInput, + correlationId: string, + options: CompleteCookingSessionOptions = {}, +): Promise { + const plannedPortions = input.portionsCooked ?? session.plannedPortions; + const mealBoxPortions = input.mealBoxPortions ?? 0; + const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions); + const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions; + + if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) { + const languageTag = await userLanguageTag(app.db, userId); + throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag)); + } + + if (options.emitStartedEvent && session.status === "started") { + await trackProductAnalytics( + app.db, + userId, + cookingSessionStarted({ + householdId: session.householdId, + properties: { + cookingSessionId: session.id, + recipeId: session.recipeId, + status: session.status, + plannedPortions, + }, + }), + ); + } + + const result = await completeCookingSessionCore( + app, + session, + userId, + { ...input, portionsCooked: plannedPortions, mealBoxPortions }, + correlationId, + ); + + // Persistera användarens svar (ersätter kärnans default-värden). + const [updated] = await app.db + .update(schema.cookingSessions) + .set({ + actualPortionsEaten, + leftoverEstimatePortions, + leftoverNote: input.leftoverNote ?? null, + updatedAt: new Date(), + }) + .where(eq(schema.cookingSessions.id, session.id)) + .returning(); + if (!updated) throw errors.internal("Kunde inte uppdatera cooking session."); + + // Uppdatera antagandeprofiler per icke-valfri ingrediens (hushållsnivå). + const date = new Date().toISOString().slice(0, 10); + for (const ing of result.recipeIngredients) { + if (ing.optional) continue; + const [existing] = await app.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, session.householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId), + ), + ) + .limit(1); + + const updatedProfile = updateCookingAssumptionProfile( + { + householdId: session.householdId, + canonicalIngredientId: ing.canonicalIngredientId, + plannedPortions, + actualPortionsEaten, + leftoverEstimatePortions, + sessionId: session.id, + date, + }, + existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }, + ); + + await app.db + .insert(schema.cookingAssumptionProfiles) + .values({ + householdId: session.householdId, + canonicalIngredientId: ing.canonicalIngredientId, + ...updatedProfile, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + schema.cookingAssumptionProfiles.householdId, + schema.cookingAssumptionProfiles.canonicalIngredientId, + ], + set: { + averageEatenPortions: updatedProfile.averageEatenPortions, + averageLeftoverPortions: updatedProfile.averageLeftoverPortions, + observationCount: updatedProfile.observationCount, + lastSessionAnswers: updatedProfile.lastSessionAnswers, + updatedAt: new Date(), + }, + }); + } + + await trackProductAnalytics( + app.db, + userId, + cookingSessionCompleted({ + householdId: session.householdId, + properties: { + cookingSessionId: session.id, + recipeId: session.recipeId, + portionsCooked: plannedPortions, + actualPortionsEaten, + leftoverEstimatePortions, + mealBoxPortions, + }, + }), + ); + + return { ...result, session: updated }; +} + +/** + * Lågnivå-kärna för att "jag har lagat". Används via completeCookingSession + * av både POST /v1/recipes/:id/cook (legacy shortcut) och * POST /v1/cooking-sessions/:id/complete. */ export async function completeCookingSessionCore( @@ -234,14 +375,12 @@ export async function completeCookingSessionCore( correlationId, }); - // 5. Uppdatera session + // 5. Uppdatera session (svaren skrivs över av completeCookingSession). await app.db .update(schema.cookingSessions) .set({ status: "completed", completedAt: new Date(), - actualPortionsEaten: portionsCooked - mealBoxPortions, - leftoverEstimatePortions: mealBoxPortions, plannedDeductions: deductions, updatedAt: new Date(), }) diff --git a/apps/api/src/lib/i18n.ts b/apps/api/src/lib/i18n.ts index be46943..678cfb5 100644 --- a/apps/api/src/lib/i18n.ts +++ b/apps/api/src/lib/i18n.ts @@ -19,14 +19,35 @@ const HOUSEHOLD_DEFAULT_NAMES: Record = { sv: "Hemma", }; +const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record = { + da: "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.", + de: "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.", + en: "Eaten portions and leftovers cannot exceed the total number of portions.", + es: "Las raciones comidas y las sobras no pueden superar el número total de raciones.", + fi: "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.", + fr: "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.", + it: "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.", + nb: "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.", + nl: "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.", + pl: "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.", + pt: "As porções comidas e as sobras não podem ultrapassar o número total de porções.", + sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.", +}; + +function resolve(catalog: Record, languageTag: string): string { + const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase(); + return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!; +} + export function t(key: string, languageTag: string): string { + if (key === "cooked.portionsSumExceedsPlanned") { + return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag); + } + if (key !== "onboarding.householdDefaultName") { // No other server-side keys are supported yet; fall back to a safe default. return HOUSEHOLD_DEFAULT_NAMES["sv"]!; } - const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase(); - return ( - HOUSEHOLD_DEFAULT_NAMES[lang] ?? HOUSEHOLD_DEFAULT_NAMES["en"] ?? HOUSEHOLD_DEFAULT_NAMES["sv"]! - ); + return resolve(HOUSEHOLD_DEFAULT_NAMES, languageTag); } diff --git a/apps/api/src/routes/cooking-sessions.ts b/apps/api/src/routes/cooking-sessions.ts index eb98f1a..d89e8fe 100644 --- a/apps/api/src/routes/cooking-sessions.ts +++ b/apps/api/src/routes/cooking-sessions.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, inArray, lt } from "drizzle-orm"; import { schema, markMilestone } from "@app/database"; import { cookingSessionStartInputSchema, @@ -8,14 +8,9 @@ import { } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { requireActiveHousehold, requireMembership } from "../lib/helpers.js"; -import { - cookingSessionStarted, - cookingSessionCompleted, - cookingSessionCancelled, -} from "@app/analytics"; +import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics"; import { trackProductAnalytics } from "../lib/helpers.js"; -import { completeCookingSessionCore } from "../lib/cooking.js"; -import { updateCookingAssumptionProfile } from "@app/inventory-engine"; +import { completeCookingSession } from "../lib/cooking.js"; import { z } from "zod"; /** @@ -137,8 +132,8 @@ export async function cookingSessionRoutes(app: FastifyInstance) { /** * Complete en session. - * I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId. - * I steg 3b: sparar svar på max 2–3 frågor och uppdaterar hushållsantagandeprofiler. + * Delad kompletteringsväg via completeCookingSession hanterar summavalidering, + * persistens, profiluppdateringar och analytics. */ app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => { const { id } = parse(idParamSchema, req.params); @@ -148,107 +143,9 @@ export async function cookingSessionRoutes(app: FastifyInstance) { throw errors.conflict("Sessionen måste vara startad för att avslutas."); } - const mealBoxPortions = input.mealBoxPortions ?? 0; - const plannedPortions = input.portionsCooked ?? session.plannedPortions; - const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions); - const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions; + const result = await completeCookingSession(app, session, req.userId, input, req.correlationId); - if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) { - throw errors.badRequest("Åtna portioner + rester får inte överstiga totalt antal portioner."); - } - - const result = await completeCookingSessionCore( - app, - session, - req.userId, - { ...input, portionsCooked: plannedPortions, mealBoxPortions }, - req.correlationId, - ); - - await app.db - .update(schema.cookingSessions) - .set({ - actualPortionsEaten, - leftoverEstimatePortions, - leftoverNote: input.leftoverNote ?? null, - updatedAt: new Date(), - }) - .where(eq(schema.cookingSessions.id, id)); - - // Uppdatera antagandeprofiler per ingrediens (hushållsnivå). - const date = new Date().toISOString().slice(0, 10); - for (const ing of result.recipeIngredients) { - if (ing.optional) continue; - const [existing] = await app.db - .select() - .from(schema.cookingAssumptionProfiles) - .where( - and( - eq(schema.cookingAssumptionProfiles.householdId, session.householdId), - eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId), - ), - ) - .limit(1); - - const updatedProfile = updateCookingAssumptionProfile( - { - householdId: session.householdId, - canonicalIngredientId: ing.canonicalIngredientId, - plannedPortions, - actualPortionsEaten, - leftoverEstimatePortions, - sessionId: id, - date, - }, - existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }, - ); - - await app.db - .insert(schema.cookingAssumptionProfiles) - .values({ - householdId: session.householdId, - canonicalIngredientId: ing.canonicalIngredientId, - ...updatedProfile, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [ - schema.cookingAssumptionProfiles.householdId, - schema.cookingAssumptionProfiles.canonicalIngredientId, - ], - set: { - averageEatenPortions: updatedProfile.averageEatenPortions, - averageLeftoverPortions: updatedProfile.averageLeftoverPortions, - observationCount: updatedProfile.observationCount, - lastSessionAnswers: updatedProfile.lastSessionAnswers, - updatedAt: new Date(), - }, - }); - } - - const [updated] = await app.db - .select() - .from(schema.cookingSessions) - .where(eq(schema.cookingSessions.id, id)) - .limit(1); - - await trackProductAnalytics( - app.db, - req.userId, - cookingSessionCompleted({ - householdId: session.householdId, - properties: { - cookingSessionId: id, - recipeId: session.recipeId, - portionsCooked: plannedPortions, - actualPortionsEaten, - leftoverEstimatePortions, - mealBoxPortions, - }, - }), - ); - - return { ...result, session: updated }; + return result; }); /** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */ @@ -258,23 +155,38 @@ export async function cookingSessionRoutes(app: FastifyInstance) { await requireMembership(app.db, householdId, req.userId); const ings = await app.db - .select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId }) + .select({ + canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId, + optional: schema.recipeIngredients.optional, + }) .from(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, id)); if (ings.length === 0) throw errors.notFound("Receptet finns inte."); + const nonOptionalIds = ings.filter((i) => !i.optional).map((i) => i.canonicalIngredientId); + if (nonOptionalIds.length === 0) { + return { + householdId, + recipeId: id, + defaultActualPortionsEaten: null, + defaultLeftoverEstimatePortions: null, + observationCount: 0, + }; + } + const profiles = await app.db .select() .from(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, householdId), - eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ings[0]!.canonicalIngredientId), + inArray(schema.cookingAssumptionProfiles.canonicalIngredientId, nonOptionalIds), ), - ) - .limit(1); + ); + + // Deterministiskt val: profilen med flest observationer (stabil över tid). + const p = profiles.sort((a, b) => b.observationCount - a.observationCount)[0]; - const p = profiles[0]; return { householdId, recipeId: id, diff --git a/apps/api/src/routes/recipes.ts b/apps/api/src/routes/recipes.ts index 9b2be1f..f4b7319 100644 --- a/apps/api/src/routes/recipes.ts +++ b/apps/api/src/routes/recipes.ts @@ -32,7 +32,7 @@ import { todayIso, } from "../lib/helpers.js"; import { requireFeature } from "../lib/entitlements.js"; -import { completeCookingSessionCore } from "../lib/cooking.js"; +import { completeCookingSession } from "../lib/cooking.js"; /** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */ export async function recipeRoutes(app: FastifyInstance) { @@ -433,7 +433,9 @@ export async function recipeRoutes(app: FastifyInstance) { }) .returning(); - const result = await completeCookingSessionCore(app, session!, req.userId, input, req.correlationId); + const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, { + emitStartedEvent: true, + }); return { sessionId: session!.id, ...result }; }); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index 3a5856b..1a21c3e 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -1,6 +1,6 @@ import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; -import { and, eq, inArray, count } from "drizzle-orm"; +import { and, eq, inArray, count, sql } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; @@ -235,6 +235,115 @@ describe("cooking sessions", () => { expect(body.mealBoxId).toBeDefined(); }); + it("legacy /cook stores actual portions and leftover estimate on the session row", async () => { + const res = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { + portionsCooked: 4, + mealBoxPortions: 1, + actualPortionsEaten: 2, + leftoverEstimatePortions: 1, + leftoverNote: "sparas i kylen", + deductInventory: true, + }, + }); + expect(res.statusCode).toBe(200); + const { sessionId } = JSON.parse(res.body) as { sessionId: string }; + + const session = await testDb.db + .select() + .from(schema.cookingSessions) + .where(eq(schema.cookingSessions.id, sessionId)) + .limit(1); + expect(session[0]!.actualPortionsEaten).toBe(2); + expect(session[0]!.leftoverEstimatePortions).toBe(1); + expect(session[0]!.leftoverNote).toBe("sparas i kylen"); + }); + + it("legacy /cook updates cooking assumption profiles", async () => { + await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + + const recipe = (await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + expect(firstNonOptionalIngredientId).toBeDefined(); + + await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { + portionsCooked: 4, + actualPortionsEaten: 3, + leftoverEstimatePortions: 1, + deductInventory: true, + }, + }); + + const profile = await testDb.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!), + ), + ) + .limit(1); + expect(profile.length).toBe(1); + expect(profile[0]!.observationCount).toBe(1); + expect(profile[0]!.averageEatenPortions).toBe(3); + expect(profile[0]!.averageLeftoverPortions).toBe(1); + }); + + it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => { + const before = await testDb.db + .select({ name: schema.productAnalyticsEvents.eventName }) + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)); + + const res = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, deductInventory: true }, + }); + expect(res.statusCode).toBe(200); + const { sessionId } = JSON.parse(res.body) as { sessionId: string }; + + const after = await testDb.db + .select() + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)) + .orderBy(schema.productAnalyticsEvents.occurredAt); + const newEvents = after.slice(before.length); + const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; + const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId); + const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId); + expect(started.length).toBe(1); + expect(completed.length).toBe(1); + }); + + it("legacy /cook rejects when eaten + leftovers exceed planned portions", async () => { + const res = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { + portionsCooked: 4, + mealBoxPortions: 0, + actualPortionsEaten: 3, + leftoverEstimatePortions: 2, + }, + }); + expect(res.statusCode).toBe(400); + }); + it("stores actual portions and leftover estimate on complete", async () => { const start = await app.inject({ method: "POST", @@ -289,8 +398,9 @@ describe("cooking sessions", () => { method: "GET", url: `/v1/recipes/${recipeId}`, headers: { authorization: `Bearer ${token}` }, - })).json() as { ingredients: Array<{ canonicalIngredientId: string }> }; - const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId; + })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + expect(firstNonOptionalIngredientId).toBeDefined(); await app.inject({ method: "POST", @@ -299,22 +409,118 @@ describe("cooking sessions", () => { payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, }); - if (firstIngredientId) { - const profile = await testDb.db - .select() - .from(schema.cookingAssumptionProfiles) - .where( - and( - eq(schema.cookingAssumptionProfiles.householdId, householdId), - eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId), - ), - ) - .limit(1); - expect(profile.length).toBe(1); - expect(profile[0]!.observationCount).toBe(1); - expect(profile[0]!.averageEatenPortions).toBe(2); - expect(profile[0]!.averageLeftoverPortions).toBe(1); - } + const profile = await testDb.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!), + ), + ) + .limit(1); + expect(profile.length).toBe(1); + expect(profile[0]!.observationCount).toBe(1); + expect(profile[0]!.averageEatenPortions).toBe(2); + expect(profile[0]!.averageLeftoverPortions).toBe(1); + }); + + it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => { + await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + + // Skapa ett recept där den första ingrediensen är valfri. + const [recipe] = await testDb.db + .insert(schema.recipes) + .values({ + slug: `optional-first-${Date.now()}`, + titleSv: "Testrecept valfri först", + descriptionSv: "", + cuisine: "international", + mealTypes: ["dinner"], + tags: [], + methods: [], + equipment: [], + difficulty: "easy", + prepTimeMinutes: 5, + cookTimeMinutes: 10, + totalTimeMinutes: 15, + portions: 4, + nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 }, + allergens: [], + spiceLevel: 0, + dna: { + cuisine: "international", + vegetables: [], + flavorProfile: [], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 15, + calories: 100, + proteinGrams: 5, + }, + status: "published", + verificationStatus: "unverified", + sourceType: "own_editorial", + creatorDisplayName: "Test", + }) + .returning(); + const testRecipeId = recipe!.id; + + await testDb.db.insert(schema.recipeIngredients).values([ + { + recipeId: testRecipeId, + canonicalIngredientId: "olive_oil", + displayNameSv: "Olivolja", + quantity: 1, + unit: "TABLESPOON", + optional: true, + sortOrder: 0, + }, + { + recipeId: testRecipeId, + canonicalIngredientId: "pasta_dry", + displayNameSv: "Pasta", + quantity: 320, + unit: "GRAM", + optional: false, + sortOrder: 1, + }, + ]); + + // Completa en session och skriv profil för den icke-valfria ingrediensen. + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${testRecipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 1 }, + }); + + const assumptions = await app.inject({ + method: "GET", + url: `/v1/recipes/${testRecipeId}/cooking-assumptions`, + headers: { authorization: `Bearer ${token}` }, + }); + expect(assumptions.statusCode).toBe(200); + const body = JSON.parse(assumptions.body) as { + defaultActualPortionsEaten: number | null; + defaultLeftoverEstimatePortions: number | null; + observationCount: number; + }; + expect(body.observationCount).toBe(1); + expect(body.defaultActualPortionsEaten).toBe(3); + expect(body.defaultLeftoverEstimatePortions).toBe(1); + + // Städa upp testreceptet. + await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId)); + await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId)); }); it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => { diff --git a/apps/mobile/src/locales/da/common.json b/apps/mobile/src/locales/da/common.json index 3509c01..081e830 100644 --- a/apps/mobile/src/locales/da/common.json +++ b/apps/mobile/src/locales/da/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Bekræft alle", "scan.diff.noChanges": "Ingen forskelle fundet.", "scan.diff.undo": "Fortryd", - "scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen." + "scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.", + "cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner." } diff --git a/apps/mobile/src/locales/de/common.json b/apps/mobile/src/locales/de/common.json index 025473b..639fb00 100644 --- a/apps/mobile/src/locales/de/common.json +++ b/apps/mobile/src/locales/de/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Alle bestätigen", "scan.diff.noChanges": "Keine Unterschiede gefunden.", "scan.diff.undo": "Rückgängig", - "scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden." + "scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.", + "cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten." } diff --git a/apps/mobile/src/locales/en/common.json b/apps/mobile/src/locales/en/common.json index bc68098..f706744 100644 --- a/apps/mobile/src/locales/en/common.json +++ b/apps/mobile/src/locales/en/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Confirm all", "scan.diff.noChanges": "No differences found.", "scan.diff.undo": "Undo", - "scan.diff.undoHint": "Each change can be undone from the item detail view." + "scan.diff.undoHint": "Each change can be undone from the item detail view.", + "cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions." } diff --git a/apps/mobile/src/locales/es/common.json b/apps/mobile/src/locales/es/common.json index 0ba77fa..e766a68 100644 --- a/apps/mobile/src/locales/es/common.json +++ b/apps/mobile/src/locales/es/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Confirmar todo", "scan.diff.noChanges": "No se encontraron diferencias.", "scan.diff.undo": "Deshacer", - "scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto." + "scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.", + "cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones." } diff --git a/apps/mobile/src/locales/fi/common.json b/apps/mobile/src/locales/fi/common.json index 2483084..a4a9c91 100644 --- a/apps/mobile/src/locales/fi/common.json +++ b/apps/mobile/src/locales/fi/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Vahvista kaikki", "scan.diff.noChanges": "Eroja ei löytynyt.", "scan.diff.undo": "Kumoa", - "scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä." + "scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.", + "cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää." } diff --git a/apps/mobile/src/locales/fr/common.json b/apps/mobile/src/locales/fr/common.json index e10ac8c..12acf10 100644 --- a/apps/mobile/src/locales/fr/common.json +++ b/apps/mobile/src/locales/fr/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Tout confirmer", "scan.diff.noChanges": "Aucune différence trouvée.", "scan.diff.undo": "Annuler", - "scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article." + "scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.", + "cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions." } diff --git a/apps/mobile/src/locales/it/common.json b/apps/mobile/src/locales/it/common.json index 5a8aad9..07d7bc6 100644 --- a/apps/mobile/src/locales/it/common.json +++ b/apps/mobile/src/locales/it/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Conferma tutto", "scan.diff.noChanges": "Nessuna differenza trovata.", "scan.diff.undo": "Annulla", - "scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo." + "scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.", + "cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni." } diff --git a/apps/mobile/src/locales/nb/common.json b/apps/mobile/src/locales/nb/common.json index 1f4d897..f3a6fde 100644 --- a/apps/mobile/src/locales/nb/common.json +++ b/apps/mobile/src/locales/nb/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Bekreft alle", "scan.diff.noChanges": "Ingen forskjeller funnet.", "scan.diff.undo": "Angre", - "scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen." + "scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.", + "cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner." } diff --git a/apps/mobile/src/locales/nl/common.json b/apps/mobile/src/locales/nl/common.json index 58a920e..d6b3cfb 100644 --- a/apps/mobile/src/locales/nl/common.json +++ b/apps/mobile/src/locales/nl/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Alles bevestigen", "scan.diff.noChanges": "Geen verschillen gevonden.", "scan.diff.undo": "Ongedaan maken", - "scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave." + "scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.", + "cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden." } diff --git a/apps/mobile/src/locales/pl/common.json b/apps/mobile/src/locales/pl/common.json index c978e42..805038c 100644 --- a/apps/mobile/src/locales/pl/common.json +++ b/apps/mobile/src/locales/pl/common.json @@ -396,5 +396,6 @@ "scan.diff.acceptAll": "Potwierdź wszystko", "scan.diff.noChanges": "Nie znaleziono różnic.", "scan.diff.undo": "Cofnij", - "scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu." + "scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.", + "cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji." } diff --git a/apps/mobile/src/locales/pt/common.json b/apps/mobile/src/locales/pt/common.json index 425eccc..1a5db6d 100644 --- a/apps/mobile/src/locales/pt/common.json +++ b/apps/mobile/src/locales/pt/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Confirmar tudo", "scan.diff.noChanges": "Nenhuma diferença encontrada.", "scan.diff.undo": "Desfazer", - "scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item." + "scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.", + "cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções." } diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json index d513d54..315931f 100644 --- a/apps/mobile/src/locales/sv/common.json +++ b/apps/mobile/src/locales/sv/common.json @@ -382,5 +382,6 @@ "scan.diff.acceptAll": "Bekräfta alla", "scan.diff.noChanges": "Inga skillnader hittades.", "scan.diff.undo": "Ångra", - "scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy." + "scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.", + "cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner." } diff --git a/packages/validation/src/recipes.ts b/packages/validation/src/recipes.ts index e43cf8c..e9a30eb 100644 --- a/packages/validation/src/recipes.ts +++ b/packages/validation/src/recipes.ts @@ -122,6 +122,12 @@ export const cookRecipeInputSchema = z.object({ .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; @@ -152,8 +158,5 @@ export const cookingSessionCompleteInputSchema = cookRecipeInputSchema.partial() ) .default([]), mealType: z.enum(MEAL_TYPES).optional(), - actualPortionsEaten: z.number().int().min(0).max(24).optional(), - leftoverEstimatePortions: z.number().int().min(0).max(24).optional(), - leftoverNote: z.string().max(200).optional(), }); export type CookingSessionCompleteInput = z.infer;