feat: preferences, exclusion-aware planner, /onboarding (PRD §5.1, §3)

Preferences (PRD §5.1)
- @ketopath/shared: zod schemas + EXCLUSION_GROUPS, CUISINE_TAGS,
  COOKING_TIMES, FASTING_PROTOCOL_VALUES
- apps/api: GET/PATCH /me/preferences (exclusions, cuisinePreferences,
  cookingTime, fastingProtocol)
- apps/web: PreferencesForm chip-based + PreferencesSection wired into
  /profile so existing users can edit
- Plan generation now aggregates Ingredient.exclusionGroups → Recipe
  exclusionTags so matchMeals filters out excluded recipes

Onboarding (PRD §3)
- /onboarding multi-step (profile → preferences → done) with smart-jump:
  starts at the first incomplete step
- ProfileForm gains optional onSaved/saveLabel for reuse outside /profile
- Redirect chain updated: /welcome accept → /onboarding (was /profile);
  /plan, /tracking, /shopping redirect missing profiles to /onboarding
- /profile remains the editing surface for returning users

i18n
- New Onboarding + Preferences namespaces (it)

Smoke tested end-to-end: selected fish exclusion, regenerated the plan,
verified Salmone/Branzino/Tonno are filtered out and unaffected recipes
(Frittata, Pollo, Mandorle, Polpette) remain.

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 22:14:50 +02:00
1 parent cf7361f735
commit 624152b29b
18 files changed
+864 -22

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
import { notificationsRoutes } from './modules/notifications/notifications.routes.js';
import { planRoutes } from './modules/plan/plan.routes.js';
import { preferencesRoutes } from './modules/preferences/preferences.routes.js';
import { profileRoutes } from './modules/profile/profile.routes.js';
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
import { fastRoutes } from './modules/tracking/fast.routes.js';
@@ -51,6 +52,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(dbRoutes);
await app.register(meRoutes);
await app.register(profileRoutes);
await app.register(preferencesRoutes);
await app.register(weightRoutes);
await app.register(fastRoutes);
await app.register(planRoutes);
+20 -11
View File
@@ -47,23 +47,32 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
fatG: true,
netCarbG: true,
phases: true,
ingredients: {
select: { ingredient: { select: { exclusionGroups: 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 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);
}
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),
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
};
});
const weightCurrentKg = Number(profile.weightCurrentKg);
const bmr = calculateBmr({
@@ -0,0 +1,68 @@
// PRD §5.1 — preferences utente: esclusioni, cucine, tempo di cottura,
// protocollo digiuno default. Le esclusioni filtrano il matchmaking ricette.
import { preferencesPatchSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/preferences', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const prefs = await fastify.prisma.preferences.findUnique({ where: { userId } });
return {
preferences: prefs
? {
exclusions: prefs.exclusions,
cuisinePreferences: prefs.cuisinePreferences,
cookingTime: prefs.cookingTime,
fastingProtocol: prefs.fastingProtocol,
}
: {
exclusions: [],
cuisinePreferences: [],
cookingTime: 'MEDIUM',
fastingProtocol: null,
},
};
});
fastify.patch('/me/preferences', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = preferencesPatchSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const userId = request.user!.id;
const data = parsed.data;
// Costruiamo il diff: con `exactOptionalPropertyTypes` Prisma rifiuta i
// campi `undefined` espliciti.
const create: Record<string, unknown> = {
userId,
exclusions: data.exclusions ?? [],
cuisinePreferences: data.cuisinePreferences ?? [],
cookingTime: data.cookingTime ?? 'MEDIUM',
fastingProtocol: data.fastingProtocol ?? null,
};
const update: Record<string, unknown> = {};
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
if (data.cuisinePreferences !== undefined) update.cuisinePreferences = data.cuisinePreferences;
if (data.cookingTime !== undefined) update.cookingTime = data.cookingTime;
if (data.fastingProtocol !== undefined) update.fastingProtocol = data.fastingProtocol;
const prefs = await fastify.prisma.preferences.upsert({
where: { userId },
create: create as never,
update,
});
return {
preferences: {
exclusions: prefs.exclusions,
cuisinePreferences: prefs.cuisinePreferences,
cookingTime: prefs.cookingTime,
fastingProtocol: prefs.fastingProtocol,
},
};
});
};