3c: partial consumption + undo for cooking sessions

- Append-only undo via correction transactions tied to cookingSessionId.
- POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry).
- Complete undo scope: meals removed, meal boxes discarded, recipe_cooks
deleted, cookCount decremented; activation milestones left intact.
- Deterministic assumption-profile rollback via lastSessionAnswers.sessionId;
profile deleted when observationCount reaches 0.
- New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES.
- Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED,
MEAL_REMOVED, COOKING_SESSION_UNDONE.
- Partial consumption uses actualPortionsEaten/plannedPortions factor.
- i18n coverage for cooked.undoWindowExpired across all 12 locales + server.
- Invariant tests: computeBalance(transactions) === item.quantity after
complete, undo, and undo + new complete; transaction count increases on undo.
- Migration 0014_expand_event_types.sql adds new enum values.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 05:47:54 +07:00
parent bbe7526ef3
commit cb493010d0
25 changed files with 969 additions and 30 deletions
+252 -4
View File
@@ -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<UndoCookingSessionResult> {
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<string>();
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),
};
}