Files
ketopath/packages/db/prisma/schema.prisma
T
lucianoandClaude Opus 4.7 bda96a3550 feat: catalogo+30, varianti, sostituti, reintroduzione Fase 2, free meal Fase 3 (PRD §5.1)
Quattro completamenti del PRD §5.1: catalogo, varianti/sostituti, fase 2,
free meal.

Catalogo ricette 16 → 46
- seed-recipes.ts: +30 ricette italiane keto bilanciate
  (8 colazione · 8 pranzo · 6 spuntino · 8 cena)
- seed-ingredients.ts: 33 → 55 ingredienti
  (verdura/carne/pesce/latticini/condimenti + categorie nuove `legumi` e
  `cereali` per Fase 2)
- seed.ts: ora fa upsert anche degli ingredienti pre-esistenti per
  propagare campi nuovi (es. phase2Week)

Varianti + sostituti
- Recipe.variants Json (array { name, description, kcalDelta? })
- RecipeIngredient.substitutes String[]
- /recipes/[id]: nuova sezione "Varianti suggerite" + riga "Sostituzioni:"
  sotto ogni ingrediente quando presenti

Reintroduzione progressiva Fase 2
- Ingredient.phase2Week (settimana minima di reintroduzione, NULL=sempre)
- User.phase2StartedAt: settato automaticamente quando si passa a TRANSITION
- @ketopath/shared/planner/phase-progression: currentPhase2Week,
  isRecipeAllowedForPhaseWeek, weeksSince
- plan.routes filtra fuori le ricette con ingredienti non ancora reintrodotti
- POST /me/profile/phase per avanzare di fase (idempotente sul timestamp)
- 12 unit test su phase-progression
- Esempi seed: lenticchie phase2Week=3, ceci=4, mela=5, pera=6, quinoa=7

Free Meal pianificato Fase 3
- MealSlot.isFreeMeal Boolean (default false)
- POST /me/meal-plans/slots/:id/free-meal toggle, valido solo in MAINTENANCE
- @ketopath/shared/planner/phase-progression: freeMealKcalAdjustment
  (compensazione clamp -200/+50 kcal per pasto sui rimanenti)
- /plan: bottone ☆ in ogni SlotCard quando phase=MAINTENANCE; lo slot
  marcato come pasto libero rende "Pasto libero pianificato" + ~750 kcal
  stimate; macro tracker ne tiene conto

Indicatori di fase nel piano
- /plan ora include currentPhase + phase2Week dalla API
- Header del piano: "Settimana N di Fase 2" o "Mantenimento — Fase 3"

i18n: namespaces Plan + Recipe arricchiti

Schema migration 20260429XXXXXX_add_meal_plan_extras.

66/66 unit test verdi (12 nuovi). Smoke verificato live: API ritorna
correctly i campi nuovi, UI rende badge fase, bottoni ☆/↻, free-meal slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:18:47 +02:00

405 lines
13 KiB
Plaintext

