Fas 3 steg 3b: minimala efterfrågor + hushållsantagandeprofiler per ingrediens

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 03:56:41 +07:00
parent 44f6385dce
commit e46281b9b5
24 changed files with 10130 additions and 5 deletions
+14 -1
View File
@@ -26,6 +26,7 @@ export interface CompleteCookingResult {
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 }>;
}
/**
@@ -251,5 +252,17 @@ export async function completeCookingSessionCore(
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
}
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
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,
})),
};
}
+116 -3
View File
@@ -15,6 +15,7 @@ import {
} from "@app/analytics";
import { trackProductAnalytics } from "../lib/helpers.js";
import { completeCookingSessionCore } from "../lib/cooking.js";
import { updateCookingAssumptionProfile } from "@app/inventory-engine";
import { z } from "zod";
/**
@@ -137,6 +138,7 @@ 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.
*/
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
@@ -146,7 +148,83 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
}
const result = await completeCookingSessionCore(app, session, req.userId, input, req.correlationId);
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;
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()
@@ -162,8 +240,10 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
properties: {
cookingSessionId: id,
recipeId: session.recipeId,
portionsCooked: session.plannedPortions,
mealBoxPortions: input.mealBoxPortions ?? 0,
portionsCooked: plannedPortions,
actualPortionsEaten,
leftoverEstimatePortions,
mealBoxPortions,
},
}),
);
@@ -171,6 +251,39 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
return { ...result, session: updated };
});
/** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */
app.get("/v1/recipes/:id/cooking-assumptions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const ings = await app.db
.select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId })
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id));
if (ings.length === 0) throw errors.notFound("Receptet finns inte.");
const profiles = await app.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ings[0]!.canonicalIngredientId),
),
)
.limit(1);
const p = profiles[0];
return {
householdId,
recipeId: id,
defaultActualPortionsEaten: p?.averageEatenPortions ?? null,
defaultLeftoverEstimatePortions: p?.averageLeftoverPortions ?? null,
observationCount: p?.observationCount ?? 0,
};
});
/** Lista hushållets aktiva sessioner. */
app.get("/v1/cooking-sessions", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
+84 -1
View File
@@ -1,6 +1,6 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray, count } from "drizzle-orm";
import { and, eq, inArray, count } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
@@ -43,6 +43,7 @@ describe("cooking sessions", () => {
for (const m of memberships) {
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId));
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId));
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId));
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
}
@@ -234,6 +235,88 @@ describe("cooking sessions", () => {
expect(body.mealBoxId).toBeDefined();
});
it("stores actual portions and leftover estimate on complete", async () => {
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 res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } };
expect(body.session.actualPortionsEaten).toBe(2);
expect(body.session.leftoverEstimatePortions).toBe(1);
});
it("rejects complete when eaten + leftovers exceed planned portions", async () => {
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 res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2 },
});
expect(res.statusCode).toBe(400);
});
it("updates cooking assumption profiles per household and ingredient", async () => {
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
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 recipe = (await app.inject({
method: "GET",
url: `/v1/recipes/${recipeId}`,
headers: { authorization: `Bearer ${token}` },
})).json() as { ingredients: Array<{ canonicalIngredientId: string }> };
const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
});
if (firstIngredientId) {
const profile = await testDb.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId),
),
)
.limit(1);
expect(profile.length).toBe(1);
expect(profile[0]!.observationCount).toBe(1);
expect(profile[0]!.averageEatenPortions).toBe(2);
expect(profile[0]!.averageLeftoverPortions).toBe(1);
}
});
it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
// complete
const startComplete = await app.inject({