- 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>
687 lines
24 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
});
|
|
};
|