feat(3d): meal-box merge, stored mutations, undo, mobile undo UI + i18n ×12

- Merge leftovers into existing meal_box when same recipeId + cookedAt date + frozen + available.
- Store mealBoxMutations JSONB on cooking_sessions for deterministic undo.
- Undo decrements portions/remaining, discards box at zero, appends correction ledger rows.
- Mobile: undo button in cooking/[id].tsx after-flow and meal-boxes.tsx within 24h window.
- i18n undo strings across all 12 locales; parity test green.
- 6 new integration tests: merge, no cross-date merge, frozen split, undo restore, undo discard, ledger invariant.
- Update FAS3 audit doc with 3d semantics.

Closes Fas 3d
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 17:04:42 +07:00
parent c1ab2c8f37
commit 74f95daab2
26 changed files with 610 additions and 305 deletions
+162 -46
View File
@@ -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,
};
}
+221
View File
@@ -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();
});
});