fix(3b): delad kompletteringsväg för legacy /cook + deterministiska cooking-assumptions

- cookRecipeInputSchema utökas med actualPortionsEaten, leftoverEstimatePortions, leftoverNote
- completeCookingSession-wrapper i apps/api/src/lib/cooking.ts hanterar summavalidering,
  persistens av svar, profiluppdateringar och analytics (started+completed) för båda vägarna
- Legacy POST /v1/recipes/:id/cook använder wrappern med emitStartedEvent
- GET /v1/recipes/:id/cooking-assumptions väljer deterministiskt bland icke-valfria ingredienser
- i18n: nyckel cooked.portionsSumExceedsPlanned i samtliga 12 lokaler + server-i18n
- Nya tester för legacy /cook och cooking-assumptions med valfri första ingrediens

Refs: steg 3b-fix, granskningsrunda 2026-08-07
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 05:13:35 +07:00
parent e46281b9b5
commit bbe7526ef3
18 changed files with 457 additions and 162 deletions
+146 -7
View File
@@ -1,12 +1,15 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull, sql } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import { allocateFefo } from "@app/inventory-engine";
import { allocateFefo, updateCookingAssumptionProfile } from "@app/inventory-engine";
import { scaleNutrition } from "@app/nutrition-engine";
import type { Unit } from "@app/shared-types";
import { todayIso, emitEvent } from "./helpers.js";
import { cookingSessionStarted, cookingSessionCompleted } 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 {
@@ -19,6 +22,14 @@ export interface CompleteCookingInput {
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 {
@@ -30,8 +41,138 @@ export interface CompleteCookingResult {
}
/**
* Gemensam kärna för att "jag har lagat". Används av både
* POST /v1/recipes/:id/cook (legacy shortcut) och
* 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 (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(
@@ -234,14 +375,12 @@ export async function completeCookingSessionCore(
correlationId,
});
// 5. Uppdatera session
// 5. Uppdatera session (svaren skrivs över av completeCookingSession).
await app.db
.update(schema.cookingSessions)
.set({
status: "completed",
completedAt: new Date(),
actualPortionsEaten: portionsCooked - mealBoxPortions,
leftoverEstimatePortions: mealBoxPortions,
plannedDeductions: deductions,
updatedAt: new Date(),
})