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 929abfa..0f6a777 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