Files
Cibello-app/apps/api/src/routes/meals.ts
T
2026-08-05 19:21:11 +07:00

335 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
});
}