506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
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<string, IngredientSafetyInfo>(
|
||
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<string>`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<number>`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<string>(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,
|
||
};
|
||
});
|
||
}
|