From 1d904484e60b029279cf7c6f5ce5e5d593f7120e Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 14:20:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20profile=20flow=20=E2=80=94=20Zod=20sche?= =?UTF-8?q?ma,=20/me/profile=20API,=20/profile=20page=20with=20BMR/TDEE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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) --- apps/api/src/app.ts | 2 + .../api/src/modules/profile/profile.routes.ts | 97 +++++++++++ apps/web/.env.example | 3 + apps/web/messages/it.json | 33 +++- apps/web/src/app/[locale]/page.tsx | 3 + apps/web/src/app/[locale]/profile/actions.ts | 43 +++++ apps/web/src/app/[locale]/profile/page.tsx | 41 +++++ .../src/app/[locale]/profile/profile-form.tsx | 155 ++++++++++++++++++ packages/shared/package.json | 3 + packages/shared/src/index.ts | 1 + packages/shared/src/profile/schema.ts | 18 ++ pnpm-lock.yaml | Bin 223685 -> 223766 bytes 12 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/profile/profile.routes.ts create mode 100644 apps/web/src/app/[locale]/profile/actions.ts create mode 100644 apps/web/src/app/[locale]/profile/page.tsx create mode 100644 apps/web/src/app/[locale]/profile/profile-form.tsx create mode 100644 packages/shared/src/profile/schema.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b393115..a11c472 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 { await app.register(healthRoutes); await app.register(dbRoutes); await app.register(meRoutes); + await app.register(profileRoutes); return app; } diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts new file mode 100644 index 0000000..e878ad0 --- /dev/null +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -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) }; + }); +}; diff --git a/apps/web/.env.example b/apps/web/.env.example index 175aa5c..ae0e216 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 42e9d21..65dd103 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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)" + } } } diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 09a9261..2e7c7ef 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -24,6 +24,9 @@ function HomeContent({ userName }: { userName: string | null }) { {userName ? (
{t('signedInAs', { name: userName })} +
) : ( diff --git a/apps/web/src/app/[locale]/profile/actions.ts b/apps/web/src/app/[locale]/profile/actions.ts new file mode 100644 index 0000000..933d77d --- /dev/null +++ b/apps/web/src/app/[locale]/profile/actions.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx new file mode 100644 index 0000000..4e024eb --- /dev/null +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -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[0]['initial']; + + return ; +} + +function ProfilePageContent({ + initial, +}: { + initial: Parameters[0]['initial']; +}) { + const t = useTranslations('Profile'); + + return ( +
+ + + {t('title')} + {t('subtitle')} + + + + + +
+ ); +} diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx new file mode 100644 index 0000000..4e77f4f --- /dev/null +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -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(null); + const [saved, setSaved] = useState(initial?.derived ?? null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( +
+
+ + + + + + +
+ + + + + +
+ + + + + + + + + +
+ + + + + + {serverError ? ( +

+ {serverError} +

+ ) : null} + + + + {saved ? ( +
+

{t('summary')}

+
    +
  • + {t('bmr')}: {saved.bmr} kcal +
  • +
  • + {t('tdee')}: {saved.tdee} kcal +
  • +
  • + {t('activityMultiplier')}:{' '} + {saved.activityMultiplier} +
  • +
+

{t('hint')}

+
+ ) : null} +
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error ?

{error}

: null} +
+ ); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 0db2b1a..aee1762 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,6 +14,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "zod": "^3.23.8" + }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", "@ketopath/tsconfig": "workspace:*" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6fac531..3ad18de 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './nutrition/bmr.js'; export * from './nutrition/tdee.js'; export * from './nutrition/types.js'; +export * from './profile/schema.js'; diff --git a/packages/shared/src/profile/schema.ts b/packages/shared/src/profile/schema.ts new file mode 100644 index 0000000..f4b50e6 --- /dev/null +++ b/packages/shared/src/profile/schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const; +export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const; + +export const profileInputSchema = z.object({ + age: z.coerce.number().int().min(18, 'Devi avere almeno 18 anni').max(110), + gender: z.enum(GENDERS), + heightCm: z.coerce.number().int().min(120).max(230), + weightStartKg: z.coerce.number().min(35).max(300), + weightCurrentKg: z.coerce.number().min(35).max(300), + weightGoalKg: z.coerce.number().min(35).max(300), + activityLevel: z.enum(ACTIVITY_LEVELS), + targetDate: z.coerce.date().optional(), +}); + +export type ProfileInput = z.input; +export type ProfileInputParsed = z.output; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0ef98bb3369e67e1a77022f5509c4f28a5faa28..74459b838b96fac1f420140d3bc08416403cd622 100644 GIT binary patch delta 35 rcmX?lnRnV1-VM4k(|5)(2~9r2xqR|&sq7}%Hd)3tS*C5W%tw6y4DAhp delta 37 scmbPsh4<)X-VM4k6HCP>`-^i