import type { FastifyInstance } from "fastify"; import { and, eq, inArray, 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, cookingSessionCancelled } from "@app/analytics"; import { trackProductAnalytics } from "../lib/helpers.js"; import { completeCookingSession, undoCookingSession } 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(); // cooking_session_started skickas endast vid faktisk start, inte vid planned. if (status === "started") { 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" || session.status === "undone" ) { 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. * 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); 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 completeCookingSession(app, session, req.userId, input, req.correlationId); return result; }); /** 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, 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), inArray(schema.cookingAssumptionProfiles.canonicalIngredientId, nonOptionalIds), ), ); // Deterministiskt val: profilen med flest observationer (stabil över tid). const p = profiles.sort((a, b) => b.observationCount - a.observationCount)[0]; return { householdId, recipeId: id, defaultActualPortionsEaten: p?.averageEatenPortions ?? null, defaultLeftoverEstimatePortions: p?.averageLeftoverPortions ?? null, observationCount: p?.observationCount ?? 0, }; }); /** * Ångra en completed session inom 24 h. * Ledger är append-only: reverseringstransaktioner skrivs, befintliga * transaktioner rörs inte. */ app.post("/v1/cooking-sessions/:id/undo", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const session = await getOwnedSession(app, id, req.userId); const result = await undoCookingSession(app, session, req.userId, req.correlationId); return result; }); /** 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; }