diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index db1d32a..4d60735 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { and, eq, gt, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, gt, isNull, sql } from "drizzle-orm"; import { schema, markMilestone } from "@app/database"; import { allocateFefo, @@ -14,6 +14,7 @@ import { cookingSessionStarted, cookingSessionCompleted, cookingSessionUndone, + leftoversCreated, } from "@app/analytics"; import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js"; import { errors } from "./errors.js"; @@ -45,6 +46,7 @@ export interface CompleteCookingResult { ok: boolean; mealIds: string[]; mealBoxId: string | null; + mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>; inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>; recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>; } @@ -93,6 +95,7 @@ export async function completeCookingSession( ); } + const mealDate = input.date ?? todayIso(); const result = await completeCookingSessionCore( app, session, @@ -103,6 +106,7 @@ export async function completeCookingSession( mealBoxPortions, actualPortionsEaten, leftoverEstimatePortions, + date: mealDate, }, correlationId, ); @@ -114,6 +118,8 @@ export async function completeCookingSession( actualPortionsEaten, leftoverEstimatePortions, leftoverNote: input.leftoverNote ?? null, + mealDate, + mealBoxMutations: result.mealBoxMutations, updatedAt: new Date(), }) .where(eq(schema.cookingSessions.id, session.id)) @@ -333,10 +339,11 @@ export async function completeCookingSessionCore( }); } - // 3. Matlådor + // 3. Matlådor (Fas 3d): alla rester hamnar i befintliga eller nya meal_boxes. let mealBoxId: string | null = null; - const mealBoxPortions = input.mealBoxPortions ?? 0; - if (mealBoxPortions > 0) { + const mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> = []; + const leftoverEstimatePortionsForBox = input.leftoverEstimatePortions ?? 0; + if (leftoverEstimatePortionsForBox > 0) { const locationId = input.mealBoxStorageLocationId ?? ( @@ -353,34 +360,97 @@ export async function completeCookingSessionCore( )[0]?.id; if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades."); - const useByDays = input.mealBoxFrozen ? 90 : 3; + const frozen = input.mealBoxFrozen ?? false; + const useByDays = frozen ? 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({ + + const [existingBox] = await app.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + eq(schema.mealBoxes.recipeId, session.recipeId), + eq(schema.mealBoxes.cookedAt, date), + eq(schema.mealBoxes.frozen, frozen), + eq(schema.mealBoxes.status, "available"), + ), + ) + .orderBy(asc(schema.mealBoxes.recommendedUseBy)) + .limit(1); + + if (existingBox) { + const newRecommendedUseBy = + existingBox.recommendedUseBy < recommendedUseBy + ? existingBox.recommendedUseBy + : recommendedUseBy; + await app.db + .update(schema.mealBoxes) + .set({ + portions: existingBox.portions + leftoverEstimatePortionsForBox, + portionsRemaining: existingBox.portionsRemaining + leftoverEstimatePortionsForBox, + recommendedUseBy: newRecommendedUseBy, + leftoverSource: "cook_session", + }) + .where(eq(schema.mealBoxes.id, existingBox.id)); + mealBoxId = existingBox.id; + mealBoxMutations.push({ mealBoxId: existingBox.id, deltaPortions: leftoverEstimatePortionsForBox, frozen }); + await emitEvent(app.db, { + type: "MEAL_BOX_UPDATED", + payload: { + mealBoxId: existingBox.id, + addedPortions: leftoverEstimatePortionsForBox, + totalPortions: existingBox.portions + leftoverEstimatePortionsForBox, + }, + userId, 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 }, + correlationId, + }); + } else { + const [box] = await app.db + .insert(schema.mealBoxes) + .values({ + householdId, + recipeId: session.recipeId, + cookingSessionId: session.id, + titleSv: recipe.titleSv, + portions: leftoverEstimatePortionsForBox, + portionsRemaining: leftoverEstimatePortionsForBox, + nutritionPerPortion: recipe.nutritionPerPortion, + cookedAt: date, + storageLocationId: locationId, + frozen, + recommendedUseBy, + leftoverSource: "cook_session", + }) + .returning(); + mealBoxId = box!.id; + mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen }); + await emitEvent(app.db, { + type: "MEAL_BOX_CREATED", + payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox }, + userId, + householdId, + correlationId, + }); + } + + await trackProductAnalytics( + app.db, userId, - householdId, - correlationId, - }); + leftoversCreated({ + householdId, + properties: { + cookingSessionId: session.id, + recipeId: session.recipeId, + portions: leftoverEstimatePortionsForBox, + mealBoxId: mealBoxId!, + merged: !!existingBox, + }, + }), + ); } // 4. recipe_cooks + statistik @@ -398,7 +468,7 @@ export async function completeCookingSessionCore( .where(eq(schema.recipes.id, session.recipeId)); await emitEvent(app.db, { type: "RECIPE_COOKED", - payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions }, + payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 }, userId, householdId, correlationId, @@ -424,6 +494,7 @@ export async function completeCookingSessionCore( ok: true, mealIds, mealBoxId, + mealBoxMutations, inventoryDeductions: deductions, recipeIngredients: recipe.ingredients.map((ing) => ({ canonicalIngredientId: ing.canonicalIngredientId, @@ -537,24 +608,69 @@ export async function undoCookingSession( } 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, - }); + // 4. Återför matlådeportioner (3d). Mutationerna lagrade på sessionen låter + // oss backa även när rester slagits ihop med en befintlig matlåda. + const discardedMealBoxIds: string[] = []; + const mutations = session.mealBoxMutations ?? []; + if (mutations.length > 0) { + for (const mutation of mutations) { + const [box] = await app.db + .select() + .from(schema.mealBoxes) + .where(eq(schema.mealBoxes.id, mutation.mealBoxId)) + .limit(1); + if (!box) continue; + + const newPortions = Math.max(0, box.portions - mutation.deltaPortions); + const newRemaining = Math.max(0, box.portionsRemaining - mutation.deltaPortions); + if (newPortions <= 0) { + await app.db + .update(schema.mealBoxes) + .set({ status: "discarded", portionsRemaining: 0 }) + .where(eq(schema.mealBoxes.id, box.id)); + discardedMealBoxIds.push(box.id); + await emitEvent(app.db, { + type: "MEAL_BOX_DISCARDED", + payload: { mealBoxId: box.id, portions: mutation.deltaPortions, source: "cooking_session_undo" }, + userId, + householdId: session.householdId, + correlationId, + }); + } else { + await app.db + .update(schema.mealBoxes) + .set({ portions: newPortions, portionsRemaining: newRemaining }) + .where(eq(schema.mealBoxes.id, box.id)); + await emitEvent(app.db, { + type: "MEAL_BOX_UPDATED", + payload: { mealBoxId: box.id, addedPortions: -mutation.deltaPortions, totalPortions: newPortions }, + userId, + householdId: session.householdId, + correlationId, + }); + } + } + } else { + // Legacy: före 3d lagrades cookingSessionId på lådan. + 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, + }); + discardedMealBoxIds.push(box.id); + } + await app.db + .update(schema.mealBoxes) + .set({ status: "discarded", portionsRemaining: 0 }) + .where(eq(schema.mealBoxes.cookingSessionId, session.id)); } - 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)); @@ -662,6 +778,6 @@ export async function undoCookingSession( reversedTransactions: cookUses.length, restoredItemIds: [...affectedItemIds], removedMealIds: mealsToRemove.map((m) => m.id), - discardedMealBoxIds: boxesToDiscard.map((b) => b.id), + discardedMealBoxIds, }; } diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index ae0f092..74a8db8 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -1307,4 +1307,225 @@ describe("cooking sessions", () => { }); expect(undo.statusCode).toBe(409); }); + + it("3d: leftovers merge into existing meal box with same recipe, date and frozen state", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + + const cook1 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + }); + expect(cook1.statusCode).toBe(200); + const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; + + const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)); + expect(boxes1.length).toBe(1); + const boxId = boxes1[0]!.id; + + const cook2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true }, + }); + expect(cook2.statusCode).toBe(200); + const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; + + const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + expect(boxes2[0]!.portions).toBe(3); + expect(boxes2[0]!.portionsRemaining).toBe(3); + + const sessions = await testDb.db + .select({ mutations: schema.cookingSessions.mealBoxMutations }) + .from(schema.cookingSessions) + .where(eq(schema.cookingSessions.id, sessionId2)); + expect(sessions[0]!.mutations).toEqual([{ mealBoxId: boxId, deltaPortions: 2, frozen: false }]); + }); + + it("3d: leftovers do not merge across cooking dates", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + + const cook1 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true }, + }); + expect(cook1.statusCode).toBe(200); + const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; + + const cook2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + }); + expect(cook2.statusCode).toBe(200); + const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; + + const allBoxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]), + ), + ); + expect(allBoxes.length).toBe(2); + }); + + it("3d: fridge and freezer leftovers do not merge", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + + const cook1 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + }); + expect(cook1.statusCode).toBe(200); + const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; + + const cook2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true }, + }); + expect(cook2.statusCode).toBe(200); + const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; + + const boxes = await testDb.db + .select() + .from(schema.mealBoxes) + .where( + and( + eq(schema.mealBoxes.householdId, householdId), + inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]), + ), + ); + expect(boxes.length).toBe(2); + expect(boxes.filter((b) => b.frozen).length).toBe(1); + expect(boxes.filter((b) => !b.frozen).length).toBe(1); + }); + + it("3d: undo restores merged meal box portions", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + + const cook1 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true }, + }); + const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; + const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id; + + const cook2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true }, + }); + const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; + + const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + expect(beforeUndo[0]!.portions).toBe(3); + + const undo = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId2}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + expect(undo.statusCode).toBe(200); + + const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId)); + expect(afterUndo[0]!.portions).toBe(1); + expect(afterUndo[0]!.portionsRemaining).toBe(1); + expect(afterUndo[0]!.status).toBe("available"); + }); + + it("3d: undo discards a meal box created solely by the session", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + + const cook = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true }, + }); + 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 boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId)); + expect(boxes[0]!.status).toBe("discarded"); + expect(boxes[0]!.portionsRemaining).toBe(0); + }); + + it("3d: ledger invariant holds after merge and undo", async () => { + await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId)); + const itemId = await createItemWithPurchase("carrot", "morot", 10, "COUNT"); + + const cook1 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true }, + }); + const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string }; + + const cook2 = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook`, + headers: { authorization: `Bearer ${token}` }, + payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true }, + }); + const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string }; + + const assertItemBalance = async () => { + 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); + const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1); + expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6); + }; + + await assertItemBalance(); + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId2}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + + await assertItemBalance(); + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId1}/undo`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }); + + await assertItemBalance(); + }); }); diff --git a/apps/mobile/src/app/cooking/[id].tsx b/apps/mobile/src/app/cooking/[id].tsx index 2a97659..deaed90 100644 --- a/apps/mobile/src/app/cooking/[id].tsx +++ b/apps/mobile/src/app/cooking/[id].tsx @@ -48,6 +48,7 @@ export default function CookingScreen() { const [timerLeft, setTimerLeft] = useState(null); const timerRef = useRef | null>(null); const [finishing, setFinishing] = useState(false); + const [completedSessionId, setCompletedSessionId] = useState(null); const [mealBoxPortions, setMealBoxPortions] = useState(0); const [actualPortionsEaten, setActualPortionsEaten] = useState(null); const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState(null); @@ -58,11 +59,28 @@ export default function CookingScreen() { }); const cook = useMutation({ - mutationFn: (body: unknown) => api(`/v1/recipes/${id}/cook`, { method: "POST", body }), - onSuccess: async () => { + mutationFn: (body: unknown) => + api<{ sessionId: string; mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> }>( + `/v1/recipes/${id}/cook`, + { method: "POST", body }, + ), + onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["inventory"] }); await queryClient.invalidateQueries({ queryKey: ["day"] }); await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] }); + setCompletedSessionId(data.sessionId); + }, + onError: (err) => + Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")), + }); + + const undo = useMutation({ + mutationFn: () => api(`/v1/cooking-sessions/${completedSessionId}/undo`, { method: "POST" }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] }); + await queryClient.invalidateQueries({ queryKey: ["inventory"] }); + await queryClient.invalidateQueries({ queryKey: ["day"] }); + Alert.alert(t("common.done"), t("mealbox.undoSuccess")); router.dismissAll(); }, onError: (err) => @@ -101,6 +119,31 @@ export default function CookingScreen() { setFinishing(true); }; + if (completedSessionId) { + return ( + + + ✅ {t("cooked.title")} + {recipe.titleSv} + {t("cooked.portionsCooked")}: {portionsCooked} + + {t("mealbox.guidanceNote")} +