Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
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 };
});
}