diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index 38f2438..ab4fc53 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -1,16 +1,29 @@ import type { FastifyInstance } from "fastify"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { schema, markMilestone } from "@app/database"; -import { allocateFefo, updateCookingAssumptionProfile } from "@app/inventory-engine"; +import { + allocateFefo, + computeBalance, + updateCookingAssumptionProfile, + rollbackCookingAssumptionProfile, +} from "@app/inventory-engine"; import { scaleNutrition } from "@app/nutrition-engine"; import type { Unit } from "@app/shared-types"; -import { cookingSessionStarted, cookingSessionCompleted } from "@app/analytics"; +import { + COOKING_SESSION_STATUSES, + MEAL_BOX_STATUSES, + 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"; -import { MEAL_TYPES } from "@app/shared-types"; export interface CompleteCookingInput { portionsCooked?: number; @@ -191,7 +204,11 @@ export async function completeCookingSessionCore( // 1. FEFO-avdrag const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = []; if (input.deductInventory !== false) { - const factor = portionsCooked / recipe.portions; + // Partiell förbrukning (Fas 3 §6.3): om actualPortionsEaten anges drar vi + // endast råvaror för de faktiskt ätna portionerna. Rester/överskott stannar + // kvar som råvara i lagret tills de förbrukas på annat sätt. + const consumptionPortions = input.actualPortionsEaten ?? portionsCooked; + const factor = consumptionPortions / recipe.portions; const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []); for (const ing of recipe.ingredients) { @@ -405,3 +422,234 @@ export async function completeCookingSessionCore( })), }; } + +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), + }; +} diff --git a/apps/api/src/lib/i18n.ts b/apps/api/src/lib/i18n.ts index 678cfb5..100bd12 100644 --- a/apps/api/src/lib/i18n.ts +++ b/apps/api/src/lib/i18n.ts @@ -34,6 +34,21 @@ const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record = { sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.", }; +const COOKED_UNDO_WINDOW_EXPIRED: Record = { + da: "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet.", + de: "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen.", + en: "This cooking session can no longer be undone. The 24-hour window has expired.", + es: "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas.", + fi: "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut.", + fr: "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré.", + it: "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta.", + nb: "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt.", + nl: "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken.", + pl: "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło.", + pt: "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou.", + sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.", +}; + function resolve(catalog: Record, languageTag: string): string { const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase(); return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!; @@ -44,6 +59,10 @@ export function t(key: string, languageTag: string): string { return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag); } + if (key === "cooked.undoWindowExpired") { + return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag); + } + if (key !== "onboarding.householdDefaultName") { // No other server-side keys are supported yet; fall back to a safe default. return HOUSEHOLD_DEFAULT_NAMES["sv"]!; diff --git a/apps/api/src/routes/cooking-sessions.ts b/apps/api/src/routes/cooking-sessions.ts index d89e8fe..cc5f63c 100644 --- a/apps/api/src/routes/cooking-sessions.ts +++ b/apps/api/src/routes/cooking-sessions.ts @@ -10,7 +10,7 @@ import { errors, parse } from "../lib/errors.js"; import { requireActiveHousehold, requireMembership } from "../lib/helpers.js"; import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics"; import { trackProductAnalytics } from "../lib/helpers.js"; -import { completeCookingSession } from "../lib/cooking.js"; +import { completeCookingSession, undoCookingSession } from "../lib/cooking.js"; import { z } from "zod"; /** @@ -103,7 +103,7 @@ export async function cookingSessionRoutes(app: FastifyInstance) { const params = z.object({ id: z.uuid() }).parse(req.params); const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {}); const session = await getOwnedSession(app, params.id, req.userId); - if (session.status === "completed" || session.status === "cancelled") { + if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") { throw errors.conflict("Sessionen är redan avslutad."); } @@ -196,6 +196,18 @@ export async function cookingSessionRoutes(app: FastifyInstance) { }; }); + /** + * Ångra en completed session inom 24 h. + * Ledger är append-only: reverseringstransaktioner skrivs, befintliga + * transaktioner rörs inte. + */ + app.post("/v1/cooking-sessions/:id/undo", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const session = await getOwnedSession(app, id, req.userId); + const result = await undoCookingSession(app, session, req.userId, req.correlationId); + return result; + }); + /** Lista hushållets aktiva sessioner. */ app.get("/v1/cooking-sessions", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index 1a21c3e..fb08509 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; import { cancelTimedOutCookingSessions } from "@app/database"; import { computeBalance } from "@app/inventory-engine"; +import type { Unit } from "@app/shared-types"; describe("cooking sessions", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); @@ -17,6 +18,36 @@ describe("cooking sessions", () => { let recipeId: string; const email = "cooking-session-test@example.invalid"; + async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) { + const [location] = await testDb.db + .select({ id: schema.storageLocations.id }) + .from(schema.storageLocations) + .where(eq(schema.storageLocations.householdId, householdId)) + .limit(1); + const [item] = await testDb.db + .insert(schema.inventoryItems) + .values({ + householdId, + canonicalIngredientId, + displayName, + quantity, + unit, + storageLocationId: location!.id, + source: "manual_search", + }) + .returning(); + await testDb.db.insert(schema.inventoryTransactions).values({ + householdId, + inventoryItemId: item!.id, + type: "purchase", + quantityDelta: quantity, + unit, + refType: "test_setup", + actorUserId: userId, + }); + return item!.id; + } + async function cleanup() { const existing = await testDb.db .select({ id: schema.users.id }) @@ -582,4 +613,419 @@ describe("cooking sessions", () => { const timedOut = await cancelTimedOutCookingSessions(testDb.db); expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true); }); + + it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => { + const recipe = (await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + })).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> }; + const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId; + + // Sätt upp ett känt lager om receptet har en icke-valfri ingrediens. + let itemId: string | undefined; + if (firstNonOptional) { + itemId = await createItemWithPurchase(firstNonOptional, "Testvara", 1000, "GRAM"); + } + + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + const complete = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 1, actualPortionsEaten: 3, leftoverEstimatePortions: 1 }, + }); + expect(complete.statusCode).toBe(200); + + const txsBefore = await testDb.db + .select({ count: count(schema.inventoryTransactions.id) }) + .from(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId)); + + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(200); + const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] }; + expect(undoBody.ok).toBe(true); + + // Sessionstatus = undone. + const session = await testDb.db + .select() + .from(schema.cookingSessions) + .where(eq(schema.cookingSessions.id, sessionId)) + .limit(1); + expect(session[0]!.status).toBe("undone"); + + // Append-only: fler transaktioner efter undo. + const txsAfter = await testDb.db + .select({ count: count(schema.inventoryTransactions.id) }) + .from(schema.inventoryTransactions) + .where(eq(schema.inventoryTransactions.cookingSessionId, sessionId)); + expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count)); + + // Meals borttagna. + const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId)); + expect(Number(meals[0]!.count)).toBe(0); + + // Matlådor markerade discarded. + const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length); + for (const box of boxes) expect(box.status).toBe("discarded"); + + // recipe_cooks borttagen och cookCount backad. + const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId)); + expect(cooks.length).toBe(0); + + // Inventory-transaktionsinvariant. + if (itemId) { + const item = await testDb.db + .select({ quantity: schema.inventoryItems.quantity }) + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); + const itemTxs = await testDb.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(itemTxs); + expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); + } + + // Analytics. + const analytics = await testDb.db + .select() + .from(schema.productAnalyticsEvents) + .where(eq(schema.productAnalyticsEvents.userId, userId)) + .orderBy(schema.productAnalyticsEvents.occurredAt); + const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string }; + expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true); + + if (itemId) { + await testDb.db + .delete(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)); + } + }); + + it("undo is rejected after 24h window", async () => { + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true }, + }); + 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 }, + }); + + // Simulera 25 h gammal completed session. + await testDb.db + .update(schema.cookingSessions) + .set({ completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }) + .where(eq(schema.cookingSessions.id, sessionId)); + + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(409); + }); + + it("partial consumption deducts only actual portions eaten from inventory", async () => { + // Skapa ett enkelt recept med en enda icke-valfri ingrediens. + const [recipe] = await testDb.db + .insert(schema.recipes) + .values({ + slug: `partial-${Date.now()}`, + titleSv: "Partial test", + 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: "pasta_dry", + displayNameSv: "Pasta", + quantity: 400, + unit: "GRAM", + optional: false, + sortOrder: 0, + }); + + await testDb.db + .delete(schema.inventoryItems) + .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); + const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM"); + + 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: 2, leftoverEstimatePortions: 0 }, + }); + + // 4 portioner → 400 g, 2 ätna → 200 g, alltså 200 g kvar. + const afterComplete = await testDb.db + .select({ quantity: schema.inventoryItems.quantity }) + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); + expect(afterComplete[0]!.quantity).toBeCloseTo(200, 1); + + // Undo återställer. + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(200); + const afterUndo = await testDb.db + .select({ quantity: schema.inventoryItems.quantity }) + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); + expect(afterUndo[0]!.quantity).toBeCloseTo(400, 1); + + // Städa testreceptet och lagerposten. + await testDb.db + .delete(schema.inventoryItems) + .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry"))); + 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("undo + new complete maintains inventory invariant", async () => { + const [recipe] = await testDb.db + .insert(schema.recipes) + .values({ + slug: `invariant-${Date.now()}`, + titleSv: "Invariant test", + 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: "rice_white", + displayNameSv: "Ris", + quantity: 400, + unit: "GRAM", + optional: false, + sortOrder: 0, + }); + + await testDb.db + .delete(schema.inventoryItems) + .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); + const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM"); + + async function assertInvariant() { + const item = await testDb.db + .select({ quantity: schema.inventoryItems.quantity }) + .from(schema.inventoryItems) + .where(eq(schema.inventoryItems.id, itemId)) + .limit(1); + const txs = await testDb.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); + expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6); + } + + 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: 4, leftoverEstimatePortions: 0 }, + }); + await assertInvariant(); + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + await assertInvariant(); + + // Ny session + complete efter undo ska bibehålla invarianten. + const start2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${testRecipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId2 = (JSON.parse(start2.body) as { session: { id: string } }).session.id; + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId2}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 }, + }); + await assertInvariant(); + + await testDb.db + .delete(schema.inventoryItems) + .where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white"))); + 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("legacy /cook followed by undo works", async () => { + const cook = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true }, + }); + expect(cook.statusCode).toBe(200); + const { sessionId } = JSON.parse(cook.body) as { sessionId: string }; + + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(200); + + const session = await testDb.db + .select() + .from(schema.cookingSessions) + .where(eq(schema.cookingSessions.id, sessionId)) + .limit(1); + expect(session[0]!.status).toBe("undone"); + + const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + expect(boxes.every((b) => b.status === "discarded")).toBe(true); + }); + + it("undo rolls back 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(); + + const cook = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true }, + }); + const { sessionId } = JSON.parse(cook.body) as { sessionId: string }; + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + + 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(0); + }); + + it("cannot undo a non-completed session", async () => { + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(409); + }); }); diff --git a/apps/mobile/src/locales/da/common.json b/apps/mobile/src/locales/da/common.json index 081e830..8b2fb66 100644 --- a/apps/mobile/src/locales/da/common.json +++ b/apps/mobile/src/locales/da/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Ingen forskelle fundet.", "scan.diff.undo": "Fortryd", "scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.", - "cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner." + "cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.", + "cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet." } diff --git a/apps/mobile/src/locales/de/common.json b/apps/mobile/src/locales/de/common.json index 639fb00..0e4eef3 100644 --- a/apps/mobile/src/locales/de/common.json +++ b/apps/mobile/src/locales/de/common.json @@ -383,5 +383,6 @@ "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.", - "cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten." + "cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.", + "cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen." } diff --git a/apps/mobile/src/locales/en/common.json b/apps/mobile/src/locales/en/common.json index f706744..59e73ed 100644 --- a/apps/mobile/src/locales/en/common.json +++ b/apps/mobile/src/locales/en/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "No differences found.", "scan.diff.undo": "Undo", "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." + "cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions.", + "cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired." } diff --git a/apps/mobile/src/locales/es/common.json b/apps/mobile/src/locales/es/common.json index e766a68..e383886 100644 --- a/apps/mobile/src/locales/es/common.json +++ b/apps/mobile/src/locales/es/common.json @@ -383,5 +383,6 @@ "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.", - "cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones." + "cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones.", + "cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas." } diff --git a/apps/mobile/src/locales/fi/common.json b/apps/mobile/src/locales/fi/common.json index a4a9c91..90898ff 100644 --- a/apps/mobile/src/locales/fi/common.json +++ b/apps/mobile/src/locales/fi/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Eroja ei löytynyt.", "scan.diff.undo": "Kumoa", "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ää." + "cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.", + "cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut." } diff --git a/apps/mobile/src/locales/fr/common.json b/apps/mobile/src/locales/fr/common.json index 12acf10..054f9bb 100644 --- a/apps/mobile/src/locales/fr/common.json +++ b/apps/mobile/src/locales/fr/common.json @@ -383,5 +383,6 @@ "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.", - "cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions." + "cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.", + "cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré." } diff --git a/apps/mobile/src/locales/it/common.json b/apps/mobile/src/locales/it/common.json index 07d7bc6..718ea4c 100644 --- a/apps/mobile/src/locales/it/common.json +++ b/apps/mobile/src/locales/it/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Nessuna differenza trovata.", "scan.diff.undo": "Annulla", "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." + "cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.", + "cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta." } diff --git a/apps/mobile/src/locales/nb/common.json b/apps/mobile/src/locales/nb/common.json index f3a6fde..ad50d0c 100644 --- a/apps/mobile/src/locales/nb/common.json +++ b/apps/mobile/src/locales/nb/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Ingen forskjeller funnet.", "scan.diff.undo": "Angre", "scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.", - "cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner." + "cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.", + "cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt." } diff --git a/apps/mobile/src/locales/nl/common.json b/apps/mobile/src/locales/nl/common.json index d6b3cfb..663adf0 100644 --- a/apps/mobile/src/locales/nl/common.json +++ b/apps/mobile/src/locales/nl/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Geen verschillen gevonden.", "scan.diff.undo": "Ongedaan maken", "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." + "cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.", + "cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken." } diff --git a/apps/mobile/src/locales/pl/common.json b/apps/mobile/src/locales/pl/common.json index 805038c..c9ec0d5 100644 --- a/apps/mobile/src/locales/pl/common.json +++ b/apps/mobile/src/locales/pl/common.json @@ -397,5 +397,6 @@ "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.", - "cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji." + "cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.", + "cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło." } diff --git a/apps/mobile/src/locales/pt/common.json b/apps/mobile/src/locales/pt/common.json index 1a5db6d..fb6dbbf 100644 --- a/apps/mobile/src/locales/pt/common.json +++ b/apps/mobile/src/locales/pt/common.json @@ -383,5 +383,6 @@ "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.", - "cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções." + "cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções.", + "cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou." } diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json index 315931f..9920f77 100644 --- a/apps/mobile/src/locales/sv/common.json +++ b/apps/mobile/src/locales/sv/common.json @@ -383,5 +383,6 @@ "scan.diff.noChanges": "Inga skillnader hittades.", "scan.diff.undo": "Ångra", "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." + "cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.", + "cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut." } diff --git a/docs/FAS3-COOKING-SESSIONS-AUDIT.md b/docs/FAS3-COOKING-SESSIONS-AUDIT.md index 5a2e736..6a5b9be 100644 --- a/docs/FAS3-COOKING-SESSIONS-AUDIT.md +++ b/docs/FAS3-COOKING-SESSIONS-AUDIT.md @@ -140,7 +140,7 @@ Att lägga till `prepared_food_batches` skulle skapa: --- ### Steg 3c – Partiell förbrukning §6.3 + undo_until -**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. +**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner. **Ändringar:** 1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror. @@ -149,24 +149,40 @@ Att lägga till `prepared_food_batches` skulle skapa: 2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb). 3. `POST /v1/cooking-sessions/:id/undo`: - Status måste vara `completed`. - - För varje `inventory_transactions` med `cookingSessionId`: skapa en `correction` med motsatt delta. - - För varje `meal` med `cookingSessionId`: radera (eller markera som cancelled). - - För varje `meal_box` med `cookingSessionId`: sätt status `discarded`. - - Sätt `cooking_sessions.status = 'cancelled'`. -4. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också. + - `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`. + - För varje `inventory_transactions.type = 'cook_use'` med `cookingSessionId`: skapa en `correction` med motsatt delta (append-only; befintliga rader rörs inte). + - Lagersaldo räknas om från transaktionshistoriken; `depletedAt` nollas där saldot blir > 0 igen. + - För varje `meal` med `cookingSessionId`: radera och emitta `MEAL_REMOVED`. + - För varje `meal_box` med `cookingSessionId`: sätt status `discarded`, `portionsRemaining = 0`, emitta `MEAL_BOX_DISCARDED`. + - Ta bort `recipe_cooks`-raden och backa `recipes.cookCount` med `GREATEST(cookCount - 1, 0)`. + - Sätt `cooking_sessions.status = 'undone'` (TEXT, valideras mot `COOKING_SESSION_STATUSES` i shared-types; ingen pgEnum, inga nya tabeller). + - Emitta `COOKING_SESSION_UNDONE` och analytics-event `cooking_session_undone` (consent-gatat via `trackProductAnalytics`, ingen fritext). + - Aktiveringsmilstolpar backas INTE (once-ever). +4. Antagandeprofiler rullas tillbaka deterministiskt via `lastSessionAnswers.sessionId`: + - `rollbackCookingAssumptionProfile(sessionId, existingProfile)` är en ren funktion i `packages/inventory-engine/src/cooking-profiles.ts`. + - Den tar bort det aktuella sessions-id:t från `lastSessionAnswers`, räknar om EMA från scratch i kronologisk ordning och sätter `observationCount = history.length`. + - Om historiken blir tom raderas profilen (inte nollas); det förhindrar dubbelräkning vid undo + ny complete. +5. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också. **Berörda filer:** +- `apps/api/src/lib/cooking.ts` (`completeCookingSession`, `undoCookingSession`) - `apps/api/src/routes/cooking-sessions.ts` -- `apps/api/src/routes/inventory.ts` (visa cookingSessionId i transaktionslistan) -- `packages/inventory-engine/src/fefo.ts` (ev. helper för omräkning) -- `apps/mobile/src/app/cooking/[id].tsx` +- `apps/api/src/lib/i18n.ts` +- `packages/inventory-engine/src/cooking-profiles.ts` +- `packages/shared-types/src/enums.ts`, `packages/shared-types/src/analytics.ts` +- `packages/events/src/index.ts` +- `packages/analytics/src/builders.ts` +- `apps/mobile/src/locales/*/common.json` (+12) -**Migration:** ingår i 0011 (kolumn cookingSessionId) +**Migration:** 0014_expand_event_types.sql (nya domänhändelser i `event_type`-enum) **Testplan:** - Laga 4 portioner, ät 3 → lager innehåller 25 % kvar av varje ingrediens - Undo → allt återställs -- Invariant: `computeBalance(transaktioner) === item.quantity` efter complete och efter undo +- Invariant: `computeBalance(transaktioner) === item.quantity` efter (a) complete, (b) undo, (c) undo + ny complete +- Append-only-bevis: antalet transaktioner ökar vid undo +- Undo efter 24 h → 409 med lokaliserat fel +- Legacy `/cook` → undo fungerar via samma kodväg --- diff --git a/infrastructure/migrations/0014_expand_event_types.sql b/infrastructure/migrations/0014_expand_event_types.sql new file mode 100644 index 0000000..e8297a5 --- /dev/null +++ b/infrastructure/migrations/0014_expand_event_types.sql @@ -0,0 +1,4 @@ +-- Expandera event_type-enum med nya domänhändelser för 3c (undo/partiell förbrukning). +ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_BOX_DISCARDED'; +ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_REMOVED'; +ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'COOKING_SESSION_UNDONE'; diff --git a/infrastructure/migrations/meta/_journal.json b/infrastructure/migrations/meta/_journal.json index 8c01350..5e75501 100644 --- a/infrastructure/migrations/meta/_journal.json +++ b/infrastructure/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1786049332540, "tag": "0013_dark_the_anarchist", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1786055900000, + "tag": "0014_expand_event_types", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/analytics/src/builders.ts b/packages/analytics/src/builders.ts index 69d6150..843e912 100644 --- a/packages/analytics/src/builders.ts +++ b/packages/analytics/src/builders.ts @@ -57,6 +57,7 @@ export const recipeSaved = builder("recipe_saved"); export const cookingSessionStarted = builder("cooking_session_started"); export const cookingSessionCompleted = builder("cooking_session_completed"); export const cookingSessionCancelled = builder("cooking_session_cancelled"); +export const cookingSessionUndone = builder("cooking_session_undone"); export const leftoversCreated = builder("leftovers_created"); export const substitutionConfirmed = builder("substitution_confirmed"); diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index bd5e8eb..e68910d 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -42,6 +42,13 @@ export interface EventPayloadMap { WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null }; MEAL_BOX_CREATED: { mealBoxId: string; portions: number }; MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number }; + MEAL_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string }; + MEAL_REMOVED: { mealId: string; recipeId: string; source: string }; + COOKING_SESSION_UNDONE: { + cookingSessionId: string; + recipeId: string; + reversedTransactions: number; + }; } /** Kontroll i kompileringstid att kartan täcker alla EventType. */ diff --git a/packages/inventory-engine/src/cooking-profiles.ts b/packages/inventory-engine/src/cooking-profiles.ts index f83b6d0..48f648d 100644 --- a/packages/inventory-engine/src/cooking-profiles.ts +++ b/packages/inventory-engine/src/cooking-profiles.ts @@ -55,6 +55,51 @@ export function updateCookingAssumptionProfile( return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers }; } +/** + * Återställ en antagandeprofil genom att ta bort ett specifikt sessions-id + * från historiken och räkna om medelvärdena deterministiskt från resterande + * observationer. Ren funktion – alla beroenden är explicita argument. + */ +export function rollbackCookingAssumptionProfile( + sessionId: string, + existing: ExistingProfile, +): { + averageEatenPortions: number | null; + averageLeftoverPortions: number | null; + observationCount: number; + lastSessionAnswers: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>; +} { + const history = (existing.lastSessionAnswers ?? []).filter((a) => a.sessionId !== sessionId); + + if (history.length === 0) { + return { + averageEatenPortions: null, + averageLeftoverPortions: null, + observationCount: 0, + lastSessionAnswers: [], + }; + } + + // Räkna om EMA från scratch i kronologisk ordning (äldst först). + const chronological = [...history].reverse(); + let avgEaten = chronological[0]!.eaten; + let avgLeftovers = chronological[0]!.leftovers; + + for (let i = 1; i < chronological.length; i++) { + const alpha = 1 / (i + 1); + const a = chronological[i]!; + avgEaten = round2(avgEaten + alpha * (a.eaten - avgEaten)); + avgLeftovers = round2(avgLeftovers + alpha * (a.leftovers - avgLeftovers)); + } + + return { + averageEatenPortions: avgEaten, + averageLeftoverPortions: avgLeftovers, + observationCount: history.length, + lastSessionAnswers: history, + }; +} + function clamp(n: number, min: number, max: number): number { return Math.max(min, Math.min(max, n)); } diff --git a/packages/inventory-engine/test/cooking-profiles.test.ts b/packages/inventory-engine/test/cooking-profiles.test.ts index bf6b8ea..2a33dbe 100644 --- a/packages/inventory-engine/test/cooking-profiles.test.ts +++ b/packages/inventory-engine/test/cooking-profiles.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js"; +import { + updateCookingAssumptionProfile, + rollbackCookingAssumptionProfile, +} from "../src/cooking-profiles.js"; describe("updateCookingAssumptionProfile", () => { it("initializes profile from first observation", () => { @@ -61,3 +64,104 @@ describe("updateCookingAssumptionProfile", () => { expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11"); }); }); + +describe("rollbackCookingAssumptionProfile", () => { + it("returns null averages and zero count when history becomes empty", () => { + const existing = updateCookingAssumptionProfile( + { + householdId: "h1", + canonicalIngredientId: "i1", + plannedPortions: 4, + actualPortionsEaten: 3, + leftoverEstimatePortions: 1, + sessionId: "s1", + date: "2026-08-07", + }, + { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }, + ); + const rolled = rollbackCookingAssumptionProfile("s1", existing); + expect(rolled.averageEatenPortions).toBeNull(); + expect(rolled.averageLeftoverPortions).toBeNull(); + expect(rolled.observationCount).toBe(0); + expect(rolled.lastSessionAnswers).toHaveLength(0); + }); + + it("recalculates averages deterministically from remaining history", () => { + let existing: { + averageEatenPortions: number | null; + averageLeftoverPortions: number | null; + observationCount: number; + lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null; + } = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }; + + // Three chronological observations. + const observations = [ + { sessionId: "s1", eaten: 4, leftovers: 0 }, + { sessionId: "s2", eaten: 2, leftovers: 1 }, + { sessionId: "s3", eaten: 3, leftovers: 1 }, + ]; + for (const obs of observations) { + existing = updateCookingAssumptionProfile( + { + householdId: "h1", + canonicalIngredientId: "i1", + plannedPortions: 4, + actualPortionsEaten: obs.eaten, + leftoverEstimatePortions: obs.leftovers, + sessionId: obs.sessionId, + date: "2026-08-07", + }, + existing, + ); + } + + // Roll back the middle observation and recompute from scratch. + const rolled = rollbackCookingAssumptionProfile("s2", existing); + expect(rolled.lastSessionAnswers).toHaveLength(2); + expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s1"); + expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s3"); + + // Recompute EMA manually from s1 then s3. + // s1: avgEaten=4, avgLeftovers=0 + // s3: alpha=1/2 => eaten=4+0.5*(3-4)=3.5, leftovers=0+0.5*(1-0)=0.5 + expect(rolled.averageEatenPortions).toBe(3.5); + expect(rolled.averageLeftoverPortions).toBe(0.5); + expect(rolled.observationCount).toBe(2); + }); + + it("is idempotent: rolling back the same session twice leaves history unchanged", () => { + let existing: { + averageEatenPortions: number | null; + averageLeftoverPortions: number | null; + observationCount: number; + lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null; + } = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }; + existing = updateCookingAssumptionProfile( + { + householdId: "h1", + canonicalIngredientId: "i1", + plannedPortions: 4, + actualPortionsEaten: 3, + leftoverEstimatePortions: 1, + sessionId: "s1", + date: "2026-08-07", + }, + existing, + ); + existing = updateCookingAssumptionProfile( + { + householdId: "h1", + canonicalIngredientId: "i1", + plannedPortions: 4, + actualPortionsEaten: 2, + leftoverEstimatePortions: 0, + sessionId: "s2", + date: "2026-08-07", + }, + existing, + ); + const first = rollbackCookingAssumptionProfile("s1", existing); + const second = rollbackCookingAssumptionProfile("s1", first); + expect(second).toEqual(first); + }); +}); diff --git a/packages/shared-types/src/analytics.ts b/packages/shared-types/src/analytics.ts index 7583e0c..422711c 100644 --- a/packages/shared-types/src/analytics.ts +++ b/packages/shared-types/src/analytics.ts @@ -53,6 +53,7 @@ export const ANALYTICS_COOKING_EVENT_NAMES = [ "cooking_session_started", "cooking_session_completed", "cooking_session_cancelled", + "cooking_session_undone", "leftovers_created", "substitution_confirmed", ] as const; diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 00be2d8..9c9edd2 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -413,6 +413,9 @@ export const EVENT_TYPES = [ "WEEK_PLAN_UPDATED", "MEAL_BOX_CREATED", "MEAL_BOX_CONSUMED", + "MEAL_BOX_DISCARDED", + "MEAL_REMOVED", + "COOKING_SESSION_UNDONE", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -433,6 +436,20 @@ export const MEAL_LOG_SOURCES = [ ] as const; export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number]; +/** Cooking session lifecycle (Fas 3 §6). TEXT i databasen, valideras mot denna const. */ +export const COOKING_SESSION_STATUSES = [ + "planned", + "started", + "completed", + "cancelled", + "undone", +] as const; +export type CookingSessionStatus = (typeof COOKING_SESSION_STATUSES)[number]; + +/** Matlådestatuser (spec §24). TEXT i databasen, valideras mot denna const. */ +export const MEAL_BOX_STATUSES = ["available", "consumed", "discarded"] as const; +export type MealBoxStatus = (typeof MEAL_BOX_STATUSES)[number]; + // --------------------------------------------------------------------------- // Smakprofil & feedback (spec §30) // ---------------------------------------------------------------------------