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:
1 parent
cf7361f735
commit
624152b29b
18 files changed
+864
-22
No files matched your search
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user