335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
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 };
|
||
});
|
||
}
|