feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004)

Chiude la decisione "payment provider" aperta in CLAUDE.md.

**Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo.

**Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe.

**Backend** (`apps/api/src/modules/billing/`):
- `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining)
- `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection)
- `POST /me/billing/portal` — Customer Portal Session
- `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed`
- Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking)

**Shared** (`@ketopath/shared/billing/pro-status`):
- `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo)
- `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI
- `computeTrialEndsAt(signupAt, days=30)`
- 11 unit test

**Frontend** (`apps/web/src/app/[locale]/billing/`):
- Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits
- `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro)
- Nav item "Abbonamento" (chapter VI) nel grid asimmetrico
- i18n IT completo (`Billing` namespace)

**Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app.

99/99 test verdi, lint pulito su tutto il monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-07 11:27:06 +02:00
1 parent 41b3646325
commit f455cbe499
28 files changed
+2035 -486

No files matched your search

@@ -0,0 +1,133 @@
import { deriveProStatus } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { requireAuth } from '../../plugins/auth.js';
import { billingEnv, isBillingConfigured } from './env.js';
import { ensureSubscription, getSubscription, toSnapshot } from './service.js';
import { getStripe } from './stripe-client.js';
const checkoutBodySchema = z.object({
interval: z.enum(['MONTH', 'YEAR']),
});
export const billingRoutes: FastifyPluginAsync = async (fastify) => {
/**
* GET /me/billing/status — stato corrente dell'abbonamento + derived.
* Risponde sempre 200 (anche per utenti senza subscription record): il
* frontend usa lo stesso payload sia per chi non ha mai aperto la pagina
* billing sia per chi è in trial / pro / canceled.
*/
fastify.get('/me/billing/status', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const signupAt = request.user!.createdAt;
// Side effect benefico: chiamare /status la prima volta crea il record
// di trial se non esiste. Idempotente.
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
const snap = toSnapshot(sub);
const derived = deriveProStatus(snap);
return {
subscription: snap
? {
status: snap.status,
trialEndsAt: snap.trialEndsAt.toISOString(),
currentPeriodEnd: snap.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: snap.cancelAtPeriodEnd,
interval: sub.interval,
stripePriceId: sub.stripePriceId,
}
: null,
derived: {
kind: derived.kind,
isPro: derived.isPro,
trialDaysRemaining: derived.trialDaysRemaining,
accessEndsAt: derived.accessEndsAt?.toISOString() ?? null,
},
configured: isBillingConfigured(),
};
});
/**
* POST /me/billing/checkout — crea una Stripe Checkout Session per il
* primo upgrade. Restituisce l'URL hosted Stripe a cui redirigere l'utente.
*/
fastify.post('/me/billing/checkout', { preHandler: requireAuth() }, async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const body = checkoutBodySchema.safeParse(request.body);
if (!body.success) {
return reply.code(400).send({ error: 'invalid_body', issues: body.error.issues });
}
const { interval } = body.data;
const priceId =
interval === 'YEAR' ? billingEnv.STRIPE_PRICE_ID_YEARLY : billingEnv.STRIPE_PRICE_ID_MONTHLY;
if (!priceId) {
return reply.code(503).send({ error: 'price_not_configured' });
}
const userId = request.user!.id;
const userEmail = request.user!.email;
const signupAt = request.user!.createdAt;
const stripe = getStripe();
const sub = await ensureSubscription(fastify.prisma, userId, signupAt);
// Riusa il customer Stripe se l'utente ha già pagato in passato; altrimenti
// ne creiamo uno nuovo passando metadata.userId per il webhook.
let customerId = sub.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
});
customerId = customer.id;
await fastify.prisma.subscription.update({
where: { userId },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=canceled`,
// Critico: il webhook risale all'utente da queste metadata.
subscription_data: { metadata: { userId } },
// Stripe Tax: calcolo automatico dell'IVA UE (vedi ADR 0004).
automatic_tax: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
// Permette all'utente di inserire una P.IVA (per chi compra B2B).
tax_id_collection: { enabled: true },
allow_promotion_codes: true,
locale: 'it',
});
return reply.send({ url: session.url });
});
/**
* POST /me/billing/portal — crea una Customer Portal Session per la
* gestione self-service di abbonamento, fatture, metodo di pagamento.
*/
fastify.post('/me/billing/portal', { preHandler: requireAuth() }, async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const userId = request.user!.id;
const sub = await getSubscription(fastify.prisma, userId);
if (!sub?.stripeCustomerId) {
return reply.code(409).send({ error: 'no_stripe_customer' });
}
const stripe = getStripe();
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripeCustomerId,
return_url: `${billingEnv.BILLING_RETURN_URL}/billing`,
locale: 'it',
});
return reply.send({ url: session.url });
});
};
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
/**
* ADR 0004 — env vars Stripe. Tutte opzionali: se mancano, i flussi di
* billing rispondono 503 (come abbiamo fatto per VAPID in ADR 0003).
* Questo permette al PO di sviluppare features non-billing senza dover
* configurare Stripe localmente.
*/
const billingEnvSchema = z.object({
STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_PRICE_ID_MONTHLY: z.string().min(1).optional(),
STRIPE_PRICE_ID_YEARLY: z.string().min(1).optional(),
// URL di base usato per le redirect post-checkout. Deve coincidere col
// dominio del frontend (apps/web).
BILLING_RETURN_URL: z.string().url().optional(),
});
export type BillingEnv = z.infer<typeof billingEnvSchema>;
export function readBillingEnv(source: NodeJS.ProcessEnv = process.env): BillingEnv {
return billingEnvSchema.parse(source);
}
export const billingEnv: BillingEnv = readBillingEnv();
export function isBillingConfigured(env: BillingEnv = billingEnv): boolean {
return Boolean(
env.STRIPE_SECRET_KEY &&
env.STRIPE_WEBHOOK_SECRET &&
env.STRIPE_PRICE_ID_MONTHLY &&
env.BILLING_RETURN_URL,
);
}
+168
View File
@@ -0,0 +1,168 @@
import {
computeTrialEndsAt,
isProActive,
type SubscriptionSnapshot,
type SubscriptionStatus as SharedStatus,
} from '@ketopath/shared';
import type { PrismaClient, Subscription } from '@prisma/client';
// eslint-disable-next-line import/no-named-as-default
import type Stripe from 'stripe';
/**
* ADR 0004 — service di billing. Orchestrazione tra Stripe e DB locale.
* Le route lo invocano per leggere/scrivere lo stato subscription; il
* webhook lo invoca per applicare gli eventi `customer.subscription.*`.
*/
export function toSnapshot(sub: Subscription | null): SubscriptionSnapshot | null {
if (!sub) return null;
return {
status: sub.status as SharedStatus,
trialEndsAt: sub.trialEndsAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
};
}
/**
* Crea il record Subscription per l'utente se non esiste — il trial parte
* dalla data di registrazione. Idempotente: se esiste già, non tocca nulla.
*/
export async function ensureSubscription(
prisma: PrismaClient,
userId: string,
signupAt: Date,
): Promise<Subscription> {
return prisma.subscription.upsert({
where: { userId },
create: {
userId,
status: 'TRIALING',
trialEndsAt: computeTrialEndsAt(signupAt),
},
update: {},
});
}
export async function getSubscription(
prisma: PrismaClient,
userId: string,
): Promise<Subscription | null> {
return prisma.subscription.findUnique({ where: { userId } });
}
export async function isUserPro(prisma: PrismaClient, userId: string): Promise<boolean> {
const sub = await getSubscription(prisma, userId);
return isProActive(toSnapshot(sub));
}
/* ─────────────────────────────────────────────────────────────────────
* Mapping da Stripe.Subscription al nostro stato.
* ────────────────────────────────────────────────────────────────────── */
function mapStatus(
stripeStatus: Stripe.Subscription.Status,
cancelAtPeriodEnd: boolean,
): SharedStatus {
if (cancelAtPeriodEnd && (stripeStatus === 'active' || stripeStatus === 'trialing')) {
return 'CANCEL_AT_PERIOD_END';
}
switch (stripeStatus) {
case 'trialing':
return 'TRIALING';
case 'active':
return 'ACTIVE';
case 'past_due':
return 'PAST_DUE';
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
return 'CANCELED';
case 'incomplete':
case 'paused':
// Stati transitori — manteniamo lo stato corrente in DB. Stripe rinotifica
// appena la situazione si stabilizza. Per sicurezza torniamo PAST_DUE.
return 'PAST_DUE';
}
}
function mapInterval(
interval: Stripe.Price.Recurring.Interval | null | undefined,
): 'MONTH' | 'YEAR' | null {
if (interval === 'month') return 'MONTH';
if (interval === 'year') return 'YEAR';
return null;
}
/**
* Applica un evento `customer.subscription.*` o `checkout.session.completed`
* di Stripe al record locale. Idempotente: lo possiamo invocare con lo stesso
* payload N volte senza side effect.
*/
export async function applyStripeSubscription(
prisma: PrismaClient,
stripeSub: Stripe.Subscription,
): Promise<void> {
const userId = stripeSub.metadata?.userId;
if (!userId) {
throw new Error(`stripe subscription ${stripeSub.id} priva di metadata.userId`);
}
const item = stripeSub.items.data[0];
if (!item) throw new Error(`stripe subscription ${stripeSub.id} senza items`);
const cancelAtPeriodEnd = stripeSub.cancel_at_period_end;
const status = mapStatus(stripeSub.status, cancelAtPeriodEnd);
const currentPeriodEnd = item.current_period_end
? new Date(item.current_period_end * 1000)
: null;
const trialEnd = stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null;
const customerId =
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id;
await prisma.subscription.upsert({
where: { userId },
create: {
userId,
status,
// Se Stripe ci dà un trial_end (caso del trial-with-card), lo prendiamo.
// Altrimenti usiamo il trial nativo a 30gg dalla creazione del record.
trialEndsAt: trialEnd ?? computeTrialEndsAt(new Date()),
stripeCustomerId: customerId,
stripeSubscriptionId: stripeSub.id,
stripePriceId: item.price.id,
currentPeriodEnd,
interval: mapInterval(item.price.recurring?.interval),
cancelAtPeriodEnd,
endedAt: status === 'CANCELED' ? new Date() : null,
},
update: {
status,
stripeCustomerId: customerId,
stripeSubscriptionId: stripeSub.id,
stripePriceId: item.price.id,
currentPeriodEnd,
interval: mapInterval(item.price.recurring?.interval),
cancelAtPeriodEnd,
endedAt: status === 'CANCELED' ? new Date() : null,
},
});
}
/**
* Marca come EXPIRED le subscription TRIALING il cui trial è scaduto.
* Invocata da uno scheduler giornaliero (vedi notifications/scheduler).
* Restituisce il numero di record aggiornati.
*/
export async function expireFinishedTrials(prisma: PrismaClient, now: Date = new Date()) {
const res = await prisma.subscription.updateMany({
where: {
status: 'TRIALING',
trialEndsAt: { lte: now },
},
data: {
status: 'EXPIRED',
endedAt: now,
},
});
return res.count;
}
@@ -0,0 +1,26 @@
// eslint-disable-next-line import/no-named-as-default
import Stripe from 'stripe';
import { billingEnv } from './env.js';
/**
* Singleton Stripe client. Costruito lazy: se `STRIPE_SECRET_KEY` non è
* configurato (es. in CI / sviluppo locale senza account Stripe), `getStripe`
* lancia. Le route che dipendono da Stripe rispondono 503 prima di chiamare.
*/
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (_stripe) return _stripe;
if (!billingEnv.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY non configurata');
}
_stripe = new Stripe(billingEnv.STRIPE_SECRET_KEY, {
// Pin esplicito: niente sorprese quando Stripe rilascia una nuova
// apiVersion. Aggiornare insieme allo `stripe` package.
apiVersion: '2025-09-30.clover',
typescript: true,
appInfo: { name: 'KetoPath', version: '0.0.0' },
});
return _stripe;
}
@@ -0,0 +1,127 @@
import type { PrismaClient } from '@prisma/client';
import type { FastifyPluginAsync } from 'fastify';
// eslint-disable-next-line import/no-named-as-default
import type Stripe from 'stripe';
import { billingEnv, isBillingConfigured } from './env.js';
import { applyStripeSubscription } from './service.js';
import { getStripe } from './stripe-client.js';
/**
* ADR 0004 — webhook Stripe. Receive raw body, verifica firma HMAC,
* idempotenza via tabella `BillingWebhookEvent`, dispatch agli handler
* per i 5 eventi che ci interessano.
*
* Encapsulation: il content-type parser raw è registrato dentro un
* `fastify.register()` scope così non interferisce con le altre rotte
* (che si aspettano JSON parsato).
*/
export const stripeWebhookRoutes: FastifyPluginAsync = async (parent) => {
await parent.register(async (instance) => {
instance.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(_request, body, done) => {
done(null, body);
},
);
instance.post('/webhooks/stripe', async (request, reply) => {
if (!isBillingConfigured()) {
return reply.code(503).send({ error: 'billing_not_configured' });
}
const signature = request.headers['stripe-signature'];
if (typeof signature !== 'string') {
return reply.code(400).send({ error: 'missing_signature' });
}
const rawBody = request.body as Buffer;
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
billingEnv.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
request.log.warn({ err }, 'webhook signature verification failed');
return reply.code(400).send({ error: 'invalid_signature' });
}
// Idempotenza: se l'evento è già stato visto, rispondiamo 200 senza
// ri-eseguire l'handler. Stripe accetta anche 200 su evento già processato.
const existing = await instance.prisma.billingWebhookEvent.findUnique({
where: { id: event.id },
});
if (existing?.processedAt) {
request.log.debug({ eventId: event.id }, 'webhook already processed, skipping');
return reply.code(200).send({ received: true, duplicate: true });
}
// Registriamo l'evento (anche se l'handler fallirà sotto, così abbiamo
// un audit trail di tutto quello che Stripe ci ha mandato).
await instance.prisma.billingWebhookEvent.upsert({
where: { id: event.id },
create: {
id: event.id,
type: event.type,
payload: event as unknown as Stripe.Event,
},
update: {},
});
try {
await dispatchEvent(instance.prisma, stripe, event);
await instance.prisma.billingWebhookEvent.update({
where: { id: event.id },
data: { processedAt: new Date() },
});
return reply.code(200).send({ received: true });
} catch (err) {
request.log.error({ err, eventId: event.id, type: event.type }, 'webhook handler failed');
// 500 → Stripe ritenta. processedAt resta null, idempotenza ok al
// prossimo retry.
return reply.code(500).send({ error: 'handler_failed' });
}
});
});
};
async function dispatchEvent(
prisma: PrismaClient,
stripe: Stripe,
event: Stripe.Event,
): Promise<void> {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await applyStripeSubscription(prisma, sub);
return;
}
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== 'subscription' || !session.subscription) return;
const subId =
typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
// Recuperiamo la subscription completa (con items expansi) per avere
// tutti i dati che ci servono per applyStripeSubscription.
const fullSub = await stripe.subscriptions.retrieve(subId);
await applyStripeSubscription(prisma, fullSub);
return;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const lineSub = invoice.lines.data.find((line) => line.subscription)?.subscription;
if (!lineSub) return;
const subId = typeof lineSub === 'string' ? lineSub : lineSub.id;
const fullSub = await stripe.subscriptions.retrieve(subId);
await applyStripeSubscription(prisma, fullSub);
return;
}
default:
// Eventi non gestiti — log silenzioso. Stripe ne manda decine, normale.
return;
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import type { FastifyPluginAsync } from 'fastify';
import PDFDocument from 'pdfkit';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
@@ -26,7 +27,7 @@ const MEAL_LABELS: Record<string, string> = {
export const planExportRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get(
'/me/meal-plans/export.pdf',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
+202 -196
View File
@@ -24,6 +24,7 @@ import {
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
@@ -72,220 +73,225 @@ function parseConditions(raw: string | null): string[] {
}
export const planRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
fastify.post(
'/me/meal-plans',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const profile = await fastify.prisma.profile.findUnique({
where: { userId },
include: { user: { include: { preferences: true } } },
});
if (!profile) return reply.code(409).send({ error: 'profile_required' });
const profile = await fastify.prisma.profile.findUnique({
where: { userId },
include: { user: { include: { preferences: true } } },
});
if (!profile) return reply.code(409).send({ error: 'profile_required' });
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
const recipes = await fastify.prisma.recipe.findMany({
select: {
id: true,
name: true,
category: true,
kcal: true,
proteinG: true,
fatG: true,
netCarbG: true,
phases: true,
prepMinutes: true,
ingredients: {
select: {
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
},
},
},
},
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
// che contengono ingredienti non ancora reintrodotti per la settimana
// corrente di fase 2.
const phaseIntForFilter = phaseToInt(profile.currentPhase);
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
const allowedRecipes = recipes.filter((r) =>
isRecipeAllowedForPhaseWeek(
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
phaseIntForFilter,
phase2WeekForFilter,
),
);
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
});
if (recipes.length === 0) {
return reply.code(409).send({ error: 'recipe_catalog_empty' });
}
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),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
// PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette
// che contengono ingredienti non ancora reintrodotti per la settimana
// corrente di fase 2.
const phaseIntForFilter = phaseToInt(profile.currentPhase);
const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt);
const allowedRecipes = recipes.filter((r) =>
isRecipeAllowedForPhaseWeek(
r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })),
phaseIntForFilter,
phase2WeekForFilter,
),
);
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
const tags = new Set<string>();
const ingredientIds: string[] = [];
for (const ri of r.ingredients) {
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
ingredientIds.push(ri.ingredient.id);
}
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),
ingredientIds,
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
prepMinutes: r.prepMinutes,
};
});
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
const weightCurrentKg = Number(profile.weightCurrentKg);
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
const weekStart = startOfWeek(new Date());
const plan = await fastify.prisma.mealPlan.upsert({
where: { userId_weekStart: { userId, weekStart } },
create: { userId, weekStart },
update: { generatedAt: new Date(), status: 'ACTIVE' },
});
// Wipe existing slots before regenerating, in case the plan already existed.
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
for (let day = 0; day < 7; day++) {
const dayPlan = protocolPlanForDay(fastingProtocol, day);
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
const exclusions = profile.user.preferences?.exclusions ?? [];
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
const fastingProtocol =
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ??
null;
const cookingTime =
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
null;
const maxPrepMinutes = maxPrepMinutesFor(cookingTime);
// Ricette scelte negli ultimi 2 giorni sono "recenti".
const recentlyConsumedIds: string[] = plannedSlots
.filter((s) => day - s.day <= 2 && s.recipeId)
.map((s) => s.recipeId!);
const weekStart = startOfWeek(new Date());
for (const meal of MEALS) {
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes,
topN: 5,
});
const selected = top[0];
await fastify.prisma.mealSlot.create({
data: {
planId: plan.id,
dayOfWeek: day,
meal,
recipeId: selected?.id ?? null,
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
},
});
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
}
}
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
const plan = await fastify.prisma.mealPlan.upsert({
where: { userId_weekStart: { userId, weekStart } },
create: { userId, weekStart },
update: { generatedAt: new Date(), status: 'ACTIVE' },
});
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
});
// Wipe existing slots before regenerating, in case the plan already existed.
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
for (let day = 0; day < 7; day++) {
const dayPlan = protocolPlanForDay(fastingProtocol, day);
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
weightKg: weightCurrentKg,
phase: phaseInt,
});
// Ricette scelte negli ultimi 2 giorni sono "recenti".
const recentlyConsumedIds: string[] = plannedSlots
.filter((s) => day - s.day <= 2 && s.recipeId)
.map((s) => s.recipeId!);
for (const meal of MEALS) {
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
meal,
phase: phaseInt,
excludedTags: exclusions,
bannedIngredientIds,
recentlyConsumedIds,
dailyTarget,
mealShare: effectiveShare,
maxPrepMinutes,
topN: 5,
});
const selected = top[0];
await fastify.prisma.mealSlot.create({
data: {
planId: plan.id,
dayOfWeek: day,
meal,
recipeId: selected?.id ?? null,
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
},
});
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
}
}
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
return reply.code(201).send({
plan: {
id: plan.id,
weekStart: plan.weekStart.toISOString().slice(0, 10),
dailyTarget: baseDailyTarget,
fastingProtocol,
},
newlyUnlocked: ach.newlyUnlocked,
});
},
);
fastify.patch(
'/me/meal-plans/slots/:slotId',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const body = request.body as { recipeId?: unknown };
@@ -322,7 +328,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// automaticamente un nuovo digiuno.
fastify.post(
'/me/meal-plans/slots/:slotId/consumed',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -398,7 +404,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
fastify.post(
'/me/meal-plans/slots/:slotId/free-meal',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -424,7 +430,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// conto dei pasti del giorno già scelti (coerenza dei macros).
fastify.post(
'/me/meal-plans/slots/:slotId/regenerate',
{ preHandler: requireAuth() },
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const userId = request.user!.id;
@@ -5,6 +5,7 @@ import { dailyCheckInInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
function todayDateOnly(): Date {
const d = new Date();
@@ -54,36 +55,40 @@ export const checkInRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = dailyCheckInInputSchema.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 date = new Date(data.date);
date.setHours(0, 0, 0, 0);
fastify.post(
'/me/check-ins',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = dailyCheckInInputSchema.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 date = new Date(data.date);
date.setHours(0, 0, 0, 0);
const item = await fastify.prisma.dailyCheckIn.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
update: {
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
});
const item = await fastify.prisma.dailyCheckIn.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
update: {
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
});
return reply.code(201).send({ item: { id: item.id } });
});
return reply.code(201).send({ item: { id: item.id } });
},
);
};
+149 -134
View File
@@ -6,6 +6,7 @@ import type { FastifyPluginAsync } from 'fastify';
import PDFDocument from 'pdfkit';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
@@ -14,151 +15,165 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
});
export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const userEmail = request.user!.email ?? '—';
fastify.get(
'/me/tracking/export.pdf',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const userEmail = request.user!.email ?? '—';
const [profile, entries, currentPlan] = await Promise.all([
fastify.prisma.profile.findUnique({ where: { userId } }),
fastify.prisma.weightEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 30,
}),
fastify.prisma.mealPlan.findFirst({
where: { userId, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
const [profile, entries, currentPlan] = await Promise.all([
fastify.prisma.profile.findUnique({ where: { userId } }),
fastify.prisma.weightEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 30,
}),
fastify.prisma.mealPlan.findFirst({
where: { userId, status: 'ACTIVE' },
orderBy: { weekStart: 'desc' },
include: {
slots: {
include: {
selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } },
},
},
},
},
}),
]);
}),
]);
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
// Bufferizziamo i chunks per evitare race con fastify (vedi plan export).
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
// ── Header ─────────────────────────────────────────────────────────
doc
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(22)
.text('KetoPath', { continued: true })
.fillColor('#9a3f29')
.text('.');
doc
.moveDown(0.2)
.fillColor('#3d3530')
.font('Helvetica')
.fontSize(9)
.text(`Sintesi tracking — ${userEmail}`)
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
doc
.moveDown(1)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
// ── Profile summary ────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (profile) {
const weightStart = Number(profile.weightStartKg);
const weightCurrent = Number(profile.weightCurrentKg);
const weightGoal = Number(profile.weightGoalKg);
const lostKg = weightStart - weightCurrent;
// ── Header ─────────────────────────────────────────────────────────
doc
.moveDown(0.3)
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
.text(
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
)
.text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`)
.text(`Fase corrente: ${profile.currentPhase}`);
} else {
doc.text('Profilo non ancora configurato.');
}
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(22)
.text('KetoPath', { continued: true })
.fillColor('#9a3f29')
.text('.');
doc
.moveDown(0.2)
.fillColor('#3d3530')
.font('Helvetica')
.fontSize(9)
.text(`Sintesi tracking — ${userEmail}`)
.text(`Generato il ${ITALIAN_DATE.format(new Date())}`);
doc
.moveDown(1)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
// ── Weight history ─────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
if (entries.length === 0) {
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
} else {
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false });
const headerY = doc.y - 11;
doc.text('Peso (kg)', colX.weight, headerY);
doc.text('Energia', colX.energy, headerY);
doc.text('Sonno', colX.sleep, headerY);
doc.text('Fame', colX.hunger, headerY);
doc.font('Helvetica').moveDown(0.5);
for (const e of entries) {
const y = doc.y;
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
doc.moveDown(0.4);
// ── Profile summary ────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (profile) {
const weightStart = Number(profile.weightStartKg);
const weightCurrent = Number(profile.weightCurrentKg);
const weightGoal = Number(profile.weightGoalKg);
const lostKg = weightStart - weightCurrent;
doc
.moveDown(0.3)
.text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`)
.text(
`Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`,
)
.text(
`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`,
)
.text(`Fase corrente: ${profile.currentPhase}`);
} else {
doc.text('Profilo non ancora configurato.');
}
}
// ── Current plan summary ───────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (!currentPlan) {
doc.moveDown(0.3).text('Nessun piano attivo.');
} else {
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
// ── Weight history ─────────────────────────────────────────────────
doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO');
if (entries.length === 0) {
doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.');
} else {
doc.font('Helvetica').fontSize(9).fillColor('#3d3530');
const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 };
doc
.moveDown(0.5)
.font('Helvetica-Bold')
.text('Data', colX.date, doc.y, { continued: false });
const headerY = doc.y - 11;
doc.text('Peso (kg)', colX.weight, headerY);
doc.text('Energia', colX.energy, headerY);
doc.text('Sonno', colX.sleep, headerY);
doc.text('Fame', colX.hunger, headerY);
doc.font('Helvetica').moveDown(0.5);
for (const e of entries) {
const y = doc.y;
doc.text(e.date.toISOString().slice(0, 10), colX.date, y);
doc.text(Number(e.weightKg).toFixed(1), colX.weight, y);
doc.text(e.energy?.toString() ?? '—', colX.energy, y);
doc.text(e.sleep?.toString() ?? '—', colX.sleep, y);
doc.text(e.hunger?.toString() ?? '—', colX.hunger, y);
doc.moveDown(0.4);
}
}
// ── Current plan summary ───────────────────────────────────────────
doc
.moveDown(0.3)
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
.moveDown(1)
.fillColor('#221d18')
.font('Helvetica-Bold')
.fontSize(11)
.text('PIANO CORRENTE');
doc.font('Helvetica').fontSize(10).fillColor('#3d3530');
if (!currentPlan) {
doc.moveDown(0.3).text('Nessun piano attivo.');
} else {
const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null);
const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0);
const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0);
const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0);
const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0);
const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1;
const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length;
doc
.moveDown(0.3)
.text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`)
.text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`)
.text(
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
);
}
// ── Footer ─────────────────────────────────────────────────────────
doc
.moveDown(2)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
doc
.moveDown(0.5)
.fontSize(8)
.fillColor('#7a7060')
.text(
`Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`,
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
);
}
// ── Footer ─────────────────────────────────────────────────────────
doc
.moveDown(2)
.strokeColor('#bdb6a3')
.lineWidth(0.5)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke();
doc
.moveDown(0.5)
.fontSize(8)
.fillColor('#7a7060')
.text(
'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.',
);
doc.end();
const pdfBuffer = await done;
return reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
.header('Content-Length', String(pdfBuffer.length))
.send(pdfBuffer);
});
doc.end();
const pdfBuffer = await done;
return reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"')
.header('Content-Length', String(pdfBuffer.length))
.send(pdfBuffer);
},
);
};
+92 -79
View File
@@ -6,6 +6,7 @@ import {
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
@@ -29,91 +30,103 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = fastEventStartSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
const event = await fastify.prisma.fastEvent.create({
data: {
userId: request.user!.id,
protocol: data.protocol,
startedAt: data.startedAt ?? new Date(),
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
},
});
return reply.code(201).send({ event: { id: event.id } });
});
fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => {
const id = (request.params as { id: string }).id;
const parsed = fastEventUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
// Garantisce che l'utente possa aggiornare solo i propri eventi.
const owned = await fastify.prisma.fastEvent.findFirst({
where: { id, userId: request.user!.id },
select: { id: true },
});
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
if (data.endedAt) updateData.endedAt = data.endedAt;
if (data.status) updateData.status = data.status;
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
if (data.notes != null) updateData.notes = data.notes;
const event = await fastify.prisma.fastEvent.update({
where: { id },
data: updateData,
});
let newlyUnlocked: string[] = [];
if (event.status === 'COMPLETED') {
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
fastify.post(
'/me/fast-events',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = fastEventStartSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
}
return { event: { id: event.id, status: event.status }, newlyUnlocked };
});
const data = parsed.data;
const event = await fastify.prisma.fastEvent.create({
data: {
userId: request.user!.id,
protocol: data.protocol,
startedAt: data.startedAt ?? new Date(),
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
},
});
return reply.code(201).send({ event: { id: event.id } });
},
);
fastify.patch(
'/me/fast-events/:id',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const id = (request.params as { id: string }).id;
const parsed = fastEventUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
}
const data = parsed.data;
// Garantisce che l'utente possa aggiornare solo i propri eventi.
const owned = await fastify.prisma.fastEvent.findFirst({
where: { id, userId: request.user!.id },
select: { id: true },
});
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
// Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con
// `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito.
const updateData: Parameters<typeof fastify.prisma.fastEvent.update>[0]['data'] = {};
if (data.endedAt) updateData.endedAt = data.endedAt;
if (data.status) updateData.status = data.status;
if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms);
if (data.notes != null) updateData.notes = data.notes;
const event = await fastify.prisma.fastEvent.update({
where: { id },
data: updateData,
});
let newlyUnlocked: string[] = [];
if (event.status === 'COMPLETED') {
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
newlyUnlocked = ach.newlyUnlocked;
if (newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
}
return { event: { id: event.id, status: event.status }, newlyUnlocked };
},
);
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const body = (request.body ?? {}) as { until?: string | null };
let until: Date | null = null;
if (body.until === null) {
until = null;
} else if (typeof body.until === 'string') {
const d = new Date(body.until);
if (Number.isNaN(d.getTime())) {
return reply.code(400).send({ error: 'invalid_until' });
fastify.post(
'/me/fasting/pause',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const userId = request.user!.id;
const body = (request.body ?? {}) as { until?: string | null };
let until: Date | null = null;
if (body.until === null) {
until = null;
} else if (typeof body.until === 'string') {
const d = new Date(body.until);
if (Number.isNaN(d.getTime())) {
return reply.code(400).send({ error: 'invalid_until' });
}
until = d;
} else {
// default: domani alle 06:00 locali (server)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
until = tomorrow;
}
until = d;
} else {
// default: domani alle 06:00 locali (server)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
until = tomorrow;
}
const user = await fastify.prisma.user.update({
where: { id: userId },
data: { fastingPausedUntil: until },
select: { fastingPausedUntil: true },
});
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
});
const user = await fastify.prisma.user.update({
where: { id: userId },
data: { fastingPausedUntil: until },
select: { fastingPausedUntil: true },
});
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
},
);
// PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
+46 -41
View File
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
import { requirePro } from '../../plugins/require-pro.js';
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
@@ -26,48 +27,52 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => {
};
});
fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = weightEntryInputSchema.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;
fastify.post(
'/me/weight-entries',
{ preHandler: [requireAuth(), requirePro()] },
async (request, reply) => {
const parsed = weightEntryInputSchema.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 entry = await fastify.prisma.weightEntry.upsert({
where: { userId_date: { userId, date: data.date } },
create: {
userId,
date: data.date,
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
update: {
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
});
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
const entry = await fastify.prisma.weightEntry.upsert({
where: { userId_date: { userId, date: data.date } },
create: {
userId,
date: data.date,
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
update: {
weightKg: String(data.weightKg),
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
notes: data.notes ?? null,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
photos: data.photos ?? [],
},
});
}
return reply.code(201).send({
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
newlyUnlocked: ach.newlyUnlocked,
});
});
const ach = await evaluateAndPersist(fastify.prisma, userId);
if (ach.newlyUnlocked.length > 0) {
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
/* notifica best-effort */
});
}
return reply.code(201).send({
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
newlyUnlocked: ach.newlyUnlocked,
});
},
);
};