Chiude la decisione "payment provider" aperta in CLAUDE.md. **Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo. **Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe. **Backend** (`apps/api/src/modules/billing/`): - `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining) - `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection) - `POST /me/billing/portal` — Customer Portal Session - `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed` - Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking) **Shared** (`@ketopath/shared/billing/pro-status`): - `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo) - `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI - `computeTrialEndsAt(signupAt, days=30)` - 11 unit test **Frontend** (`apps/web/src/app/[locale]/billing/`): - Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits - `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro) - Nav item "Abbonamento" (chapter VI) nel grid asimmetrico - i18n IT completo (`Billing` namespace) **Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app. 99/99 test verdi, lint pulito su tutto il monorepo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
525 lines
19 KiB
Plaintext
525 lines
19 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
|
|
}
|
|
|
|
// PRD §16.3 / ADR 0004 — stato dell'abbonamento. Mappato 1:1 sullo stato
|
|
// Stripe ma mantenuto in DB come source-of-truth applicativo (l'API non
|
|
// chiama Stripe a ogni request — usa il valore qui).
|
|
enum SubscriptionStatus {
|
|
TRIALING // free 30gg post-signup, no carta richiesta
|
|
ACTIVE // pagante, in regola
|
|
PAST_DUE // pagamento fallito, in retry da Stripe (grace period)
|
|
CANCEL_AT_PERIOD_END // canceled ma valido fino a fine periodo corrente
|
|
CANCELED // canceled ed expired
|
|
EXPIRED // trial scaduto senza upgrade
|
|
}
|
|
|
|
enum BillingInterval {
|
|
MONTH
|
|
YEAR
|
|
}
|
|
|
|
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")
|
|
// PRD §5.3 — modalità "giorno libero" del digiuno: niente reminder, niente
|
|
// start automatico finché il timestamp non è passato. NULL = nessuna pausa.
|
|
fastingPausedUntil DateTime? @map("fasting_paused_until")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
profile Profile?
|
|
preferences Preferences?
|
|
subscription Subscription?
|
|
sessions Session[]
|
|
accounts Account[]
|
|
weightEntries WeightEntry[]
|
|
dailyCheckIns DailyCheckIn[]
|
|
fastEvents FastEvent[]
|
|
mealPlans MealPlan[]
|
|
deviceTokens DeviceToken[]
|
|
achievements Achievement[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
// ADR 0004 — abbonamento dell'utente. 1:1 con User. Lo stato è la verità
|
|
// applicativa: il webhook Stripe lo aggiorna, ma le route che gating-ano
|
|
// le feature leggono solo da qui (niente roundtrip Stripe).
|
|
model Subscription {
|
|
id String @id @default(cuid())
|
|
userId String @unique @map("user_id")
|
|
status SubscriptionStatus
|
|
// Trial nativo (30gg post-signup, no carta). Sempre valorizzato.
|
|
trialEndsAt DateTime @map("trial_ends_at")
|
|
// Stripe: presente solo dopo il primo upgrade (checkout completato).
|
|
stripeCustomerId String? @unique @map("stripe_customer_id")
|
|
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
|
stripePriceId String? @map("stripe_price_id")
|
|
// Periodo corrente (Pro). Riflette `current_period_end` di Stripe.
|
|
currentPeriodEnd DateTime? @map("current_period_end")
|
|
interval BillingInterval?
|
|
// Se `cancelAtPeriodEnd = true`, la sub è ancora ACTIVE ma non si rinnoverà.
|
|
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
|
|
// Quando lo status è effettivamente expired/canceled, archiviamo la data.
|
|
endedAt DateTime? @map("ended_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([status])
|
|
@@map("subscriptions")
|
|
}
|
|
|
|
// ADR 0004 — registro idempotenza dei webhook Stripe. Stripe ritenta gli
|
|
// eventi falliti, ricezione multipla è normale: scartiamo i duplicati su
|
|
// `event.id` invece di applicare due volte lo stesso aggiornamento.
|
|
model BillingWebhookEvent {
|
|
id String @id // event.id di Stripe (evt_...)
|
|
type String
|
|
receivedAt DateTime @default(now()) @map("received_at")
|
|
processedAt DateTime? @map("processed_at")
|
|
payload Json
|
|
|
|
@@index([type])
|
|
@@map("billing_webhook_events")
|
|
}
|
|
|
|
// PRD §6 — sistema achievement: badge sbloccati man mano che l'utente
|
|
// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.).
|
|
// `key` identifica univocamente l'achievement (vedi packages/shared).
|
|
model Achievement {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
key String
|
|
unlockedAt DateTime @default(now()) @map("unlocked_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, key])
|
|
@@index([userId])
|
|
@@map("achievements")
|
|
}
|
|
|
|
// 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.2 — soglia di allarme peso. Quando il peso corrente la supera,
|
|
// mostriamo un banner di "settimana di riparazione" in /tracking.
|
|
/// @encrypted
|
|
alertWeightKg String? @map("alert_weight_kg")
|
|
// 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")
|
|
// PRD §6 — esclusioni granulari per ingredient (override/aggiunte rispetto
|
|
// ai gruppi exclusionGroups). Memorizzate come array di Ingredient.id.
|
|
bannedIngredientIds String[] @default([]) @map("banned_ingredient_ids")
|
|
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).
|
|
// PRD §5.2 — check-in lampo quotidiano (10s): solo indicatori soggettivi.
|
|
// Distinto da WeightEntry (settimanale, con peso e misure).
|
|
model DailyCheckIn {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
date DateTime @db.Date
|
|
energy Int? // 1..10
|
|
sleep Int? // 1..10
|
|
hunger Int? // 1..10
|
|
mood Int? // 1..10
|
|
/// @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)
|
|
|
|
@@unique([userId, date])
|
|
@@index([userId, date])
|
|
@@map("daily_check_ins")
|
|
}
|
|
|
|
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?
|
|
// PRD §5.3 — milestone già notificate per evitare duplicati. Array di chiavi
|
|
// tipo "hydration_2h", "hydration_4h", "electrolyte_18h", "ending_soon".
|
|
notifiedMilestones String[] @default([]) @map("notified_milestones")
|
|
|
|
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")
|
|
// PRD §5.2 — aderenza al piano. L'utente spunta "consumato" sui pasti
|
|
// effettivamente fatti dal menu. Solo gli slot di giorni già passati
|
|
// contano nel calcolo dell'aderenza.
|
|
consumed Boolean @default(false)
|
|
consumedAt DateTime? @map("consumed_at")
|
|
|
|
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")
|
|
}
|