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:
1 parent
ac2227fdec
commit
1d904484e6
12 files changed
+398
-1
No files matched your search
@@ -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) };
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user