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:
+162
-46
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user