feat: profile flow — Zod schema, /me/profile API, /profile page with BMR/TDEE

@ketopath/shared — new module profile/schema:
- profileInputSchema (Zod): age 18-110, gender, height 120-230 cm, weights
  35-300 kg, activityLevel, optional targetDate
- GENDERS / ACTIVITY_LEVELS string-literal arrays for UI iteration
- Re-exported from @ketopath/shared

apps/api — new module modules/profile:
- PUT /me/profile: requireAuth, validates body via profileInputSchema, upserts
  the row, returns the saved profile + derived { bmr, tdee, activityMultiplier }
  (BMR/TDEE computed via @ketopath/shared)
- GET /me/profile: requireAuth, returns the same shape, 404 when missing
- Decimal columns serialised back as numbers

apps/web — new /profile page:
- src/app/[locale]/profile/page.tsx (server): redirects unauthenticated users
  to /sign-in, calls fetchProfile to hydrate the form
- profile-form.tsx (client): react-hook-form + zodResolver bound to the same
  shared schema, native styled selects for gender/activityLevel until shadcn
  Select arrives, post-submit panel showing BMR/TDEE/multiplier
- actions.ts: server actions saveProfile / fetchProfile that proxy the call
  to API_URL via cookie passthrough (no CORS, no exposed token)
- Home page gains a "Completa il tuo profilo" CTA when signed in
- API_URL env var added to .env.example
- Italian copy in messages/it.json under Profile

