Fas 3 steg 3a: cooking_sessions-tabell + lifecycle-endpoints + timeout-jobb, gamla /cook kvar som kortkommando

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 03:14:13 +07:00
parent 4a4f448e1c
commit e9fb4a17d4
17 changed files with 10619 additions and 196 deletions
+255
View File
@@ -0,0 +1,255 @@
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 { scaleNutrition } from "@app/nutrition-engine";
import type { Unit } from "@app/shared-types";
import { todayIso, emitEvent } from "./helpers.js";
import { errors } from "./errors.js";
import { loadFullRecipe } from "../routes/recipes.js";
import { MEAL_TYPES } from "@app/shared-types";
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 }>;
}
export interface CompleteCookingResult {
ok: boolean;
mealIds: string[];
mealBoxId: string | null;
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
}
/**
* Gemensam kärna för att "jag har lagat". Används 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) {
const factor = portionsCooked / 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
await app.db
.update(schema.cookingSessions)
.set({
status: "completed",
completedAt: new Date(),
actualPortionsEaten: portionsCooked - mealBoxPortions,
leftoverEstimatePortions: mealBoxPortions,
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 };
}
+198
View File
@@ -0,0 +1,198 @@
import type { FastifyInstance } from "fastify";
import { and, eq, lt } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import {
cookingSessionStartInputSchema,
cookingSessionCompleteInputSchema,
idParamSchema,
} 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 { trackProductAnalytics } from "../lib/helpers.js";
import { completeCookingSessionCore } from "../lib/cooking.js";
import { z } from "zod";
/**
* Cooking Sessions (Fas 3 §6).
*
* - PLANNED reserverar aldrig lager.
* - STARTED har 24 h på sig att complete/cancel (§20 timeout).
* - COMPLETED/CANCELLED är terminala.
* - Gamla POST /v1/recipes/:id/cook finns kvar som kortkommando.
*/
export async function cookingSessionRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
/** Skapa ny session. Status planned om startNow saknas, annars started. */
app.post("/v1/recipes/:id/cook/start", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookingSessionStartInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const [recipe] = await app.db
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
.from(schema.recipes)
.where(eq(schema.recipes.id, id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
const now = new Date();
const status = input.startNow ? "started" : "planned";
const [session] = await app.db
.insert(schema.cookingSessions)
.values({
recipeId: id,
householdId,
startedByUserId: req.userId,
status,
plannedPortions: input.portions ?? recipe.portions,
plannedMealType: input.mealType ?? "dinner",
startedAt: input.startNow ? now : null,
})
.returning();
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionStarted({
householdId,
properties: {
cookingSessionId: session!.id,
recipeId: id,
status,
plannedPortions: input.portions ?? recipe.portions,
},
}),
);
return reply.status(201).send({ session: session });
});
/** Starta en planned session. */
app.post("/v1/cooking-sessions/:id/start", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const session = await getOwnedSession(app, id, req.userId);
if (session.status !== "planned") throw errors.conflict("Sessionen är inte planerad.");
const [updated] = await app.db
.update(schema.cookingSessions)
.set({ status: "started", startedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.cookingSessions.id, id))
.returning();
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionStarted({
householdId: session.householdId,
properties: { cookingSessionId: id, recipeId: session.recipeId, status: "started" },
}),
);
return { session: updated };
});
/** Avbryt session utan att röra lagret. */
app.post("/v1/cooking-sessions/:id/cancel", auth, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
const session = await getOwnedSession(app, params.id, req.userId);
if (session.status === "completed" || session.status === "cancelled") {
throw errors.conflict("Sessionen är redan avslutad.");
}
const [updated] = await app.db
.update(schema.cookingSessions)
.set({
status: "cancelled",
cancelledAt: new Date(),
cancelReason: body.reason ?? null,
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, params.id))
.returning();
await trackProductAnalytics(
app.db,
req.userId,
cookingSessionCancelled({
householdId: session.householdId,
properties: { cookingSessionId: params.id, recipeId: session.recipeId },
}),
);
return { session: updated };
});
/**
* Complete en session.
* I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId.
*/
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookingSessionCompleteInputSchema, req.body);
const session = await getOwnedSession(app, id, req.userId);
if (session.status !== "started") {
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
}
const result = await completeCookingSessionCore(app, session, req.userId, input, req.correlationId);
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: session.plannedPortions,
mealBoxPortions: input.mealBoxPortions ?? 0,
},
}),
);
return { ...result, session: updated };
});
/** Lista hushållets aktiva sessioner. */
app.get("/v1/cooking-sessions", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
await requireMembership(app.db, householdId, req.userId);
const sessions = await app.db
.select()
.from(schema.cookingSessions)
.where(
and(
eq(schema.cookingSessions.householdId, householdId),
eq(schema.cookingSessions.status, "started"),
),
)
.orderBy(schema.cookingSessions.startedAt);
return { sessions };
});
}
async function getOwnedSession(app: FastifyInstance, sessionId: string, userId: string) {
const [session] = await app.db
.select()
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, sessionId))
.limit(1);
if (!session) throw errors.notFound("Sessionen finns inte.");
await requireMembership(app.db, session.householdId, userId);
return session;
}
+17 -194
View File
@@ -32,6 +32,7 @@ import {
todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
import { completeCookingSessionCore } from "../lib/cooking.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
@@ -415,204 +416,26 @@ export async function recipeRoutes(app: FastifyInstance) {
app.post("/v1/recipes/:id/cook", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookRecipeInputSchema, req.body);
const recipe = await loadFullRecipe(app, id);
await loadFullRecipe(app, id);
const householdId = await requireActiveHousehold(app.db, req.userId);
const date = input.date ?? todayIso();
// 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum)
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory) {
const factor = input.portionsCooked / 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,
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,
type: "cook_use",
quantityDelta: -(item.quantity - newQty),
unit: item.unit,
refType: "recipe_cook",
refId: id,
actorUserId: req.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. Logga måltid per ätare med portionsandel (spec §7: individuellt)
const eaters =
input.eaters.length > 0 ? input.eaters : [{ userId: req.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,
date,
mealType: input.mealType,
source: "cooked_recipe",
recipeId: id,
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: input.mealType,
kcal: nutrition.kcal,
source: "cooked_recipe",
},
userId: eater.userId,
householdId,
correlationId: req.correlationId,
});
}
// 3. Matlådor (spec §24)
let mealBoxId: string | null = null;
if (input.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.now() + useByDays * 86_400_000)
.toISOString()
.slice(0, 10);
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: id,
titleSv: recipe.titleSv,
portions: input.mealBoxPortions,
portionsRemaining: input.mealBoxPortions,
nutritionPerPortion: recipe.nutritionPerPortion,
cookedAt: date,
storageLocationId: locationId,
frozen: input.mealBoxFrozen,
recommendedUseBy,
})
.returning();
mealBoxId = box!.id;
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.mealBoxPortions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
}
// 4. Statistik + event
await app.db.insert(schema.recipeCooks).values({
recipeId: id,
userId: req.userId,
householdId,
portionsCooked: input.portionsCooked,
});
await app.db
.update(schema.recipes)
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
.where(eq(schema.recipes.id, id));
await emitEvent(app.db, {
type: "RECIPE_COOKED",
payload: {
// Bakåtkompatibel shortcut: skapa session + complete direkt.
const [session] = await app.db
.insert(schema.cookingSessions)
.values({
recipeId: id,
portions: input.portionsCooked,
mealBoxPortions: input.mealBoxPortions,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
householdId,
startedByUserId: req.userId,
status: "started",
plannedPortions: input.portionsCooked,
plannedMealType: input.mealType,
startedAt: new Date(),
})
.returning();
await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt");
if (input.deductInventory && deductions.length > 0) {
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
}
const result = await completeCookingSessionCore(app, session!, req.userId, input, req.correlationId);
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
return { sessionId: session!.id, ...result };
});
/** Substitutionsförslag för en ingrediens (spec §20). */
@@ -997,7 +820,7 @@ export async function recipeRoutes(app: FastifyInstance) {
});
}
async function loadFullRecipe(app: FastifyInstance, id: string) {
export async function loadFullRecipe(app: FastifyInstance, id: string) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
+2
View File
@@ -13,6 +13,7 @@ import { inventoryRoutes } from "./routes/inventory.js";
import { scanRoutes } from "./routes/scans.js";
import { scanDiffRoutes } from "./routes/scan-diff.js";
import { recipeRoutes } from "./routes/recipes.js";
import { cookingSessionRoutes } from "./routes/cooking-sessions.js";
import { mealRoutes } from "./routes/meals.js";
import { shoppingRoutes } from "./routes/shopping.js";
import { planningRoutes } from "./routes/planning.js";
@@ -80,6 +81,7 @@ export async function buildServer(config: AppConfig) {
await app.register(scanRoutes);
await app.register(scanDiffRoutes);
await app.register(recipeRoutes);
await app.register(cookingSessionRoutes);
await app.register(mealRoutes);
await app.register(shoppingRoutes);
await app.register(planningRoutes);
+238
View File
@@ -0,0 +1,238 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { 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";
import { cancelTimedOutCookingSessions } from "@app/database";
import { computeBalance } from "@app/inventory-engine";
describe("cooking sessions", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
let token: string;
let householdId: string;
let recipeId: string;
const email = "cooking-session-test@example.invalid";
async function cleanup() {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, [email]));
for (const u of existing) {
const sessions = await testDb.db
.select({ id: schema.cookingSessions.id })
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.startedByUserId, u.id));
for (const s of sessions) {
await testDb.db
.delete(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.cookingSessionId, s.id));
await testDb.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, s.id));
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, s.id));
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, s.id));
}
await testDb.db.delete(schema.cookingSessions).where(eq(schema.cookingSessions.startedByUserId, u.id));
const memberships = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, u.id));
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.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId));
await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId));
}
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
beforeAll(async () => {
await cleanup();
app = await buildServer(config);
await app.ready();
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "Cooking Test" },
});
token = (JSON.parse(res.body) as { accessToken: string }).accessToken;
const quick = await app.inject({
method: "POST",
url: "/v1/onboarding/quick-start",
headers: { authorization: `Bearer ${token}` },
payload: { goals: ["less_waste"], precisionMode: "simple" },
});
householdId = (JSON.parse(quick.body) as { householdId: string }).householdId;
// Hitta ett seedat recept
const recipes = await app.inject({
method: "GET",
url: "/v1/recipes?limit=1",
headers: { authorization: `Bearer ${token}` },
});
recipeId = (JSON.parse(recipes.body) as { recipes: Array<{ id: string }> }).recipes[0]!.id;
});
afterAll(async () => {
await cleanup();
await closeDatabase();
await app.close();
});
it("creates a planned session", async () => {
const res = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { portions: 4, mealType: "dinner" },
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body) as { session: { status: string; plannedPortions: number } };
expect(body.session.status).toBe("planned");
expect(body.session.plannedPortions).toBe(4);
});
it("starts a planned session", async () => {
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { portions: 2 },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
const res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/start`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { session: { status: string } };
expect(body.session.status).toBe("started");
});
it("cancels a session without touching inventory", async () => {
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
const before = await testDb.db
.select({ count: count(schema.inventoryTransactions.id) })
.from(schema.inventoryTransactions)
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
.where(eq(schema.inventoryItems.householdId, householdId));
const res = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/cancel`,
headers: { authorization: `Bearer ${token}` },
payload: { reason: "vi åt ute" },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { session: { status: string } };
expect(body.session.status).toBe("cancelled");
const after = await testDb.db
.select({ count: count(schema.inventoryTransactions.id) })
.from(schema.inventoryTransactions)
.innerJoin(schema.inventoryItems, eq(schema.inventoryTransactions.inventoryItemId, schema.inventoryItems.id))
.where(eq(schema.inventoryItems.householdId, householdId));
expect(after[0]!.count).toBe(before[0]!.count);
});
it("completes a started session and links inventory, meals and recipe_cooks", 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, deductInventory: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as {
session: { status: string };
mealIds: string[];
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
};
expect(body.session.status).toBe("completed");
expect(body.mealIds.length).toBeGreaterThan(0);
const txCount = await testDb.db
.select({ count: count(schema.inventoryTransactions.id) })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
expect(Number(txCount[0]!.count)).toBeGreaterThanOrEqual(body.inventoryDeductions.length);
const cookCount = await testDb.db
.select({ count: count(schema.recipeCooks.id) })
.from(schema.recipeCooks)
.where(eq(schema.recipeCooks.cookingSessionId, sessionId));
expect(Number(cookCount[0]!.count)).toBe(1);
// Transaktionsinvariant: computeBalance ska matcha item.quantity
for (const d of body.inventoryDeductions) {
const item = await testDb.db
.select({ id: schema.inventoryItems.id, quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, d.itemId))
.limit(1);
const txs = await testDb.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, d.itemId));
const balance = computeBalance(txs);
expect(Math.abs(balance.balance - (item[0]?.quantity ?? 0))).toBeLessThan(1e-6);
}
});
it("legacy POST /v1/recipes/:id/cook still works as shortcut", async () => {
const res = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 2, deductInventory: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { ok: boolean; sessionId: string; mealBoxId: string | null };
expect(body.ok).toBe(true);
expect(body.sessionId).toBeDefined();
expect(body.mealBoxId).toBeDefined();
});
it("times out started sessions older than 24 h", async () => {
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
// Simulera 25 h gammal session
await testDb.db
.update(schema.cookingSessions)
.set({ startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
.where(eq(schema.cookingSessions.id, sessionId));
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
});
});
+12
View File
@@ -8,6 +8,7 @@ import { processModerateRecipe } from "./processors/moderation.js";
import { processTranslateRecipe } from "./processors/translation.js";
import { processGenerateWeekPlan } from "./processors/weekplan.js";
import {
processCookingSessionTimeout,
processExpiryNotifications,
processMealBoxReminders,
processMemorySync,
@@ -117,6 +118,12 @@ const worker = new Worker(
return;
}
case "COOKING_SESSION_TIMEOUT": {
const cancelled = await processCookingSessionTimeout(ctx);
if (cancelled > 0) log(`Cooking session timeout: ${cancelled} avbrutna`);
return;
}
// Deterministiska/planerade jobb som inte kräver egen processor ännu
case "NORMALIZE_PRODUCTS":
case "DEDUPLICATE_INVENTORY":
@@ -213,6 +220,11 @@ async function registerRepeatableJobs() {
{ every: 6 * 60 * 60 * 1000 }, // var 6:e timme; decay lever på dygnsskala
{ name: "UPDATE_TRUST_STATES", data: { jobType: "UPDATE_TRUST_STATES" }, opts: baseOpts },
);
await queue.upsertJobScheduler(
"scheduler-cooking-timeout",
{ every: 60 * 60 * 1000 }, // varje timme räcker för 24 h-timeout
{ name: "COOKING_SESSION_TIMEOUT", data: { jobType: "COOKING_SESSION_TIMEOUT" }, opts: baseOpts },
);
await queue.upsertJobScheduler(
"scheduler-expiry",
{ pattern: "0 7 * * *", tz: "Europe/Stockholm" },
+8 -1
View File
@@ -1,9 +1,10 @@
import { and, asc, eq, gt, inArray, isNull, lte, sql } from "drizzle-orm";
import { and, asc, eq, gt, inArray, isNull, lt, lte, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { classifyExpiry, computeTrust } from "@app/inventory-engine";
import { deriveMemoryUpdates } from "@app/memory-client";
import { getLocaleContext } from "../locale.js";
import type { WorkerContext } from "../context.js";
import { cancelTimedOutCookingSessions } from "@app/database";
/**
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
@@ -84,6 +85,12 @@ export async function processTrustDecay(ctx: WorkerContext): Promise<number> {
return updated;
}
/** COOKING_SESSION_TIMEOUT (Fas 3 §6 / §20): avbryt STARTED-sessioner äldre än 24 h. */
export async function processCookingSessionTimeout(ctx: WorkerContext): Promise<number> {
const sessions = await cancelTimedOutCookingSessions(ctx.db);
return sessions.length;
}
/** SEND_EXPIRY_NOTIFICATION (spec §54): skapa notiser för varor som snart går ut. */
export async function processExpiryNotifications(ctx: WorkerContext): Promise<number> {
const households = await ctx.db.select({ id: schema.households.id }).from(schema.households);