feat: medical disclaimer onboarding + V1 + recipe schema (no migration yet)
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
50d68fc522
commit
f6423844da
9 files changed
+361
-1
No files matched your search
@@ -9,6 +9,20 @@
|
|||||||
"signOut": "Esci",
|
"signOut": "Esci",
|
||||||
"completeProfile": "Completa il tuo profilo"
|
"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": {
|
"CookieBanner": {
|
||||||
"aria": "Informativa cookie",
|
"aria": "Informativa cookie",
|
||||||
"message": "Usiamo solo cookie essenziali per l'autenticazione e la sessione. Niente analytics o tracker di terze parti.",
|
"message": "Usiamo solo cookie essenziali per l'autenticazione e la sessione. Niente analytics o tracker di terze parti.",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@ketopath/auth": "workspace:*",
|
"@ketopath/auth": "workspace:*",
|
||||||
|
"@ketopath/db": "workspace:*",
|
||||||
"@ketopath/shared": "workspace:*",
|
"@ketopath/shared": "workspace:*",
|
||||||
"@ketopath/ui": "workspace:*",
|
"@ketopath/ui": "workspace:*",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { prisma } from '@ketopath/db';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
@@ -13,6 +14,12 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo
|
|||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
if (!session?.user) redirect('/sign-in');
|
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<typeof ProfileForm>[0]['initial'];
|
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
|
|
||||||
return <ProfilePageContent initial={profile} />;
|
return <ProfilePageContent initial={profile} />;
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -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<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!allChecked) return;
|
||||||
|
setPending(true);
|
||||||
|
await acceptDisclaimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||||
|
<Checkbox checked={adult} onChange={setAdult} label={t('checkAdult')} />
|
||||||
|
<Checkbox checked={aware} onChange={setAware} label={t('checkAware')} />
|
||||||
|
<Checkbox checked={accepts} onChange={setAccepts} label={t('checkAccepts')} />
|
||||||
|
<Button type="submit" disabled={!allChecked || pending} className="mt-4 w-full">
|
||||||
|
{pending ? t('submitting') : t('submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="border-input text-primary focus-visible:ring-ring mt-0.5 h-4 w-4 rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <WelcomePageContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WelcomePageContent() {
|
||||||
|
const t = useTranslations('Welcome');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="space-y-3 text-sm">
|
||||||
|
<li>
|
||||||
|
<span className="font-medium">1. </span>
|
||||||
|
{t('point1')}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium">2. </span>
|
||||||
|
{t('point2')}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium">3. </span>
|
||||||
|
{t('point3')}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium">4. </span>
|
||||||
|
{t('point4')}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<DisclaimerForm />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<p className="text-muted-foreground text-xs">{t('legalNote')}</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,31 @@ enum FastingProtocol {
|
|||||||
FIVE_TWO
|
FIVE_TWO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FastStatus {
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
ABORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MealCategory {
|
||||||
|
COLAZIONE
|
||||||
|
PRANZO
|
||||||
|
SPUNTINO
|
||||||
|
CENA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Difficulty {
|
||||||
|
FACILE
|
||||||
|
MEDIA
|
||||||
|
ELABORATA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MealPlanStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -58,6 +83,9 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
image String?
|
image String?
|
||||||
role Role @default(USER)
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -65,6 +93,9 @@ model User {
|
|||||||
preferences Preferences?
|
preferences Preferences?
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
weightEntries WeightEntry[]
|
||||||
|
fastEvents FastEvent[]
|
||||||
|
mealPlans MealPlan[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -161,3 +192,145 @@ model Preferences {
|
|||||||
|
|
||||||
@@map("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")
|
||||||
|
}
|
||||||
@@ -2,10 +2,28 @@ export { prisma } from './client.js';
|
|||||||
export {
|
export {
|
||||||
ActivityLevel,
|
ActivityLevel,
|
||||||
CookingTime,
|
CookingTime,
|
||||||
|
Difficulty,
|
||||||
|
FastStatus,
|
||||||
FastingProtocol,
|
FastingProtocol,
|
||||||
Gender,
|
Gender,
|
||||||
|
MealCategory,
|
||||||
|
MealPlanStatus,
|
||||||
Phase,
|
Phase,
|
||||||
Prisma,
|
Prisma,
|
||||||
Role,
|
Role,
|
||||||
} from '@prisma/client';
|
} 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';
|
||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user