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>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-06 15:45:30 +02:00
1 parent 3b895191f5
commit d17c5ef869
29 files changed
+1048 -86

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { env } from './config/env.js';
import { Sentry } from './lib/sentry.js';
import { achievementsRoutes } from './modules/achievements/achievements.routes.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { gdprRoutes } from './modules/me/gdpr.routes.js';
@@ -66,6 +67,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(planExportRoutes);
await app.register(shoppingRoutes);
await app.register(notificationsRoutes);
await app.register(achievementsRoutes);
if (env.SENTRY_DSN) {
app.setErrorHandler((err, request, reply) => {
@@ -0,0 +1,24 @@
// PRD §6 — route achievement: lista degli sbloccati per l'utente, con i
// "locked" inferiti lato client dal catalogo statico.
import { ACHIEVEMENT_KEYS, type AchievementKey } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const achievementsRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/achievements', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const rows = await fastify.prisma.achievement.findMany({
where: { userId },
orderBy: { unlockedAt: 'desc' },
select: { key: true, unlockedAt: true },
});
// Filtra eventuali key legacy non più nel catalogo: niente errori UI.
const validKeys = new Set<string>(ACHIEVEMENT_KEYS);
const unlocked = rows
.filter((r) => validKeys.has(r.key))
.map((r) => ({ key: r.key as AchievementKey, unlockedAt: r.unlockedAt.toISOString() }));
return { unlocked };
});
};
@@ -0,0 +1,97 @@
// PRD §6 — service achievement: orchestra l'eval e la persistenza.
// Idempotente: rigirare la funzione senza nuovi eventi non duplica nulla
// e non emette notifiche. Pensato per essere chiamato dopo i 4 trigger:
// weight save, plan create, fast complete, slot consume.
import type { ExtendedPrismaClient } from '@ketopath/db';
import { evaluateAchievements, type AchievementKey } from '@ketopath/shared';
import { pushIsEnabled, sendToDevice } from '../notifications/sender.js';
interface UnlockOutput {
newlyUnlocked: AchievementKey[];
}
export async function evaluateAndPersist(
prisma: ExtendedPrismaClient,
userId: string,
): Promise<UnlockOutput> {
// Misuriamo le metriche correnti in parallelo. Niente filtri sulla data:
// è uno snapshot. La unique constraint su (userId, key) ci protegge dai
// doppioni quando l'utente innesca trigger ripetuti.
const [weightEntryCount, planCount, completedFastCount, consumedMealCount] = await Promise.all([
prisma.weightEntry.count({ where: { userId } }),
prisma.mealPlan.count({ where: { userId } }),
prisma.fastEvent.count({ where: { userId, status: 'COMPLETED' } }),
prisma.mealSlot.count({ where: { plan: { userId }, consumed: true } }),
]);
const should = evaluateAchievements({
weightEntryCount,
planCount,
completedFastCount,
consumedMealCount,
});
if (should.length === 0) return { newlyUnlocked: [] };
const existing = await prisma.achievement.findMany({
where: { userId, key: { in: should } },
select: { key: true },
});
const have = new Set(existing.map((a) => a.key));
const newlyUnlocked = should.filter((k) => !have.has(k));
if (newlyUnlocked.length === 0) return { newlyUnlocked: [] };
// createMany con skipDuplicates: race-safe contro chiamate concorrenti.
await prisma.achievement.createMany({
data: newlyUnlocked.map((key) => ({ userId, key })),
skipDuplicates: true,
});
return { newlyUnlocked };
}
const ACHIEVEMENT_PUSH_COPY: Record<AchievementKey, { title: string; body: string }> = {
first_weigh_in: {
title: 'Achievement sbloccato: Prima pesata',
body: 'Hai messo il primo punto sulla mappa. Ben fatto.',
},
first_plan: {
title: 'Achievement sbloccato: Primo piano',
body: 'Hai il tuo piano della settimana. Si parte.',
},
first_fast_complete: {
title: 'Achievement sbloccato: Primo digiuno',
body: 'Sessione completata. Costanza è la chiave.',
},
ten_meals_consumed: {
title: 'Achievement sbloccato: 10 pasti',
body: 'Dieci pasti spuntati. La routine sta diventando un ritmo.',
},
};
// Best-effort: non blocchiamo la response principale se push fallisce.
export async function notifyUnlocked(
prisma: ExtendedPrismaClient,
userId: string,
keys: AchievementKey[],
): Promise<void> {
if (keys.length === 0 || !pushIsEnabled()) return;
const tokens = await prisma.deviceToken.findMany({ where: { userId } });
if (tokens.length === 0) return;
for (const key of keys) {
const copy = ACHIEVEMENT_PUSH_COPY[key];
for (const token of tokens) {
const r = await sendToDevice(token, {
title: copy.title,
body: copy.body,
url: '/profile',
});
if (r.expired) {
await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => {
/* race */
});
}
}
}
}
+38 -4
View File
@@ -24,6 +24,7 @@ import {
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;
@@ -93,7 +94,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { exclusionGroups: true, phase2Week: true } },
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
},
},
},
@@ -117,8 +118,10 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
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,
@@ -129,6 +132,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
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,
};
@@ -186,6 +190,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
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 =
@@ -239,6 +244,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
@@ -259,6 +265,13 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
}
}
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,
@@ -266,6 +279,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
});
@@ -366,7 +380,18 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
}
}
return { slot: updated, autoStartedFast };
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 };
},
);
@@ -450,13 +475,19 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: { select: { ingredient: { select: { exclusionGroups: true } } } },
ingredients: {
select: { ingredient: { select: { id: true, exclusionGroups: true } } },
},
},
});
const candidates: RecipeCandidate[] = recipes.map((r) => {
const tags = new Set<string>();
for (const ri of r.ingredients) for (const g of ri.ingredient.exclusionGroups) tags.add(g);
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,
@@ -466,6 +497,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
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,
};
@@ -536,6 +568,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
);
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;
@@ -553,6 +586,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
meal: slot.meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
consumedSoFar,
dailyTarget,
@@ -15,6 +15,7 @@ function serialize(prefs: {
trainingType: string | null;
sessionMinutes: number | null;
mealsPerDay: number | null;
bannedIngredientIds: string[];
}) {
return {
exclusions: prefs.exclusions,
@@ -25,6 +26,7 @@ function serialize(prefs: {
trainingType: prefs.trainingType,
sessionMinutes: prefs.sessionMinutes,
mealsPerDay: prefs.mealsPerDay,
bannedIngredientIds: prefs.bannedIngredientIds,
};
}
@@ -37,6 +39,7 @@ const EMPTY_PREFS = {
trainingType: null,
sessionMinutes: null,
mealsPerDay: null,
bannedIngredientIds: [],
};
export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
@@ -64,6 +67,7 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
trainingType: data.trainingType ?? null,
sessionMinutes: data.sessionMinutes ?? null,
mealsPerDay: data.mealsPerDay ?? null,
bannedIngredientIds: data.bannedIngredientIds ?? [],
};
const update: Record<string, unknown> = {};
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
@@ -74,6 +78,8 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
if (data.trainingType !== undefined) update.trainingType = data.trainingType;
if (data.sessionMinutes !== undefined) update.sessionMinutes = data.sessionMinutes;
if (data.mealsPerDay !== undefined) update.mealsPerDay = data.mealsPerDay;
if (data.bannedIngredientIds !== undefined)
update.bannedIngredientIds = data.bannedIngredientIds;
const prefs = await fastify.prisma.preferences.upsert({
where: { userId },
@@ -83,4 +89,33 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
return { preferences: serialize(prefs) };
});
// PRD §6 — catalogo ingredienti per la UI di esclusioni granulari.
// Ricerca case-insensitive per nome, oppure filtro per ids (per resolvere
// i bannati attualmente salvati). Max 50 risultati.
fastify.get('/me/ingredients', { preHandler: requireAuth() }, async (request) => {
const query = request.query as { q?: string; ids?: string };
const q = query.q?.trim() ?? '';
const idsRaw = query.ids?.trim() ?? '';
const where =
idsRaw.length > 0
? {
id: {
in: idsRaw
.split(',')
.filter((s) => s.length > 0)
.slice(0, 50),
},
}
: q.length > 0
? { name: { contains: q, mode: 'insensitive' as const } }
: {};
const ingredients = await fastify.prisma.ingredient.findMany({
where,
select: { id: true, name: true, category: true, exclusionGroups: true },
orderBy: { name: 'asc' },
take: 50,
});
return { ingredients };
});
};
+27 -2
View File
@@ -1,11 +1,16 @@
import {
ACTIVITY_MULTIPLIERS,
bmrAdjustForDietHistory,
bmrAdjustmentForConditions,
calculateBmr,
calculateBmrKatchMcArdle,
calculateTdee,
computeDailyKcalTarget,
estimateBodyFatPercentageUSNavy,
macrosForPhase,
profileInputSchema,
type ActivityLevel,
type DietHistory,
type Gender,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
@@ -36,7 +41,7 @@ function serialize(
if (!profile) return null;
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
const bmr = bodyFatPct
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
@@ -44,7 +49,23 @@ function serialize(
ageYears: profile.age,
gender: profile.gender,
});
const conditions = parseConditions(profile.medicalConditions);
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt =
profile.currentPhase === 'INTENSIVE' ? 1 : profile.currentPhase === 'TRANSITION' ? 2 : 3;
const { kcalTarget } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const macroTarget = macrosForPhase({
kcalTarget,
weightKg: weightCurrentKg,
phase: phaseInt,
});
return {
age: profile.age,
gender: profile.gender,
@@ -55,7 +76,7 @@ function serialize(
activityLevel: profile.activityLevel,
targetDate: profile.targetDate,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
medicalConditions: parseConditions(profile.medicalConditions),
medicalConditions: conditions,
bodyFatPct,
alertWeightKg: profile.alertWeightKg ? Number(profile.alertWeightKg) : null,
dietHistory: profile.dietHistory,
@@ -65,6 +86,10 @@ function serialize(
tdee: Math.round(tdee),
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
bmrFormula: bodyFatPct ? 'katch_mcardle' : 'mifflin_st_jeor',
kcalTarget: Math.round(kcalTarget),
proteinTargetG: Math.round(macroTarget.proteinG),
fatTargetG: Math.round(macroTarget.fatG),
netCarbTargetG: Math.round(macroTarget.netCarbG),
},
};
}
+12 -1
View File
@@ -6,6 +6,7 @@ import {
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => {
@@ -71,7 +72,17 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
where: { id },
data: updateData,
});
return { event: { id: event.id, status: event.status } };
let newlyUnlocked: string[] = [];
if (event.status === 'COMPLETED') {
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
}
return { event: { id: event.id, status: event.status }, newlyUnlocked };
});
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
+12 -3
View File
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => {
@@ -57,8 +58,16 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => {
},
});
return reply
.code(201)
.send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } });
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({
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
newlyUnlocked: ach.newlyUnlocked,
});
});
};