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:
+252
-4
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user