import type { FastifyInstance } from "fastify"; import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import { schema } from "@app/database"; import type { MemoryItem, TasteSignal } from "@app/shared-types"; import { whatToEatQuerySchema } from "@app/validation"; import { computeCoverage, checkRecipeSafety, isRecipeSafe, type IngredientSafetyInfo, type PantryItem, } from "@app/recipe-engine"; import { depersonalize, isEventActive, parseCraving, rankAll, seasonForDate, summarizeContext, viewWeights, type CookingAssumption, type RecommendationCandidate, type RecommendationContext, } from "@app/recommendation-engine"; import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine"; import { parse } from "../lib/errors.js"; import { getActiveHouseholdId, todayIso } from "../lib/helpers.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; /** * "Vad ska vi äta?" (spec §18) – appens viktigaste endpoint. * * Pipeline: * 1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext. * 2. Deterministisk säkerhetsfiltrering (spec §61.2) – blockers försvinner. * 3. Täckningsberäkning mot lagret + poängsättning med förklaringar. * 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24). * 5. (Bakom flagga) AAMOS får omranka topplistan – aldrig lägga till recept. */ export async function recommendationRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/recommendations/what-to-eat", auth, async (req) => { const q = parse(whatToEatQuerySchema, req.query); // Graceful for users without a household yet (e.g. after step A onboarding): // treat it as an empty pantry / single-person context instead of failing. const householdId = await getActiveHouseholdId(app.db, req.userId); const today = new Date(); // --- 1. Kontext: lager --- const stockRows = householdId ? await app.db .select({ item: schema.inventoryItems, locationType: schema.storageLocations.type, shelfLife: schema.canonicalIngredients.shelfLifeGuidance, density: schema.canonicalIngredients.densityGPerMl, gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, }) .from(schema.inventoryItems) .innerJoin( schema.storageLocations, eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), ) .leftJoin( schema.canonicalIngredients, eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), ) .where( and( eq(schema.inventoryItems.householdId, householdId), isNull(schema.inventoryItems.depletedAt), gt(schema.inventoryItems.quantity, 0), ), ) : []; const pantry: PantryItem[] = stockRows.map((r) => ({ id: r.item.id, canonicalIngredientId: r.item.canonicalIngredientId, quantity: r.item.quantity, unit: r.item.unit, bestBeforeDate: r.item.bestBeforeDate, useByDate: r.item.useByDate, openedAt: r.item.openedAt, frozenAt: r.item.frozenAt, thawedAt: r.item.thawedAt, purchasedAt: r.item.purchasedAt, storageLocationType: r.locationType, shelfLifeGuidance: r.shelfLife, })); const unitInfo = new Map( stockRows .filter((r) => r.item.canonicalIngredientId) .map((r) => [ r.item.canonicalIngredientId!, { densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece }, ]), ); // --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) --- const members = householdId ? await app.db .select({ userId: schema.householdMembers.userId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.householdId, householdId)) : []; const memberIds = members.length ? members.map((m) => m.userId) : [req.userId]; const allPrefs = await app.db .select() .from(schema.userPreferences) .where(inArray(schema.userPreferences.userId, memberIds)); const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))]; const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))]; const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5); const myPrefs = allPrefs.find((p) => p.userId === req.userId); // --- 3. Kandidater: publicerade recept för måltidstypen --- const candidates = await app.db .select() .from(schema.recipes) .where( and( eq(schema.recipes.status, "published"), sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`, ), ) .limit(200); const allIngredients = await app.db .select() .from(schema.recipeIngredients) .where( inArray( schema.recipeIngredients.recipeId, candidates.map((c) => c.id), ), ); const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))]; const safetyRows = await app.db .select() .from(schema.canonicalIngredients) .where(inArray(schema.canonicalIngredients.id, ingredientIds)); const safetyMap = new Map( safetyRows.map((r) => [ r.id, { id: r.id, allergens: r.allergens, isVegan: r.isVegan, isVegetarian: r.isVegetarian, containsGluten: r.containsGluten, containsLactose: r.containsLactose, isPork: r.isPork, isBeef: r.isBeef, isAlcohol: r.isAlcohol, }, ]), ); for (const r of safetyRows) { if (!unitInfo.has(r.id)) { unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece }); } } // --- 4. Näringskontext: vad återstår av dagen? --- const [profile] = await app.db .select() .from(schema.userHealthProfiles) .where(eq(schema.userHealthProfiles.userId, req.userId)) .limit(1); const targets = profile?.weightKg && profile.heightCm && profile.birthYear ? computeDailyTargets({ sex: profile.sex ?? "unspecified", age: today.getUTCFullYear() - profile.birthYear, heightCm: profile.heightCm, weightKg: profile.weightKg, activityLevel: profile.activityLevel, primaryGoal: myPrefs?.primaryGoal ?? undefined, }).targets : DEFAULT_TARGETS; const todaysMeals = await app.db .select({ nutrition: schema.meals.nutrition }) .from(schema.meals) .where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso()))); const daySummary = summarizeDay( todaysMeals.map((m) => m.nutrition), targets, ); // --- 5. Säsong & högtid (spec §28) – marknadsstyrt via användarens region --- const localePrefs = await loadLocalePreferences(app.db, req.userId); const market = localePrefs.regionCode; const events = await app.db .select() .from(schema.seasonEvents) .where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, market))); const activeEvents = events.filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today), ); const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring) const activeHolidayNames = activeEvents.map((e) => e.nameLocalized || e.nameSv); // lokaliserat namn, fallback nameSv // --- 6. Senast lagat (variation) + hushållsbetyg --- const cooks = householdId ? await app.db .select({ recipeId: schema.recipeCooks.recipeId, last: sql`max(${schema.recipeCooks.cookedAt})`, }) .from(schema.recipeCooks) .where(eq(schema.recipeCooks.householdId, householdId)) .groupBy(schema.recipeCooks.recipeId) : []; const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last])); const householdRatings = await app.db .select({ recipeId: schema.recipeRatings.recipeId, avg: sql`avg(${schema.recipeRatings.stars})`, }) .from(schema.recipeRatings) .where(inArray(schema.recipeRatings.userId, memberIds)) .groupBy(schema.recipeRatings.recipeId); const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)])); // --- 7. Personaliseringssamtycke — HÅRD GRIND (S1) --- const [personalizationConsent] = await app.db .select() .from(schema.userConsents) .where( and( eq(schema.userConsents.userId, req.userId), eq(schema.userConsents.kind, "personalization"), ), ) .limit(1); const personalizationEnabled = personalizationConsent?.status === "granted"; let memoryItems: MemoryItem[] = []; let tasteSignals: TasteSignal[] = []; let cookingAssumptions: CookingAssumption[] = []; if (personalizationEnabled && householdId) { const memoryRows = await app.db .select({ id: schema.memoryItems.id, userId: schema.memoryItems.userId, householdId: schema.memoryItems.householdId, kind: schema.memoryItems.kind, key: schema.memoryItems.key, summarySv: schema.memoryItems.summarySv, value: schema.memoryItems.value, origin: schema.memoryItems.origin, confidence: schema.memoryItems.confidence, verifiedByUser: schema.memoryItems.verifiedByUser, paused: schema.memoryItems.paused, createdAt: schema.memoryItems.createdAt, updatedAt: schema.memoryItems.updatedAt, }) .from(schema.memoryItems) .where( or( eq(schema.memoryItems.userId, req.userId), eq(schema.memoryItems.householdId, householdId), ), ); memoryItems = memoryRows.map((m) => ({ ...m, userId: m.userId ?? undefined, householdId: m.householdId ?? undefined, createdAt: m.createdAt.toISOString(), updatedAt: m.updatedAt.toISOString(), lastUsedAt: undefined, expiresAt: undefined, })); const tasteRows = await app.db .select({ id: schema.tasteSignals.id, userId: schema.tasteSignals.userId, axis: schema.tasteSignals.axis, target: schema.tasteSignals.target, direction: schema.tasteSignals.direction, strength: schema.tasteSignals.strength, origin: schema.tasteSignals.origin, refRecipeId: schema.tasteSignals.refRecipeId, createdAt: schema.tasteSignals.createdAt, }) .from(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, req.userId)); tasteSignals = tasteRows.map((t) => ({ ...t, target: t.target ?? undefined, refRecipeId: t.refRecipeId ?? undefined, createdAt: t.createdAt.toISOString(), })); const assumptions = await app.db .select({ canonicalIngredientId: schema.cookingAssumptionProfiles.canonicalIngredientId, averageEatenPortions: schema.cookingAssumptionProfiles.averageEatenPortions, averageLeftoverPortions: schema.cookingAssumptionProfiles.averageLeftoverPortions, observationCount: schema.cookingAssumptionProfiles.observationCount, }) .from(schema.cookingAssumptionProfiles) .where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); cookingAssumptions = assumptions.map((a) => ({ canonicalIngredientId: a.canonicalIngredientId, averageEatenPortions: a.averageEatenPortions, averageLeftoverPortions: a.averageLeftoverPortions, observationCount: a.observationCount, })); } // --- 8. Tolka "jag är sugen på" (spec §19) --- const craving = q.craving ? parseCraving(q.craving) : null; const ctx: RecommendationContext = { mealType: q.mealType, persons: q.persons ?? (members.length ? members.length : 1), maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined, maxCostMinorPerPortion: q.maxCostMinorPerPortion, remainingProteinG: Math.max(0, daySummary.remaining.proteinG), remainingKcal: Math.max(0, daySummary.remaining.kcal), currentSeason: seasonForDate(today), activeHolidayTags, isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4, favoriteCuisines: myPrefs?.favoriteCuisines ?? [], cravingTags: craving?.tags, cravingCuisine: craving?.cuisine, cravingMaxKcal: craving?.maxKcal, personalizationEnabled, memoryItems: personalizationEnabled ? memoryItems : undefined, tasteSignals: personalizationEnabled ? tasteSignals : undefined, cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined, }; // --- 8. Filtrera säkert + beräkna täckning + poängsätt --- const scoredCandidates: RecommendationCandidate[] = []; for (const recipe of candidates) { const recipeIngredients = allIngredients .filter((i) => i.recipeId === recipe.id) .map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, displayNameSv: i.displayNameSv, quantity: i.quantity, unit: i.unit, optional: i.optional, })); const violations = checkRecipeSafety( { ingredients: recipeIngredients.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, optional: i.optional, })), spiceLevel: recipe.spiceLevel, }, { allergens: combinedAllergens, dietPattern: myPrefs?.dietPattern, religiousRule: myPrefs?.religiousRule, avoidIngredientIds: combinedAvoid, spiceLevelMax: strictestSpice, }, safetyMap, ); if (!isRecipeSafe(violations)) continue; const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today); const lastDate = lastCooked.get(recipe.id); scoredCandidates.push({ recipeId: recipe.id, titleSv: recipe.titleSv, cuisine: recipe.cuisine, tags: recipe.tags as never, totalTimeMinutes: recipe.totalTimeMinutes, nutritionPerPortion: recipe.nutritionPerPortion, estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion, ratingAverage: recipe.ratingAverage, ratingCount: recipe.ratingCount, peakSeasons: recipe.peakSeasons, holidayTags: recipe.holidayTags, spiceLevel: recipe.spiceLevel, coverage, daysSinceLastCooked: lastDate ? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000) : null, householdRating: householdRatingMap.get(recipe.id) ?? null, ingredientIds: recipeIngredients .map((i) => i.canonicalIngredientId) .filter((id): id is string => id != null), }); } const weights = personalizationEnabled ? viewWeights(q.view) : depersonalize(viewWeights(q.view)); // Högtids-sök: matchar sökningen ett event för marknaden (året runt) → expandera till dess foodTags + slug. const holidayFoodTags: string[] = []; const holidaySlugs: string[] = []; if (q.craving) { const qc = q.craving.toLowerCase(); for (const e of events) { if ( qc.includes(e.slug) || qc.includes(e.nameSv.toLowerCase()) || (e.nameLocalized ? qc.includes(e.nameLocalized.toLowerCase()) : false) ) { holidayFoodTags.push(...e.foodTags); holidaySlugs.push(e.slug); } } } // Sökte användaren specifikt (råvara "kyckling" ELLER högtid "midsommar")? Visa matchande först. let pool = scoredCandidates; if (craving && (craving.keywords.length > 0 || craving.cuisine || holidaySlugs.length > 0)) { const matches = scoredCandidates.filter((c) => { const title = c.titleSv.toLowerCase(); const tagSet = new Set(c.tags as unknown as string[]); return ( (!!craving.cuisine && c.cuisine === craving.cuisine) || craving.keywords.some((k) => title.includes(k) || tagSet.has(k)) || holidaySlugs.some((s) => c.holidayTags.includes(s)) || holidayFoodTags.some((ft) => title.includes(ft) || tagSet.has(ft)) ); }); if (matches.length > 0) pool = matches; // annars behåll alla (aldrig tomt) } let recommendations = rankAll(pool, ctx, weights, q.limit); // --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) --- if (await app.flags.isEnabled("ai_rerank", req.userId)) { const result = await app.aamos.runTask( "RANK_RECIPES", { candidateIds: recommendations.map((r) => r.recipeId), deterministicScores: Object.fromEntries( recommendations.map((r) => [r.recipeId, r.score]), ), contextSummary: summarizeContext(ctx), }, { correlationId: req.correlationId, subjectRef: null, localeContext: await ( await import("../lib/localeContext.js") ).getLocaleContext(app.db, req.userId), }, ); if (result.status === "ok" && result.output) { const order = new Map(result.output.rankedIds.map((id, i) => [id, i])); recommendations = [...recommendations].sort( (a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99), ); } } // --- 10. Matlådor först när rimligt (spec §24) --- const mealBoxes = q.includeLeftovers && householdId ? await app.db .select() .from(schema.mealBoxes) .where( and( eq(schema.mealBoxes.householdId, householdId), eq(schema.mealBoxes.status, "available"), ), ) .orderBy(schema.mealBoxes.recommendedUseBy) .limit(5) : []; const mealBoxSuggestions = mealBoxes.map((box) => ({ mealBoxId: box.id, titleSv: box.titleSv, portionsRemaining: box.portionsRemaining, recommendedUseBy: box.recommendedUseBy, whySv: Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000 ? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.` : "Färdig mat som väntar – snabbaste middagen i huset.", })); return { mealType: q.mealType, context: { persons: ctx.persons, season: ctx.currentSeason, activeHolidays: activeHolidayNames, remainingKcal: ctx.remainingKcal, remainingProteinG: ctx.remainingProteinG, craving: craving ?? null, view: q.view, }, mealBoxSuggestions, recommendations, }; }); }