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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.js";
|
||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||
import { cancelTimedOutCookingSessions } from "@app/database";
|
||||
import { computeBalance } from "@app/inventory-engine";
|
||||
import type { Unit } from "@app/shared-types";
|
||||
|
||||
describe("cooking sessions", () => {
|
||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||
@@ -17,6 +18,36 @@ describe("cooking sessions", () => {
|
||||
let recipeId: string;
|
||||
const email = "cooking-session-test@example.invalid";
|
||||
|
||||
async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) {
|
||||
const [location] = await testDb.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(eq(schema.storageLocations.householdId, householdId))
|
||||
.limit(1);
|
||||
const [item] = await testDb.db
|
||||
.insert(schema.inventoryItems)
|
||||
.values({
|
||||
householdId,
|
||||
canonicalIngredientId,
|
||||
displayName,
|
||||
quantity,
|
||||
unit,
|
||||
storageLocationId: location!.id,
|
||||
source: "manual_search",
|
||||
})
|
||||
.returning();
|
||||
await testDb.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: item!.id,
|
||||
type: "purchase",
|
||||
quantityDelta: quantity,
|
||||
unit,
|
||||
refType: "test_setup",
|
||||
actorUserId: userId,
|
||||
});
|
||||
return item!.id;
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const existing = await testDb.db
|
||||
.select({ id: schema.users.id })
|
||||
@@ -582,4 +613,419 @@ describe("cooking sessions", () => {
|
||||
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
|
||||
expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
|
||||
});
|
||||
|
||||
it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => {
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
|
||||
// Sätt upp ett känt lager om receptet har en icke-valfri ingrediens.
|
||||
let itemId: string | undefined;
|
||||
if (firstNonOptional) {
|
||||
itemId = await createItemWithPurchase(firstNonOptional, "Testvara", 1000, "GRAM");
|
||||
}
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true, portions: 4 },
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
const complete = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mealBoxPortions: 1, actualPortionsEaten: 3, leftoverEstimatePortions: 1 },
|
||||
});
|
||||
expect(complete.statusCode).toBe(200);
|
||||
|
||||
const txsBefore = await testDb.db
|
||||
.select({ count: count(schema.inventoryTransactions.id) })
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
|
||||
|
||||
const undo = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(undo.statusCode).toBe(200);
|
||||
const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] };
|
||||
expect(undoBody.ok).toBe(true);
|
||||
|
||||
// Sessionstatus = undone.
|
||||
const session = await testDb.db
|
||||
.select()
|
||||
.from(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.id, sessionId))
|
||||
.limit(1);
|
||||
expect(session[0]!.status).toBe("undone");
|
||||
|
||||
// Append-only: fler transaktioner efter undo.
|
||||
const txsAfter = await testDb.db
|
||||
.select({ count: count(schema.inventoryTransactions.id) })
|
||||
.from(schema.inventoryTransactions)
|
||||
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
|
||||
expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count));
|
||||
|
||||
// Meals borttagna.
|
||||
const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId));
|
||||
expect(Number(meals[0]!.count)).toBe(0);
|
||||
|
||||
// Matlådor markerade discarded.
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length);
|
||||
for (const box of boxes) expect(box.status).toBe("discarded");
|
||||
|
||||
// recipe_cooks borttagen och cookCount backad.
|
||||
const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId));
|
||||
expect(cooks.length).toBe(0);
|
||||
|
||||
// Inventory-transaktionsinvariant.
|
||||
if (itemId) {
|
||||
const item = await testDb.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
const itemTxs = 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(itemTxs);
|
||||
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
|
||||
}
|
||||
|
||||
// Analytics.
|
||||
const analytics = await testDb.db
|
||||
.select()
|
||||
.from(schema.productAnalyticsEvents)
|
||||
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
||||
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
||||
const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
|
||||
expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true);
|
||||
|
||||
if (itemId) {
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId));
|
||||
}
|
||||
});
|
||||
|
||||
it("undo is rejected after 24h window", async () => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true },
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mealBoxPortions: 0 },
|
||||
});
|
||||
|
||||
// Simulera 25 h gammal completed session.
|
||||
await testDb.db
|
||||
.update(schema.cookingSessions)
|
||||
.set({ completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
|
||||
.where(eq(schema.cookingSessions.id, sessionId));
|
||||
|
||||
const undo = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(undo.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("partial consumption deducts only actual portions eaten from inventory", async () => {
|
||||
// Skapa ett enkelt recept med en enda icke-valfri ingrediens.
|
||||
const [recipe] = await testDb.db
|
||||
.insert(schema.recipes)
|
||||
.values({
|
||||
slug: `partial-${Date.now()}`,
|
||||
titleSv: "Partial test",
|
||||
descriptionSv: "",
|
||||
cuisine: "international",
|
||||
mealTypes: ["dinner"],
|
||||
tags: [],
|
||||
methods: [],
|
||||
equipment: [],
|
||||
difficulty: "easy",
|
||||
prepTimeMinutes: 5,
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
creatorDisplayName: "Test",
|
||||
})
|
||||
.returning();
|
||||
const testRecipeId = recipe!.id;
|
||||
await testDb.db.insert(schema.recipeIngredients).values({
|
||||
recipeId: testRecipeId,
|
||||
canonicalIngredientId: "pasta_dry",
|
||||
displayNameSv: "Pasta",
|
||||
quantity: 400,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
|
||||
const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM");
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true, portions: 4 },
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
|
||||
});
|
||||
|
||||
// 4 portioner → 400 g, 2 ätna → 200 g, alltså 200 g kvar.
|
||||
const afterComplete = await testDb.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
expect(afterComplete[0]!.quantity).toBeCloseTo(200, 1);
|
||||
|
||||
// Undo återställer.
|
||||
const undo = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(undo.statusCode).toBe(200);
|
||||
const afterUndo = await testDb.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
expect(afterUndo[0]!.quantity).toBeCloseTo(400, 1);
|
||||
|
||||
// Städa testreceptet och lagerposten.
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
it("undo + new complete maintains inventory invariant", async () => {
|
||||
const [recipe] = await testDb.db
|
||||
.insert(schema.recipes)
|
||||
.values({
|
||||
slug: `invariant-${Date.now()}`,
|
||||
titleSv: "Invariant test",
|
||||
descriptionSv: "",
|
||||
cuisine: "international",
|
||||
mealTypes: ["dinner"],
|
||||
tags: [],
|
||||
methods: [],
|
||||
equipment: [],
|
||||
difficulty: "easy",
|
||||
prepTimeMinutes: 5,
|
||||
cookTimeMinutes: 10,
|
||||
totalTimeMinutes: 15,
|
||||
portions: 4,
|
||||
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||
allergens: [],
|
||||
spiceLevel: 0,
|
||||
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
|
||||
status: "published",
|
||||
verificationStatus: "unverified",
|
||||
sourceType: "own_editorial",
|
||||
creatorDisplayName: "Test",
|
||||
})
|
||||
.returning();
|
||||
const testRecipeId = recipe!.id;
|
||||
await testDb.db.insert(schema.recipeIngredients).values({
|
||||
recipeId: testRecipeId,
|
||||
canonicalIngredientId: "rice_white",
|
||||
displayNameSv: "Ris",
|
||||
quantity: 400,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
|
||||
const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM");
|
||||
|
||||
async function assertInvariant() {
|
||||
const item = await testDb.db
|
||||
.select({ quantity: schema.inventoryItems.quantity })
|
||||
.from(schema.inventoryItems)
|
||||
.where(eq(schema.inventoryItems.id, itemId))
|
||||
.limit(1);
|
||||
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);
|
||||
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
|
||||
}
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true, portions: 4 },
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mealBoxPortions: 0, actualPortionsEaten: 4, leftoverEstimatePortions: 0 },
|
||||
});
|
||||
await assertInvariant();
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
await assertInvariant();
|
||||
|
||||
// Ny session + complete efter undo ska bibehålla invarianten.
|
||||
const start2 = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true, portions: 4 },
|
||||
});
|
||||
const sessionId2 = (JSON.parse(start2.body) as { session: { id: string } }).session.id;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId2}/complete`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
|
||||
});
|
||||
await assertInvariant();
|
||||
|
||||
await testDb.db
|
||||
.delete(schema.inventoryItems)
|
||||
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
|
||||
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||
});
|
||||
|
||||
it("legacy /cook followed by undo works", async () => {
|
||||
const cook = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
|
||||
});
|
||||
expect(cook.statusCode).toBe(200);
|
||||
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 session = await testDb.db
|
||||
.select()
|
||||
.from(schema.cookingSessions)
|
||||
.where(eq(schema.cookingSessions.id, sessionId))
|
||||
.limit(1);
|
||||
expect(session[0]!.status).toBe("undone");
|
||||
|
||||
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||
expect(boxes.every((b) => b.status === "discarded")).toBe(true);
|
||||
});
|
||||
|
||||
it("undo rolls back cooking assumption profiles", async () => {
|
||||
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||
|
||||
const recipe = (await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/recipes/${recipeId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||
|
||||
const cook = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true },
|
||||
});
|
||||
const { sessionId } = JSON.parse(cook.body) as { sessionId: string };
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const profile = await testDb.db
|
||||
.select()
|
||||
.from(schema.cookingAssumptionProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
||||
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
expect(profile.length).toBe(0);
|
||||
});
|
||||
|
||||
it("cannot undo a non-completed session", async () => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/recipes/${recipeId}/cook/start`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { startNow: true },
|
||||
});
|
||||
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||
|
||||
const undo = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/cooking-sessions/${sessionId}/undo`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(undo.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Ingen forskelle fundet.",
|
||||
"scan.diff.undo": "Fortryd",
|
||||
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
||||
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner."
|
||||
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
|
||||
"cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
|
||||
"scan.diff.undo": "Rückgängig",
|
||||
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
|
||||
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten."
|
||||
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
|
||||
"cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "No differences found.",
|
||||
"scan.diff.undo": "Undo",
|
||||
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
|
||||
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions."
|
||||
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions.",
|
||||
"cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "No se encontraron diferencias.",
|
||||
"scan.diff.undo": "Deshacer",
|
||||
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
|
||||
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones."
|
||||
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
|
||||
"cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Eroja ei löytynyt.",
|
||||
"scan.diff.undo": "Kumoa",
|
||||
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
|
||||
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää."
|
||||
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
|
||||
"cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Aucune différence trouvée.",
|
||||
"scan.diff.undo": "Annuler",
|
||||
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
|
||||
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions."
|
||||
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
|
||||
"cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Nessuna differenza trovata.",
|
||||
"scan.diff.undo": "Annulla",
|
||||
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
|
||||
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni."
|
||||
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
|
||||
"cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Ingen forskjeller funnet.",
|
||||
"scan.diff.undo": "Angre",
|
||||
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
|
||||
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner."
|
||||
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
|
||||
"cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Geen verschillen gevonden.",
|
||||
"scan.diff.undo": "Ongedaan maken",
|
||||
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
|
||||
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden."
|
||||
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
|
||||
"cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken."
|
||||
}
|
||||
|
||||
@@ -397,5 +397,6 @@
|
||||
"scan.diff.noChanges": "Nie znaleziono różnic.",
|
||||
"scan.diff.undo": "Cofnij",
|
||||
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
|
||||
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji."
|
||||
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
|
||||
"cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
|
||||
"scan.diff.undo": "Desfazer",
|
||||
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
|
||||
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções."
|
||||
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
|
||||
"cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou."
|
||||
}
|
||||
|
||||
@@ -383,5 +383,6 @@
|
||||
"scan.diff.noChanges": "Inga skillnader hittades.",
|
||||
"scan.diff.undo": "Ångra",
|
||||
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
|
||||
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner."
|
||||
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
|
||||
"cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut."
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ Att lägga till `prepared_food_batches` skulle skapa:
|
||||
---
|
||||
|
||||
### Steg 3c – Partiell förbrukning §6.3 + undo_until
|
||||
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår.
|
||||
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner.
|
||||
|
||||
**Ändringar:**
|
||||
1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror.
|
||||
@@ -149,24 +149,40 @@ Att lägga till `prepared_food_batches` skulle skapa:
|
||||
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
|
||||
3. `POST /v1/cooking-sessions/:id/undo`:
|
||||
- Status måste vara `completed`.
|
||||
- För varje `inventory_transactions` med `cookingSessionId`: skapa en `correction` med motsatt delta.
|
||||
- För varje `meal` med `cookingSessionId`: radera (eller markera som cancelled).
|
||||
- För varje `meal_box` med `cookingSessionId`: sätt status `discarded`.
|
||||
- Sätt `cooking_sessions.status = 'cancelled'`.
|
||||
4. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också.
|
||||
- `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.
|
||||
- För varje `inventory_transactions.type = 'cook_use'` med `cookingSessionId`: skapa en `correction` med motsatt delta (append-only; befintliga rader rörs inte).
|
||||
- Lagersaldo räknas om från transaktionshistoriken; `depletedAt` nollas där saldot blir > 0 igen.
|
||||
- För varje `meal` med `cookingSessionId`: radera och emitta `MEAL_REMOVED`.
|
||||
- För varje `meal_box` med `cookingSessionId`: sätt status `discarded`, `portionsRemaining = 0`, emitta `MEAL_BOX_DISCARDED`.
|
||||
- Ta bort `recipe_cooks`-raden och backa `recipes.cookCount` med `GREATEST(cookCount - 1, 0)`.
|
||||
- Sätt `cooking_sessions.status = 'undone'` (TEXT, valideras mot `COOKING_SESSION_STATUSES` i shared-types; ingen pgEnum, inga nya tabeller).
|
||||
- Emitta `COOKING_SESSION_UNDONE` och analytics-event `cooking_session_undone` (consent-gatat via `trackProductAnalytics`, ingen fritext).
|
||||
- Aktiveringsmilstolpar backas INTE (once-ever).
|
||||
4. Antagandeprofiler rullas tillbaka deterministiskt via `lastSessionAnswers.sessionId`:
|
||||
- `rollbackCookingAssumptionProfile(sessionId, existingProfile)` är en ren funktion i `packages/inventory-engine/src/cooking-profiles.ts`.
|
||||
- Den tar bort det aktuella sessions-id:t från `lastSessionAnswers`, räknar om EMA från scratch i kronologisk ordning och sätter `observationCount = history.length`.
|
||||
- Om historiken blir tom raderas profilen (inte nollas); det förhindrar dubbelräkning vid undo + ny complete.
|
||||
5. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också.
|
||||
|
||||
**Berörda filer:**
|
||||
- `apps/api/src/lib/cooking.ts` (`completeCookingSession`, `undoCookingSession`)
|
||||
- `apps/api/src/routes/cooking-sessions.ts`
|
||||
- `apps/api/src/routes/inventory.ts` (visa cookingSessionId i transaktionslistan)
|
||||
- `packages/inventory-engine/src/fefo.ts` (ev. helper för omräkning)
|
||||
- `apps/mobile/src/app/cooking/[id].tsx`
|
||||
- `apps/api/src/lib/i18n.ts`
|
||||
- `packages/inventory-engine/src/cooking-profiles.ts`
|
||||
- `packages/shared-types/src/enums.ts`, `packages/shared-types/src/analytics.ts`
|
||||
- `packages/events/src/index.ts`
|
||||
- `packages/analytics/src/builders.ts`
|
||||
- `apps/mobile/src/locales/*/common.json` (+12)
|
||||
|
||||
**Migration:** ingår i 0011 (kolumn cookingSessionId)
|
||||
**Migration:** 0014_expand_event_types.sql (nya domänhändelser i `event_type`-enum)
|
||||
|
||||
**Testplan:**
|
||||
- Laga 4 portioner, ät 3 → lager innehåller 25 % kvar av varje ingrediens
|
||||
- Undo → allt återställs
|
||||
- Invariant: `computeBalance(transaktioner) === item.quantity` efter complete och efter undo
|
||||
- Invariant: `computeBalance(transaktioner) === item.quantity` efter (a) complete, (b) undo, (c) undo + ny complete
|
||||
- Append-only-bevis: antalet transaktioner ökar vid undo
|
||||
- Undo efter 24 h → 409 med lokaliserat fel
|
||||
- Legacy `/cook` → undo fungerar via samma kodväg
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Expandera event_type-enum med nya domänhändelser för 3c (undo/partiell förbrukning).
|
||||
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_BOX_DISCARDED';
|
||||
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_REMOVED';
|
||||
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'COOKING_SESSION_UNDONE';
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1786049332540,
|
||||
"tag": "0013_dark_the_anarchist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1786055900000,
|
||||
"tag": "0014_expand_event_types",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export const recipeSaved = builder("recipe_saved");
|
||||
export const cookingSessionStarted = builder("cooking_session_started");
|
||||
export const cookingSessionCompleted = builder("cooking_session_completed");
|
||||
export const cookingSessionCancelled = builder("cooking_session_cancelled");
|
||||
export const cookingSessionUndone = builder("cooking_session_undone");
|
||||
export const leftoversCreated = builder("leftovers_created");
|
||||
export const substitutionConfirmed = builder("substitution_confirmed");
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface EventPayloadMap {
|
||||
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
|
||||
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
|
||||
MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number };
|
||||
MEAL_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string };
|
||||
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
|
||||
COOKING_SESSION_UNDONE: {
|
||||
cookingSessionId: string;
|
||||
recipeId: string;
|
||||
reversedTransactions: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
|
||||
|
||||
@@ -55,6 +55,51 @@ export function updateCookingAssumptionProfile(
|
||||
return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Återställ en antagandeprofil genom att ta bort ett specifikt sessions-id
|
||||
* från historiken och räkna om medelvärdena deterministiskt från resterande
|
||||
* observationer. Ren funktion – alla beroenden är explicita argument.
|
||||
*/
|
||||
export function rollbackCookingAssumptionProfile(
|
||||
sessionId: string,
|
||||
existing: ExistingProfile,
|
||||
): {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>;
|
||||
} {
|
||||
const history = (existing.lastSessionAnswers ?? []).filter((a) => a.sessionId !== sessionId);
|
||||
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
averageEatenPortions: null,
|
||||
averageLeftoverPortions: null,
|
||||
observationCount: 0,
|
||||
lastSessionAnswers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Räkna om EMA från scratch i kronologisk ordning (äldst först).
|
||||
const chronological = [...history].reverse();
|
||||
let avgEaten = chronological[0]!.eaten;
|
||||
let avgLeftovers = chronological[0]!.leftovers;
|
||||
|
||||
for (let i = 1; i < chronological.length; i++) {
|
||||
const alpha = 1 / (i + 1);
|
||||
const a = chronological[i]!;
|
||||
avgEaten = round2(avgEaten + alpha * (a.eaten - avgEaten));
|
||||
avgLeftovers = round2(avgLeftovers + alpha * (a.leftovers - avgLeftovers));
|
||||
}
|
||||
|
||||
return {
|
||||
averageEatenPortions: avgEaten,
|
||||
averageLeftoverPortions: avgLeftovers,
|
||||
observationCount: history.length,
|
||||
lastSessionAnswers: history,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js";
|
||||
import {
|
||||
updateCookingAssumptionProfile,
|
||||
rollbackCookingAssumptionProfile,
|
||||
} from "../src/cooking-profiles.js";
|
||||
|
||||
describe("updateCookingAssumptionProfile", () => {
|
||||
it("initializes profile from first observation", () => {
|
||||
@@ -61,3 +64,104 @@ describe("updateCookingAssumptionProfile", () => {
|
||||
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollbackCookingAssumptionProfile", () => {
|
||||
it("returns null averages and zero count when history becomes empty", () => {
|
||||
const existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||
);
|
||||
const rolled = rollbackCookingAssumptionProfile("s1", existing);
|
||||
expect(rolled.averageEatenPortions).toBeNull();
|
||||
expect(rolled.averageLeftoverPortions).toBeNull();
|
||||
expect(rolled.observationCount).toBe(0);
|
||||
expect(rolled.lastSessionAnswers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recalculates averages deterministically from remaining history", () => {
|
||||
let existing: {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
|
||||
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
|
||||
|
||||
// Three chronological observations.
|
||||
const observations = [
|
||||
{ sessionId: "s1", eaten: 4, leftovers: 0 },
|
||||
{ sessionId: "s2", eaten: 2, leftovers: 1 },
|
||||
{ sessionId: "s3", eaten: 3, leftovers: 1 },
|
||||
];
|
||||
for (const obs of observations) {
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: obs.eaten,
|
||||
leftoverEstimatePortions: obs.leftovers,
|
||||
sessionId: obs.sessionId,
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
||||
// Roll back the middle observation and recompute from scratch.
|
||||
const rolled = rollbackCookingAssumptionProfile("s2", existing);
|
||||
expect(rolled.lastSessionAnswers).toHaveLength(2);
|
||||
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s1");
|
||||
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s3");
|
||||
|
||||
// Recompute EMA manually from s1 then s3.
|
||||
// s1: avgEaten=4, avgLeftovers=0
|
||||
// s3: alpha=1/2 => eaten=4+0.5*(3-4)=3.5, leftovers=0+0.5*(1-0)=0.5
|
||||
expect(rolled.averageEatenPortions).toBe(3.5);
|
||||
expect(rolled.averageLeftoverPortions).toBe(0.5);
|
||||
expect(rolled.observationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("is idempotent: rolling back the same session twice leaves history unchanged", () => {
|
||||
let existing: {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
|
||||
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 2,
|
||||
leftoverEstimatePortions: 0,
|
||||
sessionId: "s2",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
const first = rollbackCookingAssumptionProfile("s1", existing);
|
||||
const second = rollbackCookingAssumptionProfile("s1", first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ANALYTICS_COOKING_EVENT_NAMES = [
|
||||
"cooking_session_started",
|
||||
"cooking_session_completed",
|
||||
"cooking_session_cancelled",
|
||||
"cooking_session_undone",
|
||||
"leftovers_created",
|
||||
"substitution_confirmed",
|
||||
] as const;
|
||||
|
||||
@@ -413,6 +413,9 @@ export const EVENT_TYPES = [
|
||||
"WEEK_PLAN_UPDATED",
|
||||
"MEAL_BOX_CREATED",
|
||||
"MEAL_BOX_CONSUMED",
|
||||
"MEAL_BOX_DISCARDED",
|
||||
"MEAL_REMOVED",
|
||||
"COOKING_SESSION_UNDONE",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -433,6 +436,20 @@ export const MEAL_LOG_SOURCES = [
|
||||
] as const;
|
||||
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
|
||||
|
||||
/** Cooking session lifecycle (Fas 3 §6). TEXT i databasen, valideras mot denna const. */
|
||||
export const COOKING_SESSION_STATUSES = [
|
||||
"planned",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"undone",
|
||||
] as const;
|
||||
export type CookingSessionStatus = (typeof COOKING_SESSION_STATUSES)[number];
|
||||
|
||||
/** Matlådestatuser (spec §24). TEXT i databasen, valideras mot denna const. */
|
||||
export const MEAL_BOX_STATUSES = ["available", "consumed", "discarded"] as const;
|
||||
export type MealBoxStatus = (typeof MEAL_BOX_STATUSES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smakprofil & feedback (spec §30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user