Verified end-to-end with the "Michele" PRD persona (49, M, 170cm, 76kg, SEDENTARY):
BMR = 1583 kcal, TDEE = 1899 kcal, multiplier 1.2 — matches the unit tests.

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 14:20:07 +02:00
1 parent ac2227fdec
commit 1d904484e6
12 files changed
+398 -1

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ import { env } from './config/env.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
import { profileRoutes } from './modules/profile/profile.routes.js';
import { authPlugin } from './plugins/auth.js';
import { prismaPlugin } from './plugins/prisma.js';
@@ -43,6 +44,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(healthRoutes);
await app.register(dbRoutes);
await app.register(meRoutes);
await app.register(profileRoutes);
return app;
}
@@ -0,0 +1,97 @@
import {
ACTIVITY_MULTIPLIERS,
calculateBmr,
calculateTdee,
profileInputSchema,
type ActivityLevel,
type Gender,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
function serialize(
profile: {
age: number;
gender: Gender;
heightCm: number;
weightStartKg: { toNumber(): number };
weightCurrentKg: { toNumber(): number };
weightGoalKg: { toNumber(): number };
activityLevel: ActivityLevel;
targetDate: Date | null;
currentPhase: string;
} | null,
) {
if (!profile) return null;
const weightCurrentKg = profile.weightCurrentKg.toNumber();
const bmr = calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
const tdee = calculateTdee(bmr, profile.activityLevel);
return {
age: profile.age,
gender: profile.gender,
heightCm: profile.heightCm,
weightStartKg: profile.weightStartKg.toNumber(),
weightCurrentKg,
weightGoalKg: profile.weightGoalKg.toNumber(),
activityLevel: profile.activityLevel,
targetDate: profile.targetDate?.toISOString() ?? null,
currentPhase: profile.currentPhase,
derived: {
bmr: Math.round(bmr),
tdee: Math.round(tdee),
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
},
};
}
export const profileRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
const profile = await fastify.prisma.profile.findUnique({
where: { userId: request.user!.id },
});
if (!profile) return reply.code(404).send({ error: 'profile_not_found' });
return { profile: serialize(profile) };
});
fastify.put('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = profileInputSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const userId = request.user!.id;
const profile = await fastify.prisma.profile.upsert({
where: { userId },
create: {
userId,
age: data.age,
gender: data.gender,
heightCm: data.heightCm,
weightStartKg: data.weightStartKg,
weightCurrentKg: data.weightCurrentKg,
weightGoalKg: data.weightGoalKg,
activityLevel: data.activityLevel,
targetDate: data.targetDate,
},
update: {
age: data.age,
gender: data.gender,
heightCm: data.heightCm,
weightStartKg: data.weightStartKg,
weightCurrentKg: data.weightCurrentKg,
weightGoalKg: data.weightGoalKg,
activityLevel: data.activityLevel,
targetDate: data.targetDate ?? null,
},
});
return { profile: serialize(profile) };
});
};
+3
View File
@@ -1,6 +1,9 @@
# Next.js
NEXT_PUBLIC_APP_URL=http://localhost:3000
# API server URL (server-only — non esporre al browser)
API_URL=http://localhost:4000
# Better Auth (deve coincidere con apps/api/.env)
# Genera un secret a 32+ byte: `openssl rand -base64 32`
BETTER_AUTH_SECRET=
+32 -1
View File
@@ -6,7 +6,8 @@
"ctaSecondary": "Scopri come funziona",
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.",
"signedInAs": "Accesso effettuato come {name}",
"signOut": "Esci"
"signOut": "Esci",
"completeProfile": "Completa il tuo profilo"
},
"Metadata": {
"title": "KetoPath — Chetogenica e digiuno intermittente personalizzati",
@@ -40,5 +41,35 @@
"error": "Non è stato possibile completare la registrazione. Riprova.",
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
}
},
"Profile": {
"title": "Il tuo profilo",
"subtitle": "Inserisci i tuoi dati per calcolare BMR e TDEE personalizzati.",
"age": "Età",
"gender": "Sesso",
"heightCm": "Altezza (cm)",
"weightStartKg": "Peso iniziale (kg)",
"weightCurrentKg": "Peso attuale (kg)",
"weightGoalKg": "Peso obiettivo (kg)",
"activityLevel": "Livello di attività",
"selectPlaceholder": "Seleziona…",
"save": "Salva profilo",
"saving": "Salvataggio in corso…",
"summary": "Calcolo personalizzato",
"bmr": "Metabolismo basale (BMR)",
"tdee": "Fabbisogno totale (TDEE)",
"activityMultiplier": "Moltiplicatore attività",
"hint": "I valori sono stime basate sulla formula Mifflin-St Jeor. Non sostituiscono il parere di un nutrizionista.",
"genderOptions": {
"MALE": "Maschio",
"FEMALE": "Femmina",
"OTHER": "Altro / preferisco non specificare"
},
"activityOptions": {
"SEDENTARY": "Sedentario (lavoro d'ufficio)",
"LIGHT": "Leggero (sport 1-3 volte a settimana)",
"MODERATE": "Moderato (sport 3-5 volte a settimana)",
"INTENSE": "Intenso (sport 6+ volte a settimana)"
}
}
}
+3
View File
@@ -24,6 +24,9 @@ function HomeContent({ userName }: { userName: string | null }) {
{userName ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span>
<Button asChild>
<Link href="/profile">{t('completeProfile')}</Link>
</Button>
<SignOutButton label={t('signOut')} />
</div>
) : (
@@ -0,0 +1,43 @@
'use server';
import { profileInputSchema, type ProfileInput } from '@ketopath/shared';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
export type SaveProfileResult = { ok: true; profile: unknown } | { ok: false; error: string };
export async function saveProfile(input: ProfileInput): Promise<SaveProfileResult> {
const parsed = profileInputSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? 'invalid_input' };
}
const cookie = headers().get('cookie') ?? '';
const res = await fetch(`${API_URL}/me/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify(parsed.data),
cache: 'no-store',
});
if (!res.ok) {
return { ok: false, error: `api_error_${res.status}` };
}
const data = (await res.json()) as { profile: unknown };
return { ok: true, profile: data.profile };
}
export async function fetchProfile(): Promise<unknown | null> {
const cookie = headers().get('cookie') ?? '';
const res = await fetch(`${API_URL}/me/profile`, {
headers: { cookie },
cache: 'no-store',
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`api_error_${res.status}`);
const data = (await res.json()) as { profile: unknown };
return data.profile;
}
@@ -0,0 +1,41 @@
import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getServerSession } from '@/lib/auth';
import { fetchProfile } from './actions';
import { ProfileForm } from './profile-form';
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
return <ProfilePageContent initial={profile} />;
}
function ProfilePageContent({
initial,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
}) {
const t = useTranslations('Profile');
return (
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<ProfileForm initial={initial} />
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,155 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { ACTIVITY_LEVELS, GENDERS, profileInputSchema, type ProfileInput } from '@ketopath/shared';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { saveProfile } from './actions';
type Derived = { bmr: number; tdee: number; activityMultiplier: number };
type Profile = ProfileInput & { derived?: Derived };
export function ProfileForm({ initial }: { initial: Profile | null }) {
const t = useTranslations('Profile');
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
resolver: zodResolver(profileInputSchema),
defaultValues: initial
? {
age: initial.age,
gender: initial.gender,
heightCm: initial.heightCm,
weightStartKg: initial.weightStartKg,
weightCurrentKg: initial.weightCurrentKg,
weightGoalKg: initial.weightGoalKg,
activityLevel: initial.activityLevel,
}
: undefined,
});
const onSubmit = handleSubmit(async (input) => {
setServerError(null);
const result = await saveProfile(input);
if (!result.ok) {
setServerError(result.error);
return;
}
const profile = result.profile as Profile;
if (profile.derived) setSaved(profile.derived);
router.refresh();
});
return (
<form onSubmit={onSubmit} className="space-y-5" noValidate>
<div className="grid grid-cols-2 gap-4">
<Field label={t('age')} error={errors.age?.message}>
<Input type="number" inputMode="numeric" {...register('age')} />
</Field>
<Field label={t('gender')} error={errors.gender?.message}>
<select
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
{...register('gender')}
>
<option value="">{t('selectPlaceholder')}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{t(`genderOptions.${g}`)}
</option>
))}
</select>
</Field>
</div>
<Field label={t('heightCm')} error={errors.heightCm?.message}>
<Input type="number" inputMode="numeric" {...register('heightCm')} />
</Field>
<div className="grid grid-cols-3 gap-4">
<Field label={t('weightStartKg')} error={errors.weightStartKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightStartKg')} />
</Field>
<Field label={t('weightCurrentKg')} error={errors.weightCurrentKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightCurrentKg')} />
</Field>
<Field label={t('weightGoalKg')} error={errors.weightGoalKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightGoalKg')} />
</Field>
</div>
<Field label={t('activityLevel')} error={errors.activityLevel?.message}>
<select
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
{...register('activityLevel')}
>
<option value="">{t('selectPlaceholder')}</option>
{ACTIVITY_LEVELS.map((a) => (
<option key={a} value={a}>
{t(`activityOptions.${a}`)}
</option>
))}
</select>
</Field>
{serverError ? (
<p role="alert" className="text-destructive text-sm">
{serverError}
</p>
) : null}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? t('saving') : t('save')}
</Button>
{saved ? (
<div className="bg-secondary mt-6 rounded-md p-4 text-sm">
<p className="font-medium">{t('summary')}</p>
<ul className="mt-2 space-y-1">
<li>
{t('bmr')}: <span className="font-mono">{saved.bmr} kcal</span>
</li>
<li>
{t('tdee')}: <span className="font-mono">{saved.tdee} kcal</span>
</li>
<li>
{t('activityMultiplier')}:{' '}
<span className="font-mono">{saved.activityMultiplier}</span>
</li>
</ul>
<p className="text-muted-foreground mt-2 text-xs">{t('hint')}</p>
</div>
) : null}
</form>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error ? <p className="text-destructive text-xs">{error}</p> : null}
</div>
);
}