8637eaa6c3
- consumptionPortions = min(planned, actualPortionsEaten + leftoverEstimatePortions). Meal boxes are a subset of leftovers; raw inventory is only retained for portions that were never cooked. - Added validation leftoverEstimatePortions >= mealBoxPortions with localized 400 error (cooked.leftoverLessThanBox) in server i18n + all 12 locales. - Removed unused COOKING_SESSION_STATUSES / MEAL_BOX_STATUSES imports from apps/api/src/lib/cooking.ts. - Hardened test cleanup to delete mealBoxes/meals/recipeCooks/cookingSessions by household before dropping storageLocations. - New regression tests: meal-box double-counting, eaten+leftover split, and leftover < box rejection. - Updated FAS3-COOKING-SESSIONS-AUDIT.md with corrected physics semantics.
662 lines
22 KiB
TypeScript
662 lines
22 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
|
import { schema, markMilestone } from "@app/database";
|
|
import {
|
|
allocateFefo,
|
|
computeBalance,
|
|
updateCookingAssumptionProfile,
|
|
rollbackCookingAssumptionProfile,
|
|
} from "@app/inventory-engine";
|
|
import { scaleNutrition } from "@app/nutrition-engine";
|
|
import type { Unit } from "@app/shared-types";
|
|
import { 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";
|
|
|
|
export interface CompleteCookingInput {
|
|
portionsCooked?: number;
|
|
mealBoxPortions?: number;
|
|
mealBoxFrozen?: boolean;
|
|
mealBoxStorageLocationId?: string;
|
|
deductInventory?: boolean;
|
|
eaters?: Array<{ userId: string; portionFraction: number }>;
|
|
date?: string;
|
|
mealType?: string;
|
|
inventoryOverrides?: Array<{ canonicalIngredientId: string; quantityUsed: number; unit: string }>;
|
|
actualPortionsEaten?: number;
|
|
leftoverEstimatePortions?: number;
|
|
leftoverNote?: string;
|
|
}
|
|
|
|
export interface CompleteCookingSessionOptions {
|
|
/** Sant för legacy POST /v1/recipes/:id/cook där session skapas och startas i ett steg. */
|
|
emitStartedEvent?: boolean;
|
|
}
|
|
|
|
export interface CompleteCookingResult {
|
|
ok: boolean;
|
|
mealIds: string[];
|
|
mealBoxId: string | null;
|
|
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
|
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
|
|
}
|
|
|
|
/**
|
|
* Delad kompletteringsväg för både POST /v1/recipes/:id/cook (legacy)
|
|
* och POST /v1/cooking-sessions/:id/complete. Hanterar summavalidering,
|
|
* persistens av svar, profiluppdateringar och analytics.
|
|
*/
|
|
export async function completeCookingSession(
|
|
app: FastifyInstance,
|
|
session: typeof schema.cookingSessions.$inferSelect,
|
|
userId: string,
|
|
input: CompleteCookingInput,
|
|
correlationId: string,
|
|
options: CompleteCookingSessionOptions = {},
|
|
): Promise<CompleteCookingResult & { session: typeof schema.cookingSessions.$inferSelect }> {
|
|
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
|
|
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
|
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
|
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
|
|
|
|
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
|
|
const languageTag = await userLanguageTag(app.db, userId);
|
|
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
|
|
}
|
|
|
|
if (leftoverEstimatePortions < mealBoxPortions) {
|
|
const languageTag = await userLanguageTag(app.db, userId);
|
|
throw errors.badRequest(t("cooked.leftoverLessThanBox", languageTag));
|
|
}
|
|
|
|
if (options.emitStartedEvent && session.status === "started") {
|
|
await trackProductAnalytics(
|
|
app.db,
|
|
userId,
|
|
cookingSessionStarted({
|
|
householdId: session.householdId,
|
|
properties: {
|
|
cookingSessionId: session.id,
|
|
recipeId: session.recipeId,
|
|
status: session.status,
|
|
plannedPortions,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const result = await completeCookingSessionCore(
|
|
app,
|
|
session,
|
|
userId,
|
|
{ ...input, portionsCooked: plannedPortions, mealBoxPortions },
|
|
correlationId,
|
|
);
|
|
|
|
// Persistera användarens svar (ersätter kärnans default-värden).
|
|
const [updated] = await app.db
|
|
.update(schema.cookingSessions)
|
|
.set({
|
|
actualPortionsEaten,
|
|
leftoverEstimatePortions,
|
|
leftoverNote: input.leftoverNote ?? null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.cookingSessions.id, session.id))
|
|
.returning();
|
|
if (!updated) throw errors.internal("Kunde inte uppdatera cooking session.");
|
|
|
|
// Uppdatera antagandeprofiler per icke-valfri ingrediens (hushållsnivå).
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
for (const ing of result.recipeIngredients) {
|
|
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);
|
|
|
|
const updatedProfile = updateCookingAssumptionProfile(
|
|
{
|
|
householdId: session.householdId,
|
|
canonicalIngredientId: ing.canonicalIngredientId,
|
|
plannedPortions,
|
|
actualPortionsEaten,
|
|
leftoverEstimatePortions,
|
|
sessionId: session.id,
|
|
date,
|
|
},
|
|
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
|
);
|
|
|
|
await app.db
|
|
.insert(schema.cookingAssumptionProfiles)
|
|
.values({
|
|
householdId: session.householdId,
|
|
canonicalIngredientId: ing.canonicalIngredientId,
|
|
...updatedProfile,
|
|
updatedAt: new Date(),
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
schema.cookingAssumptionProfiles.householdId,
|
|
schema.cookingAssumptionProfiles.canonicalIngredientId,
|
|
],
|
|
set: {
|
|
averageEatenPortions: updatedProfile.averageEatenPortions,
|
|
averageLeftoverPortions: updatedProfile.averageLeftoverPortions,
|
|
observationCount: updatedProfile.observationCount,
|
|
lastSessionAnswers: updatedProfile.lastSessionAnswers,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
await trackProductAnalytics(
|
|
app.db,
|
|
userId,
|
|
cookingSessionCompleted({
|
|
householdId: session.householdId,
|
|
properties: {
|
|
cookingSessionId: session.id,
|
|
recipeId: session.recipeId,
|
|
portionsCooked: plannedPortions,
|
|
actualPortionsEaten,
|
|
leftoverEstimatePortions,
|
|
mealBoxPortions,
|
|
},
|
|
}),
|
|
);
|
|
|
|
return { ...result, session: updated };
|
|
}
|
|
|
|
/**
|
|
* Lågnivå-kärna för att "jag har lagat". Används via completeCookingSession
|
|
* av både POST /v1/recipes/:id/cook (legacy shortcut) och
|
|
* POST /v1/cooking-sessions/:id/complete.
|
|
*/
|
|
export async function completeCookingSessionCore(
|
|
app: FastifyInstance,
|
|
session: typeof schema.cookingSessions.$inferSelect,
|
|
userId: string,
|
|
input: CompleteCookingInput,
|
|
correlationId: string,
|
|
): Promise<CompleteCookingResult> {
|
|
const recipe = await loadFullRecipe(app, session.recipeId);
|
|
const householdId = session.householdId;
|
|
const portionsCooked = input.portionsCooked ?? session.plannedPortions;
|
|
const date = input.date ?? todayIso();
|
|
const mealType = (input.mealType ?? session.plannedMealType) as (typeof MEAL_TYPES)[number];
|
|
|
|
// 1. FEFO-avdrag
|
|
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
|
|
if (input.deductInventory !== false) {
|
|
// Partiell förbrukning (Fas 3 §6.3): råvaror dras för de tillagade
|
|
// portionerna = ätna + rester (inklusive matlådor). Endast portioner som
|
|
// aldrig tillagats stannar kvar som råvara i lagret.
|
|
const actualPortionsEaten = input.actualPortionsEaten ?? portionsCooked;
|
|
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? 0;
|
|
const consumptionPortions = Math.min(
|
|
portionsCooked,
|
|
actualPortionsEaten + leftoverEstimatePortions,
|
|
);
|
|
const factor = consumptionPortions / recipe.portions;
|
|
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
|
|
|
for (const ing of recipe.ingredients) {
|
|
if (ing.optional) continue;
|
|
const override = overrides.get(ing.canonicalIngredientId);
|
|
const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
|
|
const requiredUnit = override ? override.unit : ing.unit;
|
|
if (requiredQty <= 0) continue;
|
|
|
|
const stock = await app.db
|
|
.select()
|
|
.from(schema.inventoryItems)
|
|
.where(
|
|
and(
|
|
eq(schema.inventoryItems.householdId, householdId),
|
|
eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
|
|
isNull(schema.inventoryItems.depletedAt),
|
|
gt(schema.inventoryItems.quantity, 0),
|
|
),
|
|
);
|
|
if (stock.length === 0) continue;
|
|
|
|
const [info] = await app.db
|
|
.select()
|
|
.from(schema.canonicalIngredients)
|
|
.where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
|
|
.limit(1);
|
|
|
|
const allocation = allocateFefo(
|
|
requiredQty,
|
|
requiredUnit as Unit,
|
|
stock.map((s) => ({
|
|
id: s.id,
|
|
canonicalIngredientId: s.canonicalIngredientId,
|
|
quantity: s.quantity,
|
|
unit: s.unit,
|
|
bestBeforeDate: s.bestBeforeDate,
|
|
useByDate: s.useByDate,
|
|
openedAt: s.openedAt,
|
|
frozenAt: s.frozenAt,
|
|
thawedAt: s.thawedAt,
|
|
purchasedAt: s.purchasedAt,
|
|
})),
|
|
{ densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
|
|
);
|
|
|
|
for (const alloc of allocation.allocations) {
|
|
const item = stock.find((s) => s.id === alloc.itemId)!;
|
|
const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
|
|
await app.db.insert(schema.inventoryTransactions).values({
|
|
householdId,
|
|
inventoryItemId: alloc.itemId,
|
|
cookingSessionId: session.id,
|
|
type: "cook_use",
|
|
quantityDelta: -(item.quantity - newQty),
|
|
unit: item.unit,
|
|
refType: "recipe_cook",
|
|
refId: session.recipeId,
|
|
actorUserId: userId,
|
|
});
|
|
await app.db
|
|
.update(schema.inventoryItems)
|
|
.set({
|
|
quantity: newQty,
|
|
depletedAt: newQty <= 0 ? new Date() : null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.inventoryItems.id, alloc.itemId));
|
|
deductions.push({
|
|
itemId: alloc.itemId,
|
|
quantity: alloc.quantity,
|
|
unit: alloc.unit,
|
|
name: item.displayName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Måltider
|
|
const eaters =
|
|
input.eaters && input.eaters.length > 0
|
|
? input.eaters
|
|
: [{ userId, portionFraction: 1 }];
|
|
const mealIds: string[] = [];
|
|
for (const eater of eaters) {
|
|
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
|
|
const [meal] = await app.db
|
|
.insert(schema.meals)
|
|
.values({
|
|
userId: eater.userId,
|
|
householdId,
|
|
cookingSessionId: session.id,
|
|
date,
|
|
mealType,
|
|
source: "cooked_recipe",
|
|
recipeId: session.recipeId,
|
|
titleSv: recipe.titleSv,
|
|
portionFraction: eater.portionFraction,
|
|
nutrition,
|
|
nutritionIsEstimate: false,
|
|
})
|
|
.returning();
|
|
mealIds.push(meal!.id);
|
|
await emitEvent(app.db, {
|
|
type: "MEAL_LOGGED",
|
|
payload: { mealId: meal!.id, mealType, kcal: nutrition.kcal, source: "cooked_recipe" },
|
|
userId: eater.userId,
|
|
householdId,
|
|
correlationId,
|
|
});
|
|
}
|
|
|
|
// 3. Matlådor
|
|
let mealBoxId: string | null = null;
|
|
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
|
if (mealBoxPortions > 0) {
|
|
const locationId =
|
|
input.mealBoxStorageLocationId ??
|
|
(
|
|
await app.db
|
|
.select({ id: schema.storageLocations.id })
|
|
.from(schema.storageLocations)
|
|
.where(
|
|
and(
|
|
eq(schema.storageLocations.householdId, householdId),
|
|
eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
)[0]?.id;
|
|
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
|
|
|
|
const useByDays = input.mealBoxFrozen ? 90 : 3;
|
|
const recommendedUseBy = new Date(Date.parse(date) + useByDays * 86_400_000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const [box] = await app.db
|
|
.insert(schema.mealBoxes)
|
|
.values({
|
|
householdId,
|
|
recipeId: session.recipeId,
|
|
cookingSessionId: session.id,
|
|
titleSv: recipe.titleSv,
|
|
portions: mealBoxPortions,
|
|
portionsRemaining: mealBoxPortions,
|
|
nutritionPerPortion: recipe.nutritionPerPortion,
|
|
cookedAt: date,
|
|
storageLocationId: locationId,
|
|
frozen: input.mealBoxFrozen ?? false,
|
|
recommendedUseBy,
|
|
})
|
|
.returning();
|
|
mealBoxId = box!.id;
|
|
await emitEvent(app.db, {
|
|
type: "MEAL_BOX_CREATED",
|
|
payload: { mealBoxId: box!.id, portions: mealBoxPortions },
|
|
userId,
|
|
householdId,
|
|
correlationId,
|
|
});
|
|
}
|
|
|
|
// 4. recipe_cooks + statistik
|
|
await app.db.insert(schema.recipeCooks).values({
|
|
recipeId: session.recipeId,
|
|
userId,
|
|
householdId,
|
|
cookingSessionId: session.id,
|
|
portionsCooked,
|
|
cookedAt: new Date(),
|
|
});
|
|
await app.db
|
|
.update(schema.recipes)
|
|
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
|
|
.where(eq(schema.recipes.id, session.recipeId));
|
|
await emitEvent(app.db, {
|
|
type: "RECIPE_COOKED",
|
|
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions },
|
|
userId,
|
|
householdId,
|
|
correlationId,
|
|
});
|
|
|
|
// 5. Uppdatera session (svaren skrivs över av completeCookingSession).
|
|
await app.db
|
|
.update(schema.cookingSessions)
|
|
.set({
|
|
status: "completed",
|
|
completedAt: new Date(),
|
|
plannedDeductions: deductions,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.cookingSessions.id, session.id));
|
|
|
|
await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt");
|
|
if (deductions.length > 0) {
|
|
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
mealIds,
|
|
mealBoxId,
|
|
inventoryDeductions: deductions,
|
|
recipeIngredients: recipe.ingredients.map((ing) => ({
|
|
canonicalIngredientId: ing.canonicalIngredientId,
|
|
displayName: ing.displayNameSv,
|
|
quantity: ing.quantity,
|
|
unit: ing.unit,
|
|
optional: ing.optional,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|