import type { FastifyInstance } from "fastify"; import { and, desc, eq } from "drizzle-orm"; import { schema } from "@app/database"; import { consumeMealBoxInputSchema, createMealBoxInputSchema, dayQuerySchema, idParamSchema, logMealInputSchema, } from "@app/validation"; import { computeItemNutrition, DEFAULT_TARGETS, computeDailyTargets, scaleNutrition, summarizeDay, sumNutrition, } from "@app/nutrition-engine"; import { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types"; import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js"; /** * Måltidsloggning + "Min dag" (spec §4.3, §23) och matlådor (spec §24). * Näringsvärden härleds deterministiskt – aldrig av AI (spec §61.1). */ export async function mealRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.post("/v1/meals", auth, async (req, reply) => { const input = parse(logMealInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId).catch(() => null); let nutrition: NutritionValues; let isEstimate = false; let estimateMin: number | null = null; let estimateMax: number | null = null; if (input.nutritionOverride) { // Användarens egen inmatning eller bekräftat foto-intervall. nutrition = { ...EMPTY_NUTRITION, ...input.nutritionOverride }; isEstimate = input.source === "plate_photo"; } else if (input.recipeId) { const [recipe] = await app.db .select() .from(schema.recipes) .where(eq(schema.recipes.id, input.recipeId)) .limit(1); if (!recipe) throw errors.notFound("Receptet finns inte."); nutrition = scaleNutrition(recipe.nutritionPerPortion, input.portionFraction); } else if (input.items.length > 0) { const parts: NutritionValues[] = []; for (const item of input.items) { if (item.nutrition) { parts.push({ ...EMPTY_NUTRITION, ...item.nutrition }); continue; } if (item.canonicalIngredientId && item.quantity != null && item.unit) { const [ing] = await app.db .select() .from(schema.canonicalIngredients) .where(eq(schema.canonicalIngredients.id, item.canonicalIngredientId)) .limit(1); if (ing) { const computed = computeItemNutrition(item.quantity, item.unit, ing.nutritionPer100, { densityGPerMl: ing.densityGPerMl, gramsPerPiece: ing.gramsPerPiece, }); if (computed) { parts.push(computed); continue; } } } throw errors.badRequest( `"${item.displayName}" saknar näringsdata. Ange mängd + känd ingrediens, eller egna värden.`, ); } nutrition = sumNutrition(parts); } else if (input.scanJobId) { // Tallriksfoto: intervall från AAMOS som användaren bekräftar (spec §22). const [job] = await app.db .select() .from(schema.scanJobs) .where(and(eq(schema.scanJobs.id, input.scanJobId), eq(schema.scanJobs.userId, req.userId))) .limit(1); if (!job?.result) throw errors.badRequest("Skanningen har inget resultat."); const result = job.result as { kcalRange?: { min: number; max: number; mostLikely: number } }; if (!result.kcalRange) throw errors.badRequest("Skanningen saknar kaloriuppskattning."); nutrition = { ...EMPTY_NUTRITION, kcal: result.kcalRange.mostLikely }; isEstimate = true; estimateMin = result.kcalRange.min; estimateMax = result.kcalRange.max; } else { throw errors.badRequest("Ange recept, livsmedel, skanning eller egna näringsvärden."); } const [meal] = await app.db .insert(schema.meals) .values({ userId: req.userId, householdId, date: input.date, mealType: input.mealType, source: input.source, recipeId: input.recipeId ?? null, titleSv: input.titleSv, portionFraction: input.portionFraction, nutrition, nutritionIsEstimate: isEstimate, estimateMinKcal: estimateMin, estimateMaxKcal: estimateMax, items: input.items.length > 0 ? input.items : null, scanJobId: input.scanJobId ?? null, }) .returning(); await emitEvent(app.db, { type: "MEAL_LOGGED", payload: { mealId: meal!.id, mealType: input.mealType, kcal: nutrition.kcal, source: input.source, }, userId: req.userId, householdId: householdId ?? undefined, correlationId: req.correlationId, }); return reply.status(201).send(meal); }); /** "Min dag" (spec §4.3): måltider + summering mot personliga mål. */ app.get("/v1/meals/day", auth, async (req) => { const { date } = parse(dayQuerySchema, req.query); const meals = await app.db .select() .from(schema.meals) .where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, date))) .orderBy(schema.meals.loggedAt); const [profile] = await app.db .select() .from(schema.userHealthProfiles) .where(eq(schema.userHealthProfiles.userId, req.userId)) .limit(1); const [prefs] = await app.db .select() .from(schema.userPreferences) .where(eq(schema.userPreferences.userId, req.userId)) .limit(1); const targets = profile?.weightKg && profile.heightCm && profile.birthYear ? computeDailyTargets({ sex: profile.sex ?? "unspecified", age: new Date().getUTCFullYear() - profile.birthYear, heightCm: profile.heightCm, weightKg: profile.weightKg, activityLevel: profile.activityLevel, primaryGoal: prefs?.primaryGoal ?? undefined, }).targets : DEFAULT_TARGETS; const summary = summarizeDay( meals.map((m) => m.nutrition), targets, ); const hasEstimates = meals.some((m) => m.nutritionIsEstimate); return { date, meals, summary, note: hasEstimates ? "Dagen innehåller uppskattade värden från foto – justera gärna vid behov." : "Värdena är beräknade ur recept och livsmedelsdata och visas som uppskattningar.", }; }); app.delete("/v1/meals/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [meal] = await app.db .select() .from(schema.meals) .where(and(eq(schema.meals.id, id), eq(schema.meals.userId, req.userId))) .limit(1); if (!meal) throw errors.notFound(); await app.db.delete(schema.meals).where(eq(schema.meals.id, id)); return { ok: true }; }); // --------------------------------------------------------------------- // Matlådor (spec §24) // --------------------------------------------------------------------- app.get("/v1/meal-boxes", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); const boxes = await app.db .select() .from(schema.mealBoxes) .where( and( eq(schema.mealBoxes.householdId, householdId), eq(schema.mealBoxes.status, "available"), ), ) .orderBy(schema.mealBoxes.recommendedUseBy); return { mealBoxes: boxes }; }); app.post("/v1/meal-boxes", auth, async (req, reply) => { const input = parse(createMealBoxInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); let nutritionPerPortion = null; if (input.recipeId) { const [recipe] = await app.db .select({ nutrition: schema.recipes.nutritionPerPortion }) .from(schema.recipes) .where(eq(schema.recipes.id, input.recipeId)) .limit(1); nutritionPerPortion = recipe?.nutrition ?? null; } const cookedAt = input.cookedAt ?? todayIso(); const useByDays = input.frozen ? 90 : 3; const [box] = await app.db .insert(schema.mealBoxes) .values({ householdId, recipeId: input.recipeId ?? null, titleSv: input.titleSv, portions: input.portions, portionsRemaining: input.portions, nutritionPerPortion, cookedAt, storageLocationId: input.storageLocationId, frozen: input.frozen, recommendedUseBy: new Date(Date.parse(cookedAt) + useByDays * 86_400_000) .toISOString() .slice(0, 10), reservedForUserId: input.reservedForUserId ?? null, }) .returning(); await emitEvent(app.db, { type: "MEAL_BOX_CREATED", payload: { mealBoxId: box!.id, portions: input.portions }, userId: req.userId, householdId, correlationId: req.correlationId, }); return reply.status(201).send(box); }); app.post("/v1/meal-boxes/:id/consume", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const input = parse(consumeMealBoxInputSchema, req.body); const [box] = await app.db .select() .from(schema.mealBoxes) .where(eq(schema.mealBoxes.id, id)) .limit(1); if (!box) throw errors.notFound("Matlådan finns inte."); await requireMembership(app.db, box.householdId, req.userId); if (box.portionsRemaining < input.portions) { throw errors.conflict(`Bara ${box.portionsRemaining} portioner kvar.`); } const remaining = box.portionsRemaining - input.portions; await app.db .update(schema.mealBoxes) .set({ portionsRemaining: remaining, status: remaining <= 0 ? "consumed" : box.status }) .where(eq(schema.mealBoxes.id, id)); let mealId: string | null = null; if (input.logAsMeal && box.nutritionPerPortion) { const [meal] = await app.db .insert(schema.meals) .values({ userId: req.userId, householdId: box.householdId, date: input.date ?? todayIso(), mealType: input.mealType, source: "meal_box", recipeId: box.recipeId, titleSv: box.titleSv, portionFraction: input.portions, nutrition: scaleNutrition(box.nutritionPerPortion, input.portions), nutritionIsEstimate: false, }) .returning(); mealId = meal!.id; } await emitEvent(app.db, { type: "MEAL_BOX_CONSUMED", payload: { mealBoxId: id, portions: input.portions }, userId: req.userId, householdId: box.householdId, correlationId: req.correlationId, }); return { ok: true, portionsRemaining: remaining, mealId }; }); app.post("/v1/meal-boxes/:id/discard", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [box] = await app.db .select() .from(schema.mealBoxes) .where(eq(schema.mealBoxes.id, id)) .limit(1); if (!box) throw errors.notFound(); await requireMembership(app.db, box.householdId, req.userId); await app.db .update(schema.mealBoxes) .set({ status: "discarded", portionsRemaining: 0 }) .where(eq(schema.mealBoxes.id, id)); return { ok: true }; }); /** Måltidshistorik ("tidigare måltid" som loggkälla, spec §23). */ app.get("/v1/meals/recent", auth, async (req) => { const meals = await app.db .select() .from(schema.meals) .where(eq(schema.meals.userId, req.userId)) .orderBy(desc(schema.meals.loggedAt)) .limit(20); return { meals }; }); }