From 60a3d072f6abfb9881948a3375c67df200107faa Mon Sep 17 00:00:00 2001 From: luciano Date: Thu, 30 Apr 2026 00:24:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20GDPR=20(export+delete)=20+=20catalogo?= =?UTF-8?q?=20ricette=2046=E2=86=9296?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GDPR (PRD §14, art. 17 + 20) - GET /me/export.json: dump completo dei dati personali (User, Profile, Preferences, WeightEntry, FastEvent, MealPlan+slots, DailyCheckIn, DeviceToken). I campi @encrypted vengono decifrati a runtime dall'extension Prisma. Risposta con Content-Disposition attachment. - DELETE /me: cancellazione account con cascade automatico. Per gli account email/password richiede conferma password (verifica via auth.api.signInEmail). Account OAuth-only: solo sessione attiva. - Proxy Next /api/gdpr-export per same-origin + cookie sessione. - /profile: nuova sezione "I tuoi dati (GDPR)" in fondo con due azioni (esporta + elimina) e modale di conferma password per la cancellazione. Catalogo ricette 46 → 96 (target 100) - 50 ricette nuove italiane keto/low-carb bilanciate per categoria: 12 colazioni, 14 pranzi, 12 spuntini, 12 cene - Macros realistici, ingredienti dal seed esistente (no nuovi ingredienti necessari) - Run db:seed: ingredients +0 (catalog 55), recipes +49 (catalog 96), recipe-ingredient links: 323 i18n: namespace Profile esteso con dataEyebrow/Title/Subtitle, dataExport*, dataDelete* (incluso typed errors map), working. 81/81 unit test verdi. Lint, typecheck verdi. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/app.ts | 2 + apps/api/src/modules/me/gdpr.routes.ts | 148 +++ apps/web/messages/it.json | 20 + apps/web/src/app/[locale]/profile/actions.ts | 15 + .../src/app/[locale]/profile/data-panel.tsx | 119 +++ apps/web/src/app/[locale]/profile/page.tsx | 7 + apps/web/src/app/api/gdpr-export/route.ts | 33 + packages/db/prisma/seed-recipes.ts | 862 ++++++++++++++++++ 8 files changed, 1206 insertions(+) create mode 100644 apps/api/src/modules/me/gdpr.routes.ts create mode 100644 apps/web/src/app/[locale]/profile/data-panel.tsx create mode 100644 apps/web/src/app/api/gdpr-export/route.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 86da2f3..8f2cd18 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,7 @@ import { env } from './config/env.js'; import { Sentry } from './lib/sentry.js'; import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; +import { gdprRoutes } from './modules/me/gdpr.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; import { notificationsRoutes } from './modules/notifications/notifications.routes.js'; import { planExportRoutes } from './modules/plan/export.routes.js'; @@ -54,6 +55,7 @@ export async function buildApp(): Promise { await app.register(healthRoutes); await app.register(dbRoutes); await app.register(meRoutes); + await app.register(gdprRoutes); await app.register(profileRoutes); await app.register(preferencesRoutes); await app.register(weightRoutes); diff --git a/apps/api/src/modules/me/gdpr.routes.ts b/apps/api/src/modules/me/gdpr.routes.ts new file mode 100644 index 0000000..6649fec --- /dev/null +++ b/apps/api/src/modules/me/gdpr.routes.ts @@ -0,0 +1,148 @@ +// PRD §14 — Diritto all'oblio (art. 17 GDPR) + portabilità (art. 20). +// +// `/me/export.json` restituisce un dump completo dei dati personali in JSON. +// I campi @encrypted vengono decifrati a runtime dal Prisma extension, quindi +// arrivano qui in chiaro: l'export deve essere autenticato e con +// Content-Disposition attachment. +// +// `DELETE /me` cancella l'utente e tutti i dati collegati via cascade. Per +// evitare cancellazioni accidentali richiediamo conferma re-inserendo la +// password (per gli account email/password); per gli OAuth-only basta la +// sessione attiva. + +import { auth } from '@ketopath/auth'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +export const gdprRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/export.json', { preHandler: requireAuth() }, async (request, reply) => { + const userId = request.user!.id; + + const [ + user, + profile, + preferences, + weightEntries, + fastEvents, + mealPlans, + checkIns, + deviceTokens, + ] = await Promise.all([ + fastify.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + emailVerified: true, + name: true, + image: true, + role: true, + disclaimerAcceptedAt: true, + phase2StartedAt: true, + fastingPausedUntil: true, + createdAt: true, + updatedAt: true, + }, + }), + fastify.prisma.profile.findUnique({ where: { userId } }), + fastify.prisma.preferences.findUnique({ where: { userId } }), + fastify.prisma.weightEntry.findMany({ + where: { userId }, + orderBy: { date: 'asc' }, + }), + fastify.prisma.fastEvent.findMany({ + where: { userId }, + orderBy: { startedAt: 'asc' }, + }), + fastify.prisma.mealPlan.findMany({ + where: { userId }, + orderBy: { weekStart: 'asc' }, + include: { + slots: { + include: { + selected: { select: { id: true, name: true } }, + alternatives: { select: { id: true, name: true } }, + }, + }, + }, + }), + fastify.prisma.dailyCheckIn.findMany({ + where: { userId }, + orderBy: { date: 'asc' }, + }), + fastify.prisma.deviceToken.findMany({ + where: { userId }, + select: { + id: true, + platform: true, + userAgent: true, + createdAt: true, + lastSeenAt: true, + // endpoint/p256dh/auth omessi: sono token push, non dato personale + }, + }), + ]); + + const payload = { + exportedAt: new Date().toISOString(), + schemaVersion: '1.0', + user, + profile, + preferences, + weightEntries, + fastEvents, + mealPlans, + dailyCheckIns: checkIns, + deviceTokens, + }; + + return reply + .header('Content-Type', 'application/json; charset=utf-8') + .header('Content-Disposition', 'attachment; filename="ketopath-export.json"') + .send(payload); + }); + + // DELETE /me — cancellazione account. + // Body opzionale { password } per ulteriore conferma sull'account + // email/password. Il cascade del modello rimuove tutto il resto. + fastify.delete('/me', { preHandler: requireAuth() }, async (request, reply) => { + const userId = request.user!.id; + const body = (request.body ?? {}) as { password?: unknown }; + + // Se l'utente ha un account credential, esigiamo la password per + // confermare la cancellazione. Per OAuth-only saltiamo il check. + const credentialAccount = await fastify.prisma.account.findFirst({ + where: { userId, providerId: 'credential' }, + select: { password: true }, + }); + if (credentialAccount && credentialAccount.password) { + if (typeof body.password !== 'string' || body.password.length === 0) { + return reply.code(400).send({ error: 'password_required' }); + } + const email = request.user!.email; + if (!email) return reply.code(400).send({ error: 'email_required' }); + // Better Auth: verifica la password tentando un sign-in. Se la password + // non corrisponde lancia un errore. Non emettiamo cookie reply. + try { + await auth.api.signInEmail({ + body: { email, password: body.password }, + }); + } catch { + return reply.code(401).send({ error: 'invalid_password' }); + } + } + + // Hard-delete con cascade. + await fastify.prisma.user.delete({ where: { id: userId } }); + + request.log.info({ userId }, '[gdpr] user account deleted'); + + // Cookie di sessione: l'utente è già stato cancellato (cascade) ma il + // browser potrebbe ancora avere il cookie. Lo invalidiamo via API. + return reply + .header('Set-Cookie', 'ketopath.session_token=; Path=/; Max-Age=0; HttpOnly') + .code(204) + .send(); + }); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 63d9ddd..e286ef8 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -571,6 +571,26 @@ "LIGHT": "Leggero · sport 1–3 volte / settimana", "MODERATE": "Moderato · sport 3–5 volte / settimana", "INTENSE": "Intenso · sport 6+ volte / settimana" + }, + "working": "Attendere…", + "dataEyebrow": "I tuoi dati (GDPR)", + "dataTitle": "I tuoi diritti sui dati personali", + "dataSubtitle": "Puoi scaricare in qualsiasi momento una copia completa dei tuoi dati o eliminare l'account. Le richieste sono evase immediatamente.", + "dataExportEyebrow": "Esporta", + "dataExportDescription": "Scarica un file JSON con profilo, preferenze, pesate, sessioni di digiuno, piani e check-in.", + "dataExportAction": "Esporta i miei dati", + "dataDeleteEyebrow": "Elimina account", + "dataDeleteDescription": "Cancellazione completa e irreversibile di tutti i tuoi dati. Niente backup, niente recupero.", + "dataDeleteAction": "Elimina account", + "dataDeleteWarning": "Questa azione è irreversibile. Conferma con la tua password per procedere.", + "dataDeletePasswordPlaceholder": "La tua password", + "dataDeleteConfirm": "Elimina definitivamente", + "dataDeleteCancel": "Annulla", + "dataDeleteErrors": { + "password_required": "Inserisci la tua password.", + "invalid_password": "Password non corretta.", + "email_required": "Email assente sul tuo account.", + "api_error": "Errore di rete. Riprova fra poco." } } } diff --git a/apps/web/src/app/[locale]/profile/actions.ts b/apps/web/src/app/[locale]/profile/actions.ts index 8715ec6..ceb836d 100644 --- a/apps/web/src/app/[locale]/profile/actions.ts +++ b/apps/web/src/app/[locale]/profile/actions.ts @@ -42,6 +42,21 @@ export async function fetchProfile(): Promise { return data.profile; } +export async function deleteAccount( + password: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const cookie = headers().get('cookie') ?? ''; + const res = await fetch(`${API_URL}/me`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ password }), + cache: 'no-store', + }); + if (res.status === 204) return { ok: true }; + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; +} + export type SaveConditionsResult = { ok: true } | { ok: false; error: string }; export async function saveConditions(conditions: readonly string[]): Promise { diff --git a/apps/web/src/app/[locale]/profile/data-panel.tsx b/apps/web/src/app/[locale]/profile/data-panel.tsx new file mode 100644 index 0000000..51c99bd --- /dev/null +++ b/apps/web/src/app/[locale]/profile/data-panel.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useState, useTransition } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +import { deleteAccount } from './actions'; + +export function DataPanel() { + const t = useTranslations('Profile'); + const router = useRouter(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function handleDelete(): void { + setError(null); + startTransition(async () => { + const result = await deleteAccount(password); + if (!result.ok) { + setError(t(`dataDeleteErrors.${result.error}` as never) ?? t('dataDeleteErrors.api_error')); + return; + } + // Account cancellato: vai alla home pubblica. + router.replace('/'); + }); + } + + return ( +
+
+

{t('dataEyebrow')}

+

+ {t('dataTitle')} +

+

+ {t('dataSubtitle')} +

+
+ +
+ +
+ + +
+

{t('dataDeleteEyebrow')}

+

+ {t('dataDeleteDescription')} +

+ {!confirmOpen ? ( + + ) : ( +
+

{t('dataDeleteWarning')}

+ setPassword(e.target.value)} + placeholder={t('dataDeletePasswordPlaceholder')} + autoComplete="current-password" + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx index aab763c..8e34d54 100644 --- a/apps/web/src/app/[locale]/profile/page.tsx +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -7,6 +7,7 @@ import { Masthead } from '@/components/masthead'; import { getServerSession } from '@/lib/auth'; import { fetchProfile } from './actions'; +import { DataPanel } from './data-panel'; import { fetchNotificationConfig } from './notifications-actions'; import { NotificationsPanel } from './notifications-panel'; import { fetchPreferences } from './preferences-actions'; @@ -74,6 +75,12 @@ function ProfilePageContent({
+ +
+ +
+ +
); diff --git a/apps/web/src/app/api/gdpr-export/route.ts b/apps/web/src/app/api/gdpr-export/route.ts new file mode 100644 index 0000000..6bace27 --- /dev/null +++ b/apps/web/src/app/api/gdpr-export/route.ts @@ -0,0 +1,33 @@ +// Proxy verso /me/export.json (GDPR art. 20). + +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const API_URL = process.env.API_URL ?? 'http://localhost:4000'; + +export async function GET(): Promise { + const cookie = headers().get('cookie') ?? ''; + const upstream = await fetch(`${API_URL}/me/export.json`, { + headers: { cookie }, + cache: 'no-store', + }); + if (!upstream.ok) { + return NextResponse.json( + { error: `api_error_${upstream.status}` }, + { status: upstream.status }, + ); + } + const body = await upstream.text(); + return new NextResponse(body, { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': + upstream.headers.get('content-disposition') ?? + 'attachment; filename="ketopath-export.json"', + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/packages/db/prisma/seed-recipes.ts b/packages/db/prisma/seed-recipes.ts index 9a7235b..10b44a7 100644 --- a/packages/db/prisma/seed-recipes.ts +++ b/packages/db/prisma/seed-recipes.ts @@ -1130,4 +1130,866 @@ export const SEED_RECIPES: SeedRecipe[] = [ { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, ], }, + // ─── Espansione catalogo · round 2 (target 100) ────────────────────── + // COLAZIONE — 12 nuove + { + name: 'Bowl di yogurt e mandorle tostate', + category: 'COLAZIONE', + description: 'Yogurt greco con mandorle tostate in padella e cocco rapè.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 360, + proteinG: 19, + fatG: 26, + netCarbG: 9, + phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 200, unit: 'g' }, + { name: 'Mandorle', quantity: 25, unit: 'g' }, + { name: 'Cocco rapè non zuccherato', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Uova al tegamino con pomodorini', + category: 'COLAZIONE', + description: 'Due uova al tegamino con pomodorini saltati e basilico.', + prepMinutes: 7, + difficulty: 'FACILE', + kcal: 290, + proteinG: 17, + fatG: 22, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Pomodorini ciliegino', quantity: 80, unit: 'g' }, + { name: 'Basilico fresco', quantity: 5, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Frittata di asparagi e parmigiano', + category: 'COLAZIONE', + description: 'Frittata morbida con asparagi al vapore e parmigiano.', + prepMinutes: 12, + difficulty: 'FACILE', + kcal: 340, + proteinG: 25, + fatG: 24, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Asparagi', quantity: 80, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 20, unit: 'g' }, + ], + }, + { + name: 'Skyr con cannella e noci', + category: 'COLAZIONE', + description: 'Yogurt greco compatto con noci tritate e cannella.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 270, + proteinG: 20, + fatG: 16, + netCarbG: 7, + phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 200, unit: 'g' }, + { name: 'Noci', quantity: 15, unit: 'g' }, + { name: 'Cannella in polvere', quantity: 1, unit: 'cucchiaino' }, + ], + }, + { + name: 'Toast di pane di mandorle e burro', + category: 'COLAZIONE', + description: 'Pane proteico tostato con burro e una grattugiata di sale.', + prepMinutes: 4, + difficulty: 'FACILE', + kcal: 320, + proteinG: 10, + fatG: 25, + netCarbG: 8, + phases: [1, 2, 3], + ingredients: [ + { name: 'Pane proteico di mandorle', quantity: 60, unit: 'g' }, + { name: 'Burro', quantity: 15, unit: 'g' }, + ], + }, + { + name: 'Uova in camicia su rucola', + category: 'COLAZIONE', + description: "Due uova in camicia su un letto di rucola con un filo d'olio.", + prepMinutes: 10, + difficulty: 'MEDIA', + kcal: 260, + proteinG: 14, + fatG: 20, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Rucola', quantity: 50, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Crema di ricotta e cocco', + category: 'COLAZIONE', + description: 'Ricotta montata con cocco rapè e una spolverata di cannella.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 330, + proteinG: 16, + fatG: 25, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Ricotta vaccina', quantity: 150, unit: 'g' }, + { name: 'Cocco rapè non zuccherato', quantity: 15, unit: 'g' }, + { name: 'Cannella in polvere', quantity: 1, unit: 'cucchiaino' }, + ], + }, + { + name: 'Uova strapazzate con spinaci', + category: 'COLAZIONE', + description: 'Uova al burro con spinaci freschi saltati.', + prepMinutes: 8, + difficulty: 'FACILE', + kcal: 320, + proteinG: 20, + fatG: 24, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Spinaci freschi', quantity: 80, unit: 'g' }, + { name: 'Burro', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Smoothie di mascarpone e noci', + category: 'COLAZIONE', + description: 'Frullato denso di mascarpone, yogurt e noci tritate.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 410, + proteinG: 12, + fatG: 36, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mascarpone', quantity: 50, unit: 'g' }, + { name: 'Yogurt greco intero', quantity: 100, unit: 'g' }, + { name: 'Noci', quantity: 15, unit: 'g' }, + ], + }, + { + name: 'Frittata di funghi e prezzemolo', + category: 'COLAZIONE', + description: 'Frittata con champignon trifolati al prezzemolo.', + prepMinutes: 12, + difficulty: 'FACILE', + kcal: 310, + proteinG: 22, + fatG: 22, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Funghi champignon', quantity: 100, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Bocconcini di bufala e prosciutto', + category: 'COLAZIONE', + description: 'Mozzarella di bufala con prosciutto crudo arrotolato.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 380, + proteinG: 26, + fatG: 28, + netCarbG: 1, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mozzarella di bufala', quantity: 80, unit: 'g' }, + { name: 'Prosciutto crudo', quantity: 30, unit: 'g' }, + ], + }, + { + name: 'Yogurt e semi di chia', + category: 'COLAZIONE', + description: 'Yogurt greco con semi di chia ammollati e cannella.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 280, + proteinG: 17, + fatG: 16, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 200, unit: 'g' }, + { name: 'Semi di chia', quantity: 15, unit: 'g' }, + { name: 'Cannella in polvere', quantity: 1, unit: 'cucchiaino' }, + ], + }, + // PRANZO — 14 nuove + { + name: 'Tonno scottato con rucola e capperi', + category: 'PRANZO', + description: 'Trancio di tonno scottato in padella con rucola e capperi.', + prepMinutes: 12, + difficulty: 'MEDIA', + kcal: 470, + proteinG: 38, + fatG: 32, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: "Tonno sott'olio", quantity: 130, unit: 'g' }, + { name: 'Rucola', quantity: 60, unit: 'g' }, + { name: 'Capperi sotto sale', quantity: 1, unit: 'cucchiaio' }, + { name: 'Olio extravergine di oliva', quantity: 2, unit: 'cucchiaio' }, + ], + }, + { + name: 'Pollo arrosto con zucchine al forno', + category: 'PRANZO', + description: 'Cosce di pollo al forno con zucchine grigliate e limone.', + prepMinutes: 35, + difficulty: 'FACILE', + kcal: 510, + proteinG: 40, + fatG: 32, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 180, unit: 'g' }, + { name: 'Zucchine', quantity: 200, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: "Insalata greca all'italiana", + category: 'PRANZO', + description: 'Pomodorini, cetrioli, mozzarella di bufala, olive nere.', + prepMinutes: 8, + difficulty: 'FACILE', + kcal: 460, + proteinG: 22, + fatG: 36, + netCarbG: 7, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mozzarella di bufala', quantity: 100, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 120, unit: 'g' }, + { name: 'Cetrioli', quantity: 80, unit: 'g' }, + { name: 'Olive nere', quantity: 30, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Vitello tonnato light', + category: 'PRANZO', + description: 'Fettine di tacchino con salsa di tonno e capperi.', + prepMinutes: 15, + difficulty: 'MEDIA', + kcal: 440, + proteinG: 42, + fatG: 26, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 150, unit: 'g' }, + { name: "Tonno sott'olio", quantity: 60, unit: 'g' }, + { name: 'Capperi sotto sale', quantity: 1, unit: 'cucchiaio' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di gamberi e avocado', + category: 'PRANZO', + description: 'Gamberi cotti con avocado, rucola, olio EVO e limone.', + prepMinutes: 12, + difficulty: 'FACILE', + kcal: 430, + proteinG: 28, + fatG: 30, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Gamberi', quantity: 150, unit: 'g' }, + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Rucola', quantity: 60, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Pollo in padella con peperoni', + category: 'PRANZO', + description: 'Petto di pollo a tocchetti con peperoni saltati.', + prepMinutes: 20, + difficulty: 'FACILE', + kcal: 460, + proteinG: 38, + fatG: 28, + netCarbG: 7, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Peperoni', quantity: 200, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Sgombro grigliato con insalata', + category: 'PRANZO', + description: 'Sgombro alla griglia con insalata mista e limone.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 480, + proteinG: 32, + fatG: 36, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Sgombro', quantity: 150, unit: 'g' }, + { name: 'Insalata mista', quantity: 80, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Fettine di tacchino al limone', + category: 'PRANZO', + description: 'Tacchino in padella con limone, prezzemolo e burro.', + prepMinutes: 12, + difficulty: 'FACILE', + kcal: 410, + proteinG: 38, + fatG: 26, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 150, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Burro', quantity: 15, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di pollo, sedano e noci', + category: 'PRANZO', + description: 'Petto di pollo lessato con sedano croccante e noci.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 480, + proteinG: 36, + fatG: 32, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Cetrioli', quantity: 60, unit: 'g' }, + { name: 'Noci', quantity: 20, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Bresaola con caprino e rucola', + category: 'PRANZO', + description: 'Bresaola arrotolata su caprino spalmabile con rucola.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 380, + proteinG: 38, + fatG: 24, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Bresaola', quantity: 80, unit: 'g' }, + { name: 'Stracchino', quantity: 60, unit: 'g' }, + { name: 'Rucola', quantity: 50, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Salmone affumicato con avocado', + category: 'PRANZO', + description: 'Salmone affumicato con avocado e limone.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 470, + proteinG: 28, + fatG: 36, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Filetto di salmone', quantity: 120, unit: 'g' }, + { name: 'Avocado', quantity: 0.75, unit: 'pz' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + ], + }, + { + name: 'Insalata caprese rinforzata', + category: 'PRANZO', + description: 'Caprese con uova sode e olive nere.', + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 510, + proteinG: 28, + fatG: 40, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mozzarella di bufala', quantity: 100, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 100, unit: 'g' }, + { name: 'Uova', quantity: 1, unit: 'pz' }, + { name: 'Olive nere', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Tagliata di pollo con peperoni', + category: 'PRANZO', + description: 'Pollo a fettine con peperoni grigliati e olio EVO.', + prepMinutes: 18, + difficulty: 'FACILE', + kcal: 470, + proteinG: 40, + fatG: 28, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Peperoni', quantity: 150, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Salmone con cetrioli e dill', + category: 'PRANZO', + description: 'Salmone scottato con insalata di cetrioli e basilico.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 490, + proteinG: 34, + fatG: 34, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Filetto di salmone', quantity: 150, unit: 'g' }, + { name: 'Cetrioli', quantity: 100, unit: 'g' }, + { name: 'Basilico fresco', quantity: 5, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + // SPUNTINO — 12 nuove + { + name: 'Mandorle e parmigiano', + category: 'SPUNTINO', + description: 'Mix proteico di mandorle e cubetti di parmigiano.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 200, + proteinG: 12, + fatG: 16, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mandorle', quantity: 20, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + ], + }, + { + name: 'Bresaola arrotolata', + category: 'SPUNTINO', + description: 'Fettine di bresaola con un filo di olio EVO e limone.', + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 110, + proteinG: 18, + fatG: 4, + netCarbG: 0, + phases: [1, 2, 3], + ingredients: [ + { name: 'Bresaola', quantity: 50, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + { name: 'Limone', quantity: 0.25, unit: 'pz' }, + ], + }, + { + name: 'Yogurt con cocco', + category: 'SPUNTINO', + description: 'Mini bowl di yogurt con cocco rapè.', + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 160, + proteinG: 10, + fatG: 11, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 120, unit: 'g' }, + { name: 'Cocco rapè non zuccherato', quantity: 8, unit: 'g' }, + ], + }, + { + name: 'Cubetti di parmigiano e olive', + category: 'SPUNTINO', + description: 'Parmigiano stagionato con olive verdi.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 170, + proteinG: 11, + fatG: 13, + netCarbG: 1, + phases: [1, 2, 3], + ingredients: [ + { name: 'Parmigiano Reggiano', quantity: 30, unit: 'g' }, + { name: 'Olive verdi', quantity: 30, unit: 'g' }, + ], + }, + { + name: 'Avocado con sale e limone', + category: 'SPUNTINO', + description: "Mezzo avocado con un filo d'olio e limone.", + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 180, + proteinG: 2, + fatG: 17, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Limone', quantity: 0.25, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Uovo sodo', + category: 'SPUNTINO', + description: 'Un uovo sodo con un pizzico di sale.', + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 80, + proteinG: 7, + fatG: 6, + netCarbG: 0, + phases: [1, 2, 3], + ingredients: [{ name: 'Uova', quantity: 1, unit: 'pz' }], + }, + { + name: 'Pomodorini con bufala', + category: 'SPUNTINO', + description: 'Pomodorini freschi e bocconcini di bufala.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 180, + proteinG: 11, + fatG: 14, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mozzarella di bufala', quantity: 60, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 80, unit: 'g' }, + ], + }, + { + name: 'Noci e mandorle miste', + category: 'SPUNTINO', + description: 'Manciata mista di noci e mandorle.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 200, + proteinG: 6, + fatG: 18, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Noci', quantity: 15, unit: 'g' }, + { name: 'Mandorle', quantity: 15, unit: 'g' }, + ], + }, + { + name: 'Stracchino con olive', + category: 'SPUNTINO', + description: 'Stracchino spalmato su sedano con olive nere.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 180, + proteinG: 8, + fatG: 14, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Stracchino', quantity: 50, unit: 'g' }, + { name: 'Olive nere', quantity: 25, unit: 'g' }, + ], + }, + { + name: 'Frutti di bosco con yogurt', + category: 'SPUNTINO', + description: 'Mirtilli e lamponi con yogurt greco.', + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 130, + proteinG: 8, + fatG: 6, + netCarbG: 9, + phases: [2, 3], + ingredients: [ + { name: 'Frutti di bosco misti', quantity: 60, unit: 'g' }, + { name: 'Yogurt greco intero', quantity: 80, unit: 'g' }, + ], + }, + { + name: 'Cetrioli con tonno', + category: 'SPUNTINO', + description: 'Bastoncini di cetriolo con tonno schiacciato.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 130, + proteinG: 16, + fatG: 7, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Cetrioli', quantity: 100, unit: 'g' }, + { name: "Tonno sott'olio", quantity: 60, unit: 'g' }, + ], + }, + { + name: 'Prosciutto e melone keto', + category: 'SPUNTINO', + description: 'Prosciutto crudo arrotolato su pomodorini (versione keto).', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 160, + proteinG: 12, + fatG: 11, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Prosciutto crudo', quantity: 40, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 60, unit: 'g' }, + ], + }, + // CENA — 12 nuove + { + name: 'Pollo al limone e capperi', + category: 'CENA', + description: 'Pollo in padella con limone, capperi e prezzemolo.', + prepMinutes: 20, + difficulty: 'FACILE', + kcal: 470, + proteinG: 38, + fatG: 30, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Capperi sotto sale', quantity: 1, unit: 'cucchiaio' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Salmone con asparagi al burro', + category: 'CENA', + description: 'Salmone in padella con asparagi saltati al burro.', + prepMinutes: 18, + difficulty: 'FACILE', + kcal: 530, + proteinG: 36, + fatG: 38, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Filetto di salmone', quantity: 150, unit: 'g' }, + { name: 'Asparagi', quantity: 200, unit: 'g' }, + { name: 'Burro', quantity: 15, unit: 'g' }, + ], + }, + { + name: 'Tacchino al rosmarino con verdure', + category: 'CENA', + description: 'Petto di tacchino con erbe aromatiche e zucchine.', + prepMinutes: 25, + difficulty: 'FACILE', + kcal: 440, + proteinG: 42, + fatG: 26, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 180, unit: 'g' }, + { name: 'Zucchine', quantity: 200, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Branzino al sale con limone', + category: 'CENA', + description: 'Branzino intero al sale con limone e olio EVO.', + prepMinutes: 35, + difficulty: 'MEDIA', + kcal: 410, + proteinG: 40, + fatG: 26, + netCarbG: 1, + phases: [1, 2, 3], + ingredients: [ + { name: 'Branzino', quantity: 1, unit: 'pz' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Polpettone di pollo e ricotta', + category: 'CENA', + description: 'Polpettone al forno con macinato di pollo e ricotta.', + prepMinutes: 45, + difficulty: 'MEDIA', + kcal: 510, + proteinG: 44, + fatG: 32, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 180, unit: 'g' }, + { name: 'Ricotta vaccina', quantity: 80, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + { name: 'Uova', quantity: 1, unit: 'pz' }, + ], + }, + { + name: 'Sgombro al cartoccio', + category: 'CENA', + description: 'Sgombro al cartoccio con pomodorini e basilico.', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 480, + proteinG: 36, + fatG: 34, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Sgombro', quantity: 180, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 100, unit: 'g' }, + { name: 'Basilico fresco', quantity: 10, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di pollo tiepida', + category: 'CENA', + description: 'Pollo grigliato su rucola con scaglie di parmigiano.', + prepMinutes: 18, + difficulty: 'FACILE', + kcal: 470, + proteinG: 42, + fatG: 30, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Rucola', quantity: 80, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Tagliata con funghi', + category: 'CENA', + description: 'Tagliata di manzo con funghi trifolati al prezzemolo.', + prepMinutes: 20, + difficulty: 'MEDIA', + kcal: 560, + proteinG: 42, + fatG: 40, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Tagliata di manzo', quantity: 180, unit: 'g' }, + { name: 'Funghi champignon', quantity: 150, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Frittata di zucchine', + category: 'CENA', + description: 'Frittata con zucchine grigliate e parmigiano.', + prepMinutes: 18, + difficulty: 'FACILE', + kcal: 410, + proteinG: 28, + fatG: 30, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Zucchine', quantity: 150, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Pollo con broccoli al limone', + category: 'CENA', + description: 'Pollo a tocchetti con broccoli e limone.', + prepMinutes: 22, + difficulty: 'FACILE', + kcal: 460, + proteinG: 40, + fatG: 28, + netCarbG: 7, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Broccoli', quantity: 200, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Tagliata di tacchino con melanzane', + category: 'CENA', + description: 'Petto di tacchino spesso con melanzane grigliate.', + prepMinutes: 22, + difficulty: 'MEDIA', + kcal: 460, + proteinG: 42, + fatG: 28, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 180, unit: 'g' }, + { name: 'Melanzane', quantity: 150, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Cavolfiore gratinato con prosciutto', + category: 'CENA', + description: 'Cavolfiore al forno con prosciutto crudo e parmigiano.', + prepMinutes: 30, + difficulty: 'MEDIA', + kcal: 470, + proteinG: 26, + fatG: 36, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Cavolfiore', quantity: 250, unit: 'g' }, + { name: 'Prosciutto crudo', quantity: 50, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 30, unit: 'g' }, + { name: 'Burro', quantity: 10, unit: 'g' }, + ], + }, ];