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),
};
}
+19
View File
@@ -34,6 +34,21 @@ const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record<string, string> = {
sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
};
const COOKED_UNDO_WINDOW_EXPIRED: Record<string, string> = {
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<string, string>, 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"]!;
+14 -2
View File
@@ -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);