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(),
})
+25 -4
View File
@@ -19,14 +19,35 @@ const HOUSEHOLD_DEFAULT_NAMES: Record<string, string> = {
sv: "Hemma",
};
const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record<string, string> = {
da: "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
de: "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
en: "Eaten portions and leftovers cannot exceed the total number of portions.",
es: "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
fi: "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
fr: "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
it: "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
nb: "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
nl: "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
pl: "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
pt: "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
};
function resolve(catalog: Record<string, string>, languageTag: string): string {
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
}
export function t(key: string, languageTag: string): string {
if (key === "cooked.portionsSumExceedsPlanned") {
return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag);
}
if (key !== "onboarding.householdDefaultName") {
// No other server-side keys are supported yet; fall back to a safe default.
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
}
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
return (
HOUSEHOLD_DEFAULT_NAMES[lang] ?? HOUSEHOLD_DEFAULT_NAMES["en"] ?? HOUSEHOLD_DEFAULT_NAMES["sv"]!
);
return resolve(HOUSEHOLD_DEFAULT_NAMES, languageTag);
}
+27 -115
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { and, eq, lt } from "drizzle-orm";
import { and, eq, inArray, lt } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import {
cookingSessionStartInputSchema,
@@ -8,14 +8,9 @@ import {
} from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
import {
cookingSessionStarted,
cookingSessionCompleted,
cookingSessionCancelled,
} from "@app/analytics";
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
import { trackProductAnalytics } from "../lib/helpers.js";
import { completeCookingSessionCore } from "../lib/cooking.js";
import { updateCookingAssumptionProfile } from "@app/inventory-engine";
import { completeCookingSession } from "../lib/cooking.js";
import { z } from "zod";
/**
@@ -137,8 +132,8 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
/**
* Complete en session.
* I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId.
* I steg 3b: sparar svar på max 23 frågor och uppdaterar hushållsantagandeprofiler.
* Delad kompletteringsväg via completeCookingSession hanterar summavalidering,
* persistens, profiluppdateringar och analytics.
*/
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
@@ -148,107 +143,9 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
}
const mealBoxPortions = input.mealBoxPortions ?? 0;
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
const result = await completeCookingSession(app, session, req.userId, input, req.correlationId);
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
throw errors.badRequest("Åtna portioner + rester får inte överstiga totalt antal portioner.");
}
const result = await completeCookingSessionCore(
app,
session,
req.userId,
{ ...input, portionsCooked: plannedPortions, mealBoxPortions },
req.correlationId,
);
await app.db
.update(schema.cookingSessions)
.set({
actualPortionsEaten,
leftoverEstimatePortions,
leftoverNote: input.leftoverNote ?? null,
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, id));
// Uppdatera antagandeprofiler per 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: 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(),
},
});
}
const [updated] = await app.db
.select()
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, id))
.limit(1);
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionCompleted({
householdId: session.householdId,
properties: {
cookingSessionId: id,
recipeId: session.recipeId,
portionsCooked: plannedPortions,
actualPortionsEaten,
leftoverEstimatePortions,
mealBoxPortions,
},
}),
);
return { ...result, session: updated };
return result;
});
/** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */
@@ -258,23 +155,38 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
await requireMembership(app.db, householdId, req.userId);
const ings = await app.db
.select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId })
.select({
canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId,
optional: schema.recipeIngredients.optional,
})
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id));
if (ings.length === 0) throw errors.notFound("Receptet finns inte.");
const nonOptionalIds = ings.filter((i) => !i.optional).map((i) => i.canonicalIngredientId);
if (nonOptionalIds.length === 0) {
return {
householdId,
recipeId: id,
defaultActualPortionsEaten: null,
defaultLeftoverEstimatePortions: null,
observationCount: 0,
};
}
const profiles = await app.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ings[0]!.canonicalIngredientId),
inArray(schema.cookingAssumptionProfiles.canonicalIngredientId, nonOptionalIds),
),
)
.limit(1);
);
// Deterministiskt val: profilen med flest observationer (stabil över tid).
const p = profiles.sort((a, b) => b.observationCount - a.observationCount)[0];
const p = profiles[0];
return {
householdId,
recipeId: id,
+4 -2
View File
@@ -32,7 +32,7 @@ import {
todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
import { completeCookingSessionCore } from "../lib/cooking.js";
import { completeCookingSession } from "../lib/cooking.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
@@ -433,7 +433,9 @@ export async function recipeRoutes(app: FastifyInstance) {
})
.returning();
const result = await completeCookingSessionCore(app, session!, req.userId, input, req.correlationId);
const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, {
emitStartedEvent: true,
});
return { sessionId: session!.id, ...result };
});