import type { FastifyInstance } from "fastify"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { schema, markMilestone } from "@app/database"; import { allocateFefo, computeBalance, updateCookingAssumptionProfile, rollbackCookingAssumptionProfile, } from "@app/inventory-engine"; import { scaleNutrition } from "@app/nutrition-engine"; import type { Unit } from "@app/shared-types"; import { MEAL_TYPES } from "@app/shared-types"; import { cookingSessionStarted, cookingSessionCompleted, cookingSessionUndone, } 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"; export interface CompleteCookingInput { portionsCooked?: number; mealBoxPortions?: number; mealBoxFrozen?: boolean; mealBoxStorageLocationId?: string; deductInventory?: boolean; eaters?: Array<{ userId: string; portionFraction: number }>; 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 { ok: boolean; mealIds: string[]; mealBoxId: string | null; inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>; recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>; } /** * 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 (leftoverEstimatePortions < mealBoxPortions) { const languageTag = await userLanguageTag(app.db, userId); throw errors.badRequest(t("cooked.leftoverLessThanBox", 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( app: FastifyInstance, session: typeof schema.cookingSessions.$inferSelect, userId: string, input: CompleteCookingInput, correlationId: string, ): Promise { const recipe = await loadFullRecipe(app, session.recipeId); const householdId = session.householdId; const portionsCooked = input.portionsCooked ?? session.plannedPortions; const date = input.date ?? todayIso(); const mealType = (input.mealType ?? session.plannedMealType) as (typeof MEAL_TYPES)[number]; // 1. FEFO-avdrag const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = []; if (input.deductInventory !== false) { // Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade // portionerna = ätna + rester (inklusive matlådor). Endast portioner som // aldrig tillagats stannar kvar som råvara i lagret. const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked; const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0; const consumptionPortions = Math.min( portionsCooked, actualPortionsEaten + leftoverEstimatePortions, ); const factor = consumptionPortions / recipe.portions; const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []); for (const ing of recipe.ingredients) { if (ing.optional) continue; const override = overrides.get(ing.canonicalIngredientId); const requiredQty = override ? override.quantityUsed : ing.quantity * factor; const requiredUnit = override ? override.unit : ing.unit; if (requiredQty <= 0) continue; const stock = await app.db .select() .from(schema.inventoryItems) .where( and( eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId), isNull(schema.inventoryItems.depletedAt), gt(schema.inventoryItems.quantity, 0), ), ); if (stock.length === 0) continue; const [info] = await app.db .select() .from(schema.canonicalIngredients) .where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId)) .limit(1); const allocation = allocateFefo( requiredQty, requiredUnit as Unit, stock.map((s) => ({ id: s.id, canonicalIngredientId: s.canonicalIngredientId, quantity: s.quantity, unit: s.unit, bestBeforeDate: s.bestBeforeDate, useByDate: s.useByDate, openedAt: s.openedAt, frozenAt: s.frozenAt, thawedAt: s.thawedAt, purchasedAt: s.purchasedAt, })), { densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece }, ); for (const alloc of allocation.allocations) { const item = stock.find((s) => s.id === alloc.itemId)!; const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000); await app.db.insert(schema.inventoryTransactions).values({ householdId, inventoryItemId: alloc.itemId, cookingSessionId: session.id, type: "cook_use", quantityDelta: -(item.quantity - newQty), unit: item.unit, refType: "recipe_cook", refId: session.recipeId, actorUserId: userId, }); await app.db .update(schema.inventoryItems) .set({ quantity: newQty, depletedAt: newQty <= 0 ? new Date() : null, updatedAt: new Date(), }) .where(eq(schema.inventoryItems.id, alloc.itemId)); deductions.push({ itemId: alloc.itemId, quantity: alloc.quantity, unit: alloc.unit, name: item.displayName, }); } } } // 2. Måltider const eaters = input.eaters && input.eaters.length > 0 ? input.eaters : [{ userId, portionFraction: 1 }]; const mealIds: string[] = []; for (const eater of eaters) { const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction); const [meal] = await app.db .insert(schema.meals) .values({ userId: eater.userId, householdId, cookingSessionId: session.id, date, mealType, source: "cooked_recipe", recipeId: session.recipeId, titleSv: recipe.titleSv, portionFraction: eater.portionFraction, nutrition, nutritionIsEstimate: false, }) .returning(); mealIds.push(meal!.id); await emitEvent(app.db, { type: "MEAL_LOGGED", payload: { mealId: meal!.id, mealType, kcal: nutrition.kcal, source: "cooked_recipe" }, userId: eater.userId, householdId, correlationId, }); } // 3. Matlådor let mealBoxId: string | null = null; const mealBoxPortions = input.mealBoxPortions ?? 0; if (mealBoxPortions > 0) { const locationId = input.mealBoxStorageLocationId ?? ( await app.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where( and( eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"), ), ) .limit(1) )[0]?.id; if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades."); const useByDays = input.mealBoxFrozen ? 90 : 3; const recommendedUseBy = new Date(Date.parse(date) + useByDays * 86_400_000) .toISOString() .slice(0, 10); const [box] = await app.db .insert(schema.mealBoxes) .values({ householdId, recipeId: session.recipeId, cookingSessionId: session.id, titleSv: recipe.titleSv, portions: mealBoxPortions, portionsRemaining: mealBoxPortions, nutritionPerPortion: recipe.nutritionPerPortion, cookedAt: date, storageLocationId: locationId, frozen: input.mealBoxFrozen ?? false, recommendedUseBy, }) .returning(); mealBoxId = box!.id; await emitEvent(app.db, { type: "MEAL_BOX_CREATED", payload: { mealBoxId: box!.id, portions: mealBoxPortions }, userId, householdId, correlationId, }); } // 4. recipe_cooks + statistik await app.db.insert(schema.recipeCooks).values({ recipeId: session.recipeId, userId, householdId, cookingSessionId: session.id, portionsCooked, cookedAt: new Date(), }); await app.db .update(schema.recipes) .set({ cookCount: sql`${schema.recipes.cookCount} + 1` }) .where(eq(schema.recipes.id, session.recipeId)); await emitEvent(app.db, { type: "RECIPE_COOKED", payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions }, userId, householdId, correlationId, }); // 5. Uppdatera session (svaren skrivs över av completeCookingSession). await app.db .update(schema.cookingSessions) .set({ status: "completed", completedAt: new Date(), plannedDeductions: deductions, updatedAt: new Date(), }) .where(eq(schema.cookingSessions.id, session.id)); await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt"); if (deductions.length > 0) { await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt"); } return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions, recipeIngredients: recipe.ingredients.map((ing) => ({ canonicalIngredientId: ing.canonicalIngredientId, displayName: ing.displayNameSv, quantity: ing.quantity, unit: ing.unit, optional: ing.optional, })), }; } export interface UndoCookingSessionResult { ok: boolean; reversedTransactions: number; restoredItemIds: string[]; removedMealIds: string[]; discardedMealBoxIds: string[]; } /** * Ångra en completed cooking session (Fas 3 §6.3). * * - Ledger är append-only: befintliga transaktioner rörs inte; istället skrivs * reversal-transaktioner (type='correction') med motsatt delta. * - Inventory-saldo räknas om från transaktionshistoriken; depletedAt nollas där * saldot blir > 0 igen. * - meals och meal_boxes som skapades av sessionen tas bort resp. markeras * discarded; recipe_cooks-raden tas bort och cookCount backas. * - Aktiveringsmilstolpar rörs inte (once-ever). * - Antagandeprofiler rullas tillbaka deterministiskt via lastSessionAnswers. * - undo_until = completedAt + 24 h, verkställt server-side i UTC. */ export async function undoCookingSession( app: FastifyInstance, session: typeof schema.cookingSessions.$inferSelect, userId: string, correlationId: string, ): Promise { if (session.status !== "completed") { throw errors.conflict("Sessionen måste vara avslutad för att kunna ångras."); } if (!session.completedAt) { throw errors.internal("Sessionen saknar completedAt."); } const now = new Date(); const deadline = new Date(session.completedAt.getTime() + 24 * 60 * 60 * 1000); if (now > deadline) { const languageTag = await userLanguageTag(app.db, userId); throw errors.conflict(t("cooked.undoWindowExpired", languageTag)); } // 1. Reversera inventory-transaktioner (append-only). const cookUses = await app.db .select() .from(schema.inventoryTransactions) .where( and( eq(schema.inventoryTransactions.cookingSessionId, session.id), eq(schema.inventoryTransactions.type, "cook_use"), ), ); const affectedItemIds = new Set(); for (const tx of cookUses) { await app.db.insert(schema.inventoryTransactions).values({ householdId: tx.householdId, inventoryItemId: tx.inventoryItemId, cookingSessionId: session.id, type: "correction", quantityDelta: -tx.quantityDelta, unit: tx.unit, refType: "cooking_session_undo", refId: session.id, actorUserId: userId, note: "Ångrat cook_use", }); affectedItemIds.add(tx.inventoryItemId); } // 2. Återställ inventory-saldon från transaktionerna. for (const itemId of affectedItemIds) { const txs = await app.db .select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit }) .from(schema.inventoryTransactions) .where(eq(schema.inventoryTransactions.inventoryItemId, itemId)); const balance = computeBalance(txs); await app.db .update(schema.inventoryItems) .set({ quantity: balance.balance, depletedAt: balance.balance <= 1e-9 ? new Date() : null, updatedAt: new Date(), }) .where(eq(schema.inventoryItems.id, itemId)); } // 3. Ta bort måltider. const mealsToRemove = await app.db .select() .from(schema.meals) .where(eq(schema.meals.cookingSessionId, session.id)); for (const meal of mealsToRemove) { await emitEvent(app.db, { type: "MEAL_REMOVED", payload: { mealId: meal.id, recipeId: session.recipeId, source: "cooking_session_undo" }, userId: meal.userId, householdId: session.householdId, correlationId, }); } await app.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, session.id)); // 4. Markera matlådor som discarded. const boxesToDiscard = await app.db .select() .from(schema.mealBoxes) .where(eq(schema.mealBoxes.cookingSessionId, session.id)); for (const box of boxesToDiscard) { await emitEvent(app.db, { type: "MEAL_BOX_DISCARDED", payload: { mealBoxId: box.id, portions: box.portions, source: "cooking_session_undo" }, userId, householdId: session.householdId, correlationId, }); } await app.db .update(schema.mealBoxes) .set({ status: "discarded", portionsRemaining: 0 }) .where(eq(schema.mealBoxes.cookingSessionId, session.id)); // 5. Ta bort recipe_cooks-raden och backa cookCount. await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id)); await app.db .update(schema.recipes) .set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` }) .where(eq(schema.recipes.id, session.recipeId)); // 6. Rulla tillbaka antagandeprofiler. const recipe = await loadFullRecipe(app, session.recipeId); for (const ing of recipe.ingredients) { 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); if (!existing) continue; const rolledBack = rollbackCookingAssumptionProfile(session.id, { averageEatenPortions: existing.averageEatenPortions, averageLeftoverPortions: existing.averageLeftoverPortions, observationCount: existing.observationCount, lastSessionAnswers: existing.lastSessionAnswers, }); if (rolledBack.observationCount === 0) { await app.db .delete(schema.cookingAssumptionProfiles) .where( and( eq(schema.cookingAssumptionProfiles.householdId, session.householdId), eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId), ), ); } else { await app.db .insert(schema.cookingAssumptionProfiles) .values({ householdId: session.householdId, canonicalIngredientId: ing.canonicalIngredientId, averageEatenPortions: rolledBack.averageEatenPortions, averageLeftoverPortions: rolledBack.averageLeftoverPortions, observationCount: rolledBack.observationCount, lastSessionAnswers: rolledBack.lastSessionAnswers, updatedAt: new Date(), }) .onConflictDoUpdate({ target: [ schema.cookingAssumptionProfiles.householdId, schema.cookingAssumptionProfiles.canonicalIngredientId, ], set: { averageEatenPortions: rolledBack.averageEatenPortions, averageLeftoverPortions: rolledBack.averageLeftoverPortions, observationCount: rolledBack.observationCount, lastSessionAnswers: rolledBack.lastSessionAnswers, updatedAt: new Date(), }, }); } } // 7. Sätt sessionstatus till undone. await app.db .update(schema.cookingSessions) .set({ status: "undone", updatedAt: new Date(), }) .where(eq(schema.cookingSessions.id, session.id)); await trackProductAnalytics( app.db, userId, cookingSessionUndone({ householdId: session.householdId, properties: { cookingSessionId: session.id, recipeId: session.recipeId, reversedTransactions: cookUses.length, }, }), ); await emitEvent(app.db, { type: "COOKING_SESSION_UNDONE", payload: { cookingSessionId: session.id, recipeId: session.recipeId, reversedTransactions: cookUses.length, }, userId, householdId: session.householdId, correlationId, }); return { ok: true, reversedTransactions: cookUses.length, restoredItemIds: [...affectedItemIds], removedMealIds: mealsToRemove.map((m) => m.id), discardedMealBoxIds: boxesToDiscard.map((b) => b.id), }; }