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:
+146
-7
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user