feat: V1 tracking endpoints + meal plan generation (PRD §5.1, §5.2, §5.3)

@ketopath/shared:
- tracking/schema.ts — Zod schemas for WeightEntry input (weight, optional
  measurements/notes/energy/sleep/hunger, photo URLs) and FastEvent start /
  update, plus PROTOCOL_DEFAULT_MINUTES table
- planner/macros.ts — macrosForPhase(): protein 1.6-1.8g/kg, netCarb 25/60/120g
  by phase, fat = remainder (PRD §9.3)
- planner/matchmaking.ts — pure matchMeals() that filters by exclusion tags,
  phase compatibility and meal category, then scores by euclidean distance
  from the meal's macro target with a 1.5 penalty for recently-consumed recipes;
  DEFAULT_MEAL_SHARE constant (25/35/10/30 %)
- 5 new unit tests cover exclusion, phase, recency, topN, category mismatch

apps/api:
- modules/tracking/weight.routes.ts — GET /me/weight-entries (last 60),
  POST /me/weight-entries with upsert on (userId, date); encrypted fields
  serialised to/from JSON strings
- modules/tracking/fast.routes.ts — GET, POST (start) and PATCH (update) on
  fast events; symptoms stored as encrypted JSON
- modules/plan/plan.routes.ts — POST /me/meal-plans:
  - reads profile + preferences, computes BMR (Mifflin-St Jeor), TDEE, daily
    kcal target with the phase-dependent deficit, then macros via macrosForPhase
  - upserts a MealPlan rooted at Monday-of-this-week, wipes prior slots, and
    fills 28 slots running matchMeals per (day, meal) with a recent-2-day
    rolling exclusion list to keep variety
  - selected recipe + 4 alternatives per slot
- GET /me/meal-plans/current returns the active plan with selected/alternatives

@ketopath/db:
- prisma/seed-recipes.ts — 16 italian keto recipes (4 per meal type) with
  estimated macros per serving and phase compatibility
- prisma/seed.ts — idempotent insertion (skip on existing name match)

Verified end-to-end with sign-up → create profile (Michele PRD persona) →
generate plan: 28 slots filled, daily target 1399 kcal / 137 P / 83 F / 25 C,
matchmaking distributes 4 different breakfasts across the first 4 days as
the recent-consumed penalty kicks in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 16:07:56 +02:00
1 parent 0d2d1f49c7
commit 4b6c3f6ae9
12 files changed
+854 -3

No files matched your search

