From f6423844daedde0d4bf813c8694a62c4d52fa29b Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 15:53:15 +0200 Subject: [PATCH] feat: medical disclaimer onboarding + V1 + recipe schema (no migration yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema changes (require a single migration to apply): - User gains disclaimerAcceptedAt — null until the user accepts the medical disclaimer, blocks /profile until set (PRD §14.3) - New WeightEntry (PRD §5.2) — weight/measurements/notes encrypted at rest, energy/sleep/hunger left in the clear so we can produce aggregate analytics - New FastEvent (PRD §5.3) — symptoms/notes encrypted, protocol/status/timer in the clear; indexed on (userId, startedAt) - New Ingredient / Recipe / RecipeIngredient (PRD §5.1) — italian keto recipe database with macros and exclusion groups, ready for the matchmaking algo - New MealPlan / MealSlot — generated weekly plan with selected recipe and alternatives per (day, meal) - New enums: FastStatus, MealCategory, Difficulty, MealPlanStatus Web — disclaimer flow: - /welcome page (server component, requires auth) lists the 4 PRD-mandated points and surfaces the 3 mandatory checkboxes; submit triggers a server action that stamps disclaimerAcceptedAt and redirects to /profile - /profile redirects to /welcome whenever disclaimerAcceptedAt is null, so the disclaimer becomes a hard prerequisite for any sensitive page - New Italian copy under Welcome.* in messages/it.json - @ketopath/db added to apps/web dependencies (was indirect via @ketopath/auth) @ketopath/db re-exports the new models and enums. Run the migration before testing: `pnpm db:migrate --name onboarding_v1_recipes`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/messages/it.json | 14 ++ apps/web/package.json | 1 + apps/web/src/app/[locale]/profile/page.tsx | 7 + apps/web/src/app/[locale]/welcome/actions.ts | 20 ++ .../app/[locale]/welcome/disclaimer-form.tsx | 58 +++++ apps/web/src/app/[locale]/welcome/page.tsx | 69 ++++++ packages/db/prisma/schema.prisma | 199 ++++++++++++++++-- packages/db/src/index.ts | 20 +- pnpm-lock.yaml | Bin 294153 -> 294246 bytes 9 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/[locale]/welcome/actions.ts create mode 100644 apps/web/src/app/[locale]/welcome/disclaimer-form.tsx create mode 100644 apps/web/src/app/[locale]/welcome/page.tsx diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 6337d6d..d3d6c17 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -9,6 +9,20 @@ "signOut": "Esci", "completeProfile": "Completa il tuo profilo" }, + "Welcome": { + "title": "Prima di iniziare", + "subtitle": "KetoPath è uno strumento di stile di vita, non un dispositivo medico. Conferma queste 4 cose prima di proseguire.", + "point1": "L'app fornisce solo suggerimenti di stile di vita basati sui parametri che inserisci.", + "point2": "Non diagnostica patologie, non prescrive trattamenti, non sostituisce il parere del tuo medico.", + "point3": "Se hai diabete di tipo 1, sei in gravidanza o allattamento, hai disturbi alimentari, problemi cardiaci o renali, parla prima con il tuo medico.", + "point4": "Prima di iniziare ti consigliamo esami del sangue di base (glicemia, colesterolo, profilo lipidico, funzionalità renale ed epatica).", + "checkAdult": "Confermo di essere maggiorenne.", + "checkAware": "Dichiaro di non avere condizioni mediche escludenti, oppure di averle discusse con il mio medico.", + "checkAccepts": "Accetto che KetoPath non sostituisce il parere medico professionale.", + "submit": "Accetto e proseguo", + "submitting": "Salvataggio in corso…", + "legalNote": "Il consenso è registrato con data e ora come previsto dall'art. 7 GDPR." + }, "CookieBanner": { "aria": "Informativa cookie", "message": "Usiamo solo cookie essenziali per l'autenticazione e la sessione. Niente analytics o tracker di terze parti.", diff --git a/apps/web/package.json b/apps/web/package.json index b6a4553..cacc8f5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "@ketopath/auth": "workspace:*", + "@ketopath/db": "workspace:*", "@ketopath/shared": "workspace:*", "@ketopath/ui": "workspace:*", "@radix-ui/react-label": "^2.1.0", diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx index 4e024eb..e800158 100644 --- a/apps/web/src/app/[locale]/profile/page.tsx +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -1,3 +1,4 @@ +import { prisma } from '@ketopath/db'; import { redirect } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; @@ -13,6 +14,12 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo const session = await getServerSession(); if (!session?.user) redirect('/sign-in'); + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { disclaimerAcceptedAt: true }, + }); + if (!user?.disclaimerAcceptedAt) redirect('/welcome'); + const profile = (await fetchProfile()) as Parameters[0]['initial']; return ; diff --git a/apps/web/src/app/[locale]/welcome/actions.ts b/apps/web/src/app/[locale]/welcome/actions.ts new file mode 100644 index 0000000..7edf075 --- /dev/null +++ b/apps/web/src/app/[locale]/welcome/actions.ts @@ -0,0 +1,20 @@ +'use server'; + +import { prisma } from '@ketopath/db'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; + +import { getServerSession } from '@/lib/auth'; + +export async function acceptDisclaimer(): Promise { + const session = await getServerSession(); + if (!session?.user) redirect('/sign-in'); + + await prisma.user.update({ + where: { id: session.user.id }, + data: { disclaimerAcceptedAt: new Date() }, + }); + + revalidatePath('/', 'layout'); + redirect('/profile'); +} diff --git a/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx b/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx new file mode 100644 index 0000000..a7c866f --- /dev/null +++ b/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState, type FormEvent } from 'react'; + +import { Button } from '@/components/ui/button'; + +import { acceptDisclaimer } from './actions'; + +export function DisclaimerForm() { + const t = useTranslations('Welcome'); + const [adult, setAdult] = useState(false); + const [aware, setAware] = useState(false); + const [accepts, setAccepts] = useState(false); + const [pending, setPending] = useState(false); + + const allChecked = adult && aware && accepts; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!allChecked) return; + setPending(true); + await acceptDisclaimer(); + } + + return ( +
+ + + + + + ); +} + +function Checkbox({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (next: boolean) => void; + label: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/[locale]/welcome/page.tsx b/apps/web/src/app/[locale]/welcome/page.tsx new file mode 100644 index 0000000..c9989ac --- /dev/null +++ b/apps/web/src/app/[locale]/welcome/page.tsx @@ -0,0 +1,69 @@ +import { prisma } from '@ketopath/db'; +import { redirect } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { setRequestLocale } from 'next-intl/server'; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { getServerSession } from '@/lib/auth'; + +import { DisclaimerForm } from './disclaimer-form'; + +export default async function WelcomePage({ params: { locale } }: { params: { locale: string } }) { + setRequestLocale(locale); + const session = await getServerSession(); + if (!session?.user) redirect('/sign-in'); + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { disclaimerAcceptedAt: true }, + }); + if (user?.disclaimerAcceptedAt) redirect('/profile'); + + return ; +} + +function WelcomePageContent() { + const t = useTranslations('Welcome'); + + return ( +
+ + + {t('title')} + {t('subtitle')} + + +
    +
  1. + 1. + {t('point1')} +
  2. +
  3. + 2. + {t('point2')} +
  4. +
  5. + 3. + {t('point3')} +
  6. +
  7. + 4. + {t('point4')} +
  8. +
+ +
+ +

{t('legalNote')}

+
+
+
+ ); +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c956aa7..41d2fa4 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -51,20 +51,51 @@ enum FastingProtocol { FIVE_TWO } -model User { - id String @id @default(cuid()) - email String @unique - emailVerified Boolean @default(false) @map("email_verified") - name String? - image String? - role Role @default(USER) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") +enum FastStatus { + IN_PROGRESS + COMPLETED + ABORTED +} - profile Profile? - preferences Preferences? - sessions Session[] - accounts Account[] +enum MealCategory { + COLAZIONE + PRANZO + SPUNTINO + CENA +} + +enum Difficulty { + FACILE + MEDIA + ELABORATA +} + +enum MealPlanStatus { + DRAFT + ACTIVE + ARCHIVED +} + +model User { + id String @id @default(cuid()) + email String @unique + emailVerified Boolean @default(false) @map("email_verified") + name String? + image String? + role Role @default(USER) + // PRD §14.3 — consenso esplicito al disclaimer medico. NULL finché l'utente + // non lo accetta; bloccante per le pagine di profilo/tracking. + disclaimerAcceptedAt DateTime? @map("disclaimer_accepted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + profile Profile? + preferences Preferences? + sessions Session[] + accounts Account[] + weightEntries WeightEntry[] + fastEvents FastEvent[] + mealPlans MealPlan[] @@map("users") } @@ -161,3 +192,145 @@ model Preferences { @@map("preferences") } + +// PRD §5.2 — pesata settimanale, misure, indicatori soggettivi. +// I campi sanitari sono cifrati at-rest (ADR 0002). +model WeightEntry { + id String @id @default(cuid()) + userId String @map("user_id") + date DateTime @db.Date + /// @encrypted + weightKg String @map("weight_kg") + /// @encrypted + measurements String? + /// @encrypted + notes String? + energy Int? + sleep Int? + hunger Int? + photos String[] @default([]) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, date]) + @@index([userId, date]) + @@map("weight_entries") +} + +// PRD §5.3 — sessione di digiuno intermittente con tracking sintomi. +model FastEvent { + id String @id @default(cuid()) + userId String @map("user_id") + protocol FastingProtocol + startedAt DateTime @map("started_at") + endedAt DateTime? @map("ended_at") + targetDuration Int @map("target_duration_minutes") + status FastStatus @default(IN_PROGRESS) + /// @encrypted + symptoms String? + /// @encrypted + notes String? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, startedAt]) + @@map("fast_events") +} + +// PRD §5.1 — database ricette italiane chetogeniche/low-carb. +model Ingredient { + id String @id @default(cuid()) + name String @unique + category String + defaultUnit String @map("default_unit") + kcalPer100g Float @map("kcal_per_100g") + proteinPer100g Float @map("protein_per_100g") + fatPer100g Float @map("fat_per_100g") + netCarbPer100g Float @map("net_carb_per_100g") + exclusionGroups String[] @default([]) @map("exclusion_groups") + priceAvgEur Float? @map("price_avg_eur") + + recipeIngredients RecipeIngredient[] + + @@map("ingredients") +} + +model Recipe { + id String @id @default(cuid()) + name String + category MealCategory + description String? + prepMinutes Int @map("prep_minutes") + difficulty Difficulty @default(FACILE) + cuisine String @default("italiana") + photoUrl String? @map("photo_url") + kcal Float + proteinG Float @map("protein_g") + fatG Float @map("fat_g") + netCarbG Float @map("net_carb_g") + phases Int[] @default([1, 2, 3]) + servings Int @default(1) + notesChef String? @map("notes_chef") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + ingredients RecipeIngredient[] + selectedSlots MealSlot[] @relation("SelectedRecipe") + alternativeFor MealSlot[] @relation("AlternativeRecipes") + + @@index([category]) + @@map("recipes") +} + +model RecipeIngredient { + id String @id @default(cuid()) + recipeId String @map("recipe_id") + ingredientId String @map("ingredient_id") + quantity Float + unit String + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + ingredient Ingredient @relation(fields: [ingredientId], references: [id]) + + @@unique([recipeId, ingredientId]) + @@map("recipe_ingredients") +} + +// PRD §5.1 — piano settimanale generato dall'algoritmo di matchmaking. +model MealPlan { + id String @id @default(cuid()) + userId String @map("user_id") + weekStart DateTime @map("week_start") @db.Date + status MealPlanStatus @default(ACTIVE) + generatedAt DateTime @default(now()) @map("generated_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + slots MealSlot[] + + @@unique([userId, weekStart]) + @@index([userId, weekStart]) + @@map("meal_plans") +} + +model MealSlot { + id String @id @default(cuid()) + planId String @map("plan_id") + dayOfWeek Int @map("day_of_week") + meal MealCategory + recipeId String? @map("recipe_id") + status String @default("pending") + + plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + selected Recipe? @relation("SelectedRecipe", fields: [recipeId], references: [id]) + alternatives Recipe[] @relation("AlternativeRecipes") + + @@unique([planId, dayOfWeek, meal]) + @@map("meal_slots") +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf10914..bd0ce9a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,10 +2,28 @@ export { prisma } from './client.js'; export { ActivityLevel, CookingTime, + Difficulty, + FastStatus, FastingProtocol, Gender, + MealCategory, + MealPlanStatus, Phase, Prisma, Role, } from '@prisma/client'; -export type { Account, Preferences, Profile, Session, User, Verification } from '@prisma/client'; +export type { + Account, + FastEvent, + Ingredient, + MealPlan, + MealSlot, + Preferences, + Profile, + Recipe, + RecipeIngredient, + Session, + User, + Verification, + WeightEntry, +} from '@prisma/client'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 929abfab136d9ec4133a9411dabc7c34ef03e563..0f6a777b6a7d281d605b94c6930f264b55178f6a 100644 GIT binary patch delta 42 vcmeDDCHU-@;D$!t$(y8XC&#n%Og_b4(0rG-{Vp#f5HkTW^Y**EEIRoBl%o+p delta 30 jcmaF%OR)2o;D$!t=6k&D_jnnBmBt8F;b;vj