Files
ketopath/apps/api/src/modules/plan/plan.routes.ts
T
lucianoandClaude Opus 4.7 d17c5ef869 feat: §6 Gruppo B residuo — pannello macros, esclusioni granulari, achievement
- Plan: pannello macros laterale "oggi" con consumati/pianificati vs target (kcal/P/F/C). Sticky su lg, in-flow su mobile. Profile/derived ora include kcalTarget e i macro target (BMR aggiustato per condizioni e diet history).
- Preferenze: nuova sezione "Ingredienti specifici da evitare" con ricerca debounced sul catalogo (cap 50). Schema Preferences.bannedIngredientIds + matchmaking che scarta ricette contenenti ingredient bandito (test unit).
- Achievement: tabella Achievement (userId+key unique), 4 traguardi iniziali (first_weigh_in, first_plan, first_fast_complete, ten_meals_consumed). Service idempotente eval+persist agganciato a 4 trigger (weight POST, plan POST, fast PATCH→COMPLETED, slot consumed). Push notification best-effort all'unlock. Pannello badge nel profilo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:45:30 +02:00

687 lines
24 KiB
TypeScript

import {
PROTOCOL_DEFAULT_MINUTES,
bmrAdjustForDietHistory,
bmrAdjustmentForConditions,
calculateBmr,
calculateBmrKatchMcArdle,
calculateTdee,
computeDailyKcalTarget,
currentPhase2Week,
extraKcalForSession,
hasExcludingCondition,
isRecipeAllowedForPhaseWeek,
isTrainingDay,
macrosForPhase,
matchMeals,
maxPrepMinutesFor,
mealShareForFrequency,
protocolPlanForDay,
type DietHistory,
type FastingProtocolKey,
type RecipeCandidate,
type TrainingType,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.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;
}
// PRD §6.3 — il "pasto finale" della giornata determina quando far partire
// auto il digiuno. Ordine canonico: COLAZIONE, PRANZO, SPUNTINO, CENA. Se uno
// di questi manca dal giorno (per protocollo IF), l'ultimo presente conta.
const MEAL_ORDER_MAP: Record<string, number> = {
COLAZIONE: 0,
PRANZO: 1,
SPUNTINO: 2,
CENA: 3,
};
function isMealLastOfDay(meal: string, daySlots: ReadonlyArray<{ meal: string }>): boolean {
const myRank = MEAL_ORDER_MAP[meal] ?? -1;
const otherRanks = daySlots
.filter((s) => s.meal !== meal)
.map((s) => MEAL_ORDER_MAP[s.meal] ?? -1);
return otherRanks.every((r) => r < myRank);
}
function parseConditions(raw: string | null): string[] {
if (!raw) return [];
try {
const v = JSON.parse(raw) as unknown;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
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,
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
},
},
},
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
// che contengono ingredienti non ancora reintrodotti per la settimana
// corrente di fase 2.
const phaseIntForFilter = phaseToInt(profile.currentPhase);
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
const allowedRecipes = recipes.filter((r) =>
isRecipeAllowedForPhaseWeek(
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
phaseIntForFilter,
phase2WeekForFilter,
),
);
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
}
return {
id: r.id,
name: r.name,
category: r.category,
kcal: r.kcal,
proteinG: r.proteinG,
fatG: r.fatG,
netCarbG: r.netCarbG,
exclusionTags: Array.from(tags),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
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++) {
const dayPlan = protocolPlanForDay(fastingProtocol, day);
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
// Ricette 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) {
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes,
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 });
}
}
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
});
fastify.patch(
'/me/meal-plans/slots/:slotId',
{ preHandler: requireAuth() },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const body = request.body as { recipeId?: unknown };
if (typeof body.recipeId !== 'string') {
return reply.code(400).send({ error: 'invalid_body' });
}
const userId = request.user!.id;
const slot = await fastify.prisma.mealSlot.findFirst({
where: { id: slotId, plan: { userId } },
include: { alternatives: { select: { id: true } } },
});
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
const allowedIds = new Set([slot.recipeId, ...slot.alternatives.map((a) => a.id)]);
if (!allowedIds.has(body.recipeId)) {
return reply.code(400).send({ error: 'recipe_not_in_alternatives' });
}
const updated = await fastify.prisma.mealSlot.update({
where: { id: slotId },
data: { recipeId: body.recipeId },
include: {
selected: { select: { id: true, name: true, kcal: true } },
},
});
return { slot: updated };
},
);
// PRD §5.2 — aderenza: l'utente segna lo slot come consumato.
// PRD §6.3 — se è l'ultimo pasto della giornata e l'utente ha un protocollo
// di digiuno attivo nelle preferenze, e nessuna sessione è in corso, parte
// automaticamente un nuovo digiuno.
fastify.post(
'/me/meal-plans/slots/:slotId/consumed',
{ preHandler: requireAuth() },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
const body = (request.body ?? {}) as { consumed?: unknown };
const desired = typeof body.consumed === 'boolean' ? body.consumed : null;
const slot = await fastify.prisma.mealSlot.findFirst({
where: { id: slotId, plan: { userId } },
select: { id: true, consumed: true, dayOfWeek: true, meal: true, planId: true },
});
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
const next = desired ?? !slot.consumed;
const updated = await fastify.prisma.mealSlot.update({
where: { id: slotId },
data: { consumed: next, consumedAt: next ? new Date() : null },
select: { id: true, consumed: true, consumedAt: true },
});
// Auto-start digiuno se è l'ultimo pasto del giorno consumato.
let autoStartedFast: { id: string } | null = null;
if (next) {
const sameDaySlots = await fastify.prisma.mealSlot.findMany({
where: { planId: slot.planId, dayOfWeek: slot.dayOfWeek },
select: { id: true, meal: true },
orderBy: { meal: 'asc' },
});
const isLastOfDay = isMealLastOfDay(slot.meal, sameDaySlots);
if (isLastOfDay) {
const prefs = await fastify.prisma.preferences.findUnique({
where: { userId },
select: { fastingProtocol: true },
});
if (prefs?.fastingProtocol) {
const active = await fastify.prisma.fastEvent.findFirst({
where: { userId, status: 'IN_PROGRESS' },
select: { id: true },
});
if (!active) {
const minutes =
PROTOCOL_DEFAULT_MINUTES[
prefs.fastingProtocol as keyof typeof PROTOCOL_DEFAULT_MINUTES
] ?? 16 * 60;
const created = await fastify.prisma.fastEvent.create({
data: {
userId,
protocol: prefs.fastingProtocol,
startedAt: new Date(),
targetDuration: minutes,
},
});
autoStartedFast = { id: created.id };
}
}
}
}
let newlyUnlocked: string[] = [];
if (next) {
const ach = await evaluateAndPersist(fastify.prisma, userId);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
}
return { slot: updated, autoStartedFast, newlyUnlocked };
},
);
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
fastify.post(
'/me/meal-plans/slots/:slotId/free-meal',
{ preHandler: requireAuth() },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
const slot = await fastify.prisma.mealSlot.findFirst({
where: { id: slotId, plan: { userId } },
include: { plan: true },
});
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
const profile = await fastify.prisma.profile.findUnique({ where: { userId } });
if (!profile) return reply.code(409).send({ error: 'profile_required' });
if (profile.currentPhase !== 'MAINTENANCE') {
return reply.code(409).send({ error: 'free_meal_phase3_only' });
}
const updated = await fastify.prisma.mealSlot.update({
where: { id: slotId },
data: { isFreeMeal: !slot.isFreeMeal },
});
return { slot: { id: updated.id, isFreeMeal: updated.isFreeMeal } };
},
);
// PRD §5.1 — rigenera UN solo slot: ricalcola le top5 ricette tenendo
// conto dei pasti del giorno già scelti (coerenza dei macros).
fastify.post(
'/me/meal-plans/slots/:slotId/regenerate',
{ preHandler: requireAuth() },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
const slot = await fastify.prisma.mealSlot.findFirst({
where: { id: slotId, plan: { userId } },
include: {
plan: {
include: {
slots: {
include: {
selected: {
select: {
id: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
},
},
},
},
},
},
},
});
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
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 conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: {
select: { ingredient: { select: { id: true, exclusionGroups: true } } },
},
},
});
const candidates: RecipeCandidate[] = recipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
}
return {
id: r.id,
name: r.name,
category: r.category,
kcal: r.kcal,
proteinG: r.proteinG,
fatG: r.fatG,
netCarbG: r.netCarbG,
exclusionTags: Array.from(tags),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ??
null;
const dayPlan = protocolPlanForDay(fastingProtocol, slot.dayOfWeek);
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Allenamento — extra del giorno se è training day.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const trainingExtra =
isTrainingDay(slot.dayOfWeek, trainingDays) && trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
weightKg: weightCurrentKg,
})
: 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
// Macros già consumati negli altri pasti dello stesso giorno.
const sameDay = slot.plan.slots.filter(
(s) => s.dayOfWeek === slot.dayOfWeek && s.id !== slot.id && s.selected,
);
const consumedSoFar = sameDay.reduce(
(acc, s) => ({
kcal: acc.kcal + (s.selected?.kcal ?? 0),
proteinG: acc.proteinG + (s.selected?.proteinG ?? 0),
fatG: acc.fatG + (s.selected?.fatG ?? 0),
netCarbG: acc.netCarbG + (s.selected?.netCarbG ?? 0),
}),
{ kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 },
);
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
// Le ricette già scelte negli ultimi 2 giorni + lo slot corrente sono "recenti".
const recentlyConsumedIds = slot.plan.slots
.filter((s) => slot.dayOfWeek - s.dayOfWeek <= 2 && s.id !== slot.id && s.recipeId != null)
.map((s) => s.recipeId as string);
// Aggiungo anche la ricetta attualmente scelta in questo slot per
// forzare il matchmaking a proporne un'altra al primo posto.
if (slot.recipeId) recentlyConsumedIds.push(slot.recipeId);
const top = matchMeals({
candidates,
meal: slot.meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
consumedSoFar,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes: maxPrepMinutesFor(cookingTime),
topN: 5,
});
if (top.length === 0) {
return reply.code(409).send({ error: 'no_recipes_available' });
}
const newSelected = top[0]!;
const altIds = top.slice(1).map((r) => r.id);
// Re-set alternatives via Prisma `set` per pulire la M2M.
await fastify.prisma.mealSlot.update({
where: { id: slotId },
data: {
recipeId: newSelected.id,
alternatives: { set: altIds.map((id) => ({ id })) },
},
});
return reply.code(200).send({
slot: { id: slotId, recipeId: newSelected.id, alternativeIds: altIds },
});
},
);
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const [plan, prefs, profileForPhase, userForPhase2] = await Promise.all([
fastify.prisma.mealPlan.findFirst({
where: { userId, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: {
select: {
id: true,
name: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
prepMinutes: true,
},
},
alternatives: {
select: {
id: true,
name: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
prepMinutes: true,
},
},
},
orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }],
},
},
}),
fastify.prisma.preferences.findUnique({
where: { userId },
select: { fastingProtocol: true },
}),
fastify.prisma.profile.findUnique({
where: { userId },
select: { currentPhase: true },
}),
fastify.prisma.user.findUnique({
where: { id: userId },
select: { phase2StartedAt: true },
}),
]);
if (!plan) return reply.code(404).send({ error: 'plan_not_found' });
const currentPhase = profileForPhase?.currentPhase ?? null;
const phase2Week =
currentPhase === 'TRANSITION'
? currentPhase2Week(userForPhase2?.phase2StartedAt ?? null)
: null;
return {
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
slots: plan.slots,
fastingProtocol: prefs?.fastingProtocol ?? null,
currentPhase,
phase2Week,
},
};
});
};