apps/web (@sentry/nextjs): - sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts initialize Sentry only when NEXT_PUBLIC_SENTRY_DSN (or SENTRY_DSN on the server) is set; tracesSampleRate 0.1 - next.config.mjs wraps the existing config with withSentryConfig only when a DSN is present; source-map upload stays disabled until SENTRY_AUTH_TOKEN is provided - .env.example documents NEXT_PUBLIC_SENTRY_DSN and the optional auth token apps/api (@sentry/node): - src/lib/sentry.ts initializes Sentry at module load when SENTRY_DSN is set - server.ts imports sentry.ts as the very first side-effect so early-boot errors (env validation, plugin registration) reach Sentry - env schema gains optional SENTRY_DSN (URL) - app.ts registers an errorHandler that captures the exception with the authenticated user when SENTRY_DSN is set; logs and re-sends as before Build-time housekeeping: - profile route: targetDate ?? null on create to satisfy Prisma's input shape under exactOptionalPropertyTypes - profile form: useForm receives defaultValues only when initial is provided (spread instead of `defaultValues: undefined`); Field error prop typed `string | undefined` for exactOptionalPropertyTypes Without a DSN both apps run unchanged (Sentry is inert). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
2.9 KiB
TypeScript
98 lines
2.9 KiB
TypeScript
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 ?? null,
|
|
},
|
|
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) };
|
|
});
|
|
};
|