// KetoPath — schema Prisma
// Schema iniziale: solo le 3 entità di base (User, Profile, Preferences).
// Le altre entità del PRD §8 (MealPlan, Recipe, WeightEntry, ecc.) verranno
// aggiunte in step successivi, allineate alle feature corrispondenti.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
COACH
}
enum Gender {
MALE
FEMALE
OTHER
}
enum ActivityLevel {
SEDENTARY
LIGHT
MODERATE
INTENSE
}
enum Phase {
INTENSIVE
TRANSITION
MAINTENANCE
}
enum CookingTime {
LOW
MEDIUM
HIGH
}
enum FastingProtocol {
FOURTEEN_TEN
SIXTEEN_EIGHT
EIGHTEEN_SIX
TWENTY_FOUR
ESE_24
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 {
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")
// PRD §5.1 — momento in cui l'utente è entrato nella fase 2 (TRANSITION).
// Serve a calcolare la "settimana di fase" per la reintroduzione progressiva.
phase2StartedAt DateTime? @map("phase2_started_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[]
deviceTokens DeviceToken[]
@@map("users")
}
// Better Auth — sessione utente firmata.
model Session {
id String @id @default(cuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// Better Auth — credenziali (email+password) o account OAuth.
// Per email+password il provider è 'credential' e la password (hash scrypt)
// è salvata nel campo `password`.
model Account {
id String @id @default(cuid())
userId String @map("user_id")
accountId String @map("account_id")
providerId String @map("provider_id")
accessToken String? @map("access_token")
refreshToken String? @map("refresh_token")
idToken String? @map("id_token")
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
scope String?
password String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([providerId, accountId])
@@map("accounts")
}
// Better Auth — token monouso (email verification, password reset, magic link).
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([identifier, value])
@@map("verifications")
}
model Profile {
id String @id @default(cuid())
userId String @unique @map("user_id")
age Int
gender Gender
heightCm Int @map("height_cm")
/// @encrypted
weightStartKg String @map("weight_start_kg")
/// @encrypted
weightCurrentKg String @map("weight_current_kg")
/// @encrypted
weightGoalKg String @map("weight_goal_kg")
activityLevel ActivityLevel @map("activity_level")
/// @encrypted
targetDate String? @map("target_date")
// PRD §5.1 — velocità desiderata di calo peso (kg/settimana). Determina il
// deficit calorico in modo dinamico al posto del valore hard-coded.
targetWeeklyLossKg Float? @map("target_weekly_loss_kg")
// PRD §14.3 — condizioni mediche dichiarate dall'utente. Filtrano ricette,
// modulano deficit/macros, e bloccano l'app se "escludenti" (gravidanza,
// allattamento, diabete tipo 1, disturbi alimentari). Cifrate at-rest come
// dato sanitario (art. 9 GDPR — vedi ADR 0002).
/// @encrypted
medicalConditions String? @map("medical_conditions")
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
/// @encrypted
bodyFatPct String? @map("body_fat_pct")
// PRD §5.1 — storia delle diete restrittive precedenti, per stimare
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
// disturbi alimentari (art. 9 GDPR).
/// @encrypted
dietHistory String? @map("diet_history")
currentPhase Phase @default(INTENSIVE) @map("current_phase")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
model Preferences {
id String @id @default(cuid())
userId String @unique @map("user_id")
exclusions String[] @default([])
cuisinePreferences String[] @default([]) @map("cuisine_preferences")
cookingTime CookingTime @default(MEDIUM) @map("cooking_time")
fastingProtocol FastingProtocol? @map("fasting_protocol")
notificationSettings Json @default("{}") @map("notification_settings")
// PRD §5.1 — schedule allenamento. trainingDays è Mon-anchored (0=Lun..6=Dom).
// Per i giorni in lista, il planner alza le kcal in base a tipo+durata.
trainingDays Int[] @default([]) @map("training_days")
trainingType String? @map("training_type") // CARDIO|STRENGTH|MIXED|SPORT
sessionMinutes Int? @map("session_minutes")
// PRD §5.1 — pasti/giorno preferiti (1..4). Se null, fallback al default.
// Il fastingProtocol ha precedenza: se attivo definisce lui la struttura.
mealsPerDay Int? @map("meals_per_day")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@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")
// PRD §5.1 — settimana di Fase 2 dalla quale l'ingrediente diventa
// disponibile (1=da subito, 5=quinta settimana, ecc.). NULL = sempre.
phase2Week Int? @map("phase2_week")
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")
// PRD §5.1 — varianti della ricetta (es. "versione vegetariana"). Array
// JSON di { name, description, kcalDelta? }. Schema applicativo zod-validato.
variants Json @default("[]")
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
// PRD §5.1 — suggerimenti di sostituzione per quell'ingrediente in questa
// ricetta. Stringhe libere ("ricotta intera", "tofu strapazzato"…).
substitutes String[] @default([])
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")
}
// PRD §5.6 — token di un dispositivo registrato per ricevere push.
// Il modello è agnostico rispetto alla piattaforma per facilitare l'estensione
// a iOS/Android (Expo/APNs/FCM) in futuro: vedi ADR 0003.
//
// `platform` è 'web' | 'ios' | 'android'. Per web valorizziamo endpoint+p256dh+auth;
// per iOS/Android valorizziamo `token` (device token nativo o Expo push token).
model DeviceToken {
id String @id @default(cuid())
userId String @map("user_id")
platform String
endpoint String?
p256dh String?
auth String?
token String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([userId])
@@map("device_tokens")
}
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")
// PRD §5.1 — "pasto libero" pianificato in Fase 3. Quando true, lo slot
// è un placeholder (no ricetta KetoPath, kcal stimate ~750) e il piano
// adatta gli altri pasti della settimana.
isFreeMeal Boolean @default(false) @map("is_free_meal")
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")
}