+6
View File
@@ -9,7 +9,10 @@ import { Sentry } from './lib/sentry.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
import { planRoutes } from './modules/plan/plan.routes.js';
import { profileRoutes } from './modules/profile/profile.routes.js';
import { fastRoutes } from './modules/tracking/fast.routes.js';
import { weightRoutes } from './modules/tracking/weight.routes.js';
import { authPlugin } from './plugins/auth.js';
import { prismaPlugin } from './plugins/prisma.js';
@@ -46,6 +49,9 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(dbRoutes);
await app.register(meRoutes);
await app.register(profileRoutes);
await app.register(weightRoutes);
await app.register(fastRoutes);
await app.register(planRoutes);
if (env.SENTRY_DSN) {
app.setErrorHandler((err, request, reply) => {
+157
View File
@@ -0,0 +1,157 @@
import {
DEFAULT_MEAL_SHARE,
calculateBmr,
calculateTdee,
macrosForPhase,
matchMeals,
type RecipeCandidate,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
function startOfWeek(d: Date): Date {
const out = new Date(d);
const day = out.getDay(); // 0 = Sunday
const diff = day === 0 ? 6 : day - 1; // Monday-anchored week
out.setDate(out.getDate() - diff);
out.setHours(0, 0, 0, 0);
return out;
}
function phaseToInt(p: string): 1 | 2 | 3 {
if (p === 'INTENSIVE') return 1;
if (p === 'TRANSITION') return 2;
return 3;
}
export const planRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const profile = await fastify.prisma.profile.findUnique({
where: { userId },
include: { user: { include: { preferences: true } } },
});
if (!profile) return reply.code(409).send({ error: 'profile_required' });
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
},
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
const candidates: RecipeCandidate[] = recipes.map((r) => ({
id: r.id,
name: r.name,
category: r.category,
kcal: r.kcal,
proteinG: r.proteinG,
fatG: r.fatG,
netCarbG: r.netCarbG,
exclusionTags: [],
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
}));
const weightCurrentKg = Number(profile.weightCurrentKg);
const bmr = calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Deficit base solo in fase 1; in fase 2/3 si avvicina a TDEE.
const kcalTarget = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee);
const dailyTarget = macrosForPhase({ kcalTarget, weightKg: weightCurrentKg, phase: phaseInt });
const exclusions = profile.user.preferences?.exclusions ?? [];
const weekStart = startOfWeek(new Date());
const plan = await fastify.prisma.mealPlan.upsert({
where: { userId_weekStart: { userId, weekStart } },
create: { userId, weekStart },
update: { generatedAt: new Date(), status: 'ACTIVE' },
});
// Wipe existing slots before regenerating, in case the plan already existed.
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
for (let day = 0; day < 7; day++) {
// Per ogni pasto, le ricette già scelte negli ultimi 2 giorni sono "recenti".
const recentlyConsumedIds: string[] = plannedSlots
.filter((s) => day - s.day <= 2 && s.recipeId)
.map((s) => s.recipeId!);
for (const meal of MEALS) {
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
recentlyConsumedIds,
dailyTarget,
mealShare: DEFAULT_MEAL_SHARE,
topN: 5,
});
const selected = top[0];
await fastify.prisma.mealSlot.create({
data: {
planId: plan.id,
dayOfWeek: day,
meal,
recipeId: selected?.id ?? null,
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
},
});
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
}
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget,
},
});
});
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
const plan = await fastify.prisma.mealPlan.findFirst({
where: { userId: request.user!.id, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: { select: { id: true, name: true, kcal: true } },
alternatives: { select: { id: true, name: true, kcal: true } },
},
orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }],
},
},
});
if (!plan) return reply.code(404).send({ error: 'plan_not_found' });
return {
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
slots: plan.slots,
},
};
});
};
@@ -0,0 +1,73 @@
import {
PROTOCOL_DEFAULT_MINUTES,
fastEventStartSchema,
fastEventUpdateSchema,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => {
const events = await fastify.prisma.fastEvent.findMany({
where: { userId: request.user!.id },
orderBy: { startedAt: 'desc' },
take: 30,
});
return {
events: events.map((e) => ({
id: e.id,
protocol: e.protocol,
status: e.status,
startedAt: e.startedAt.toISOString(),
endedAt: e.endedAt?.toISOString() ?? null,
targetDuration: e.targetDuration,
symptoms: e.symptoms ? (JSON.parse(e.symptoms) as unknown) : null,
notes: e.notes,
})),
};
});
fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = fastEventStartSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const event = await fastify.prisma.fastEvent.create({
data: {
userId: request.user!.id,
protocol: data.protocol,
startedAt: data.startedAt ?? new Date(),
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
},
});
return reply.code(201).send({ event: { id: event.id } });
});
fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => {
const id = (request.params as { id: string }).id;
const parsed = fastEventUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
// Garantisce che l'utente possa aggiornare solo i propri eventi.
const owned = await fastify.prisma.fastEvent.findFirst({
where: { id, userId: request.user!.id },
select: { id: true },
});
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
const event = await fastify.prisma.fastEvent.update({
where: { id },
data: {
endedAt: data.endedAt ?? undefined,
status: data.status ?? undefined,
symptoms: data.symptoms ? JSON.stringify(data.symptoms) : undefined,
notes: data.notes ?? undefined,
},
});
return { event: { id: event.id, status: event.status } };
});
};
@@ -0,0 +1,64 @@
import { weightEntryInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => {
const entries = await fastify.prisma.weightEntry.findMany({
where: { userId: request.user!.id },
orderBy: { date: 'desc' },
take: 60,
});
return {
entries: entries.map((e) => ({
id: e.id,
date: e.date.toISOString().slice(0, 10),
weightKg: Number(e.weightKg),
measurements: e.measurements ? (JSON.parse(e.measurements) as unknown) : null,
notes: e.notes,
energy: e.energy,
sleep: e.sleep,
hunger: e.hunger,
photos: e.photos,
})),
};
});
fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = weightEntryInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
const entry = await fastify.prisma.weightEntry.upsert({
where: { userId_date: { userId, date: data.date } },
create: {
userId,
date: data.date,
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
update: {
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
});
return reply
.code(201)
.send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } });
});
};