Initial commit (unpacked platform)
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user