# DATABASE_DESIGN.md — FranCoach

Documento di progettazione del database MySQL 8 (Modulo 2). Copre le 36 entità richieste, la strategia degli identificativi, le politiche di cancellazione, gli indici, gli enum PHP e il versionamento delle schede. Non include controller/view (fuori perimetro di questo modulo).

## Strategia generale

### Identificativi (BIGINT + ULID ibrido)

Strategia coerente unica per tutto lo schema:

- **Chiave primaria interna**: `BIGINT UNSIGNED AUTO_INCREMENT` su ogni tabella. Usata per tutte le foreign key. Scelta per compatibilità piena con MySQL 8/hosting condiviso, join più leggeri (8 byte vs 26 caratteri), indici più compatti su uno schema con decine di tabelle collegate.
- **Identificatore pubblico ULID** (`CHAR(26)`, colonna `ulid`, unique, generato in `booted()` del model tramite il trait `App\Models\Concerns\HasUlid`): aggiunto solo alle entità che potranno essere esposte in URL/deep-link o che contengono dati sensibili per cui l'enumerabilità dell'ID sequenziale sarebbe un rischio (IDOR). Tabelle con `ulid`: `users`, `workout_sessions`, `conversations`, `messages`, `progress_photos`, `notifications`, `data_export_requests`, `account_deletion_requests`.
- Le foreign key restano sempre sul `BIGINT` interno, mai sull'ULID (l'ULID è solo per l'esposizione esterna futura, es. `Route::model` con binding su `ulid`).

### Soft delete

Applicato solo dove ha un reale valore (recuperabilità, storicità, GDPR):

`users`, `exercises`, `workout_plans`, `workout_plan_versions`. Le tabelle "storiche/di composizione" (settimane, giorni, esercizi di scheda, set, sessioni, set di sessione) NON hanno soft delete proprio: sono sempre subordinate a un genitore (settimana → versione, set → esercizio di sessione, ecc.) che già gestisce soft delete/versionamento al livello giusto; l'eliminazione "morbida" andrebbe duplicata inutilmente a ogni livello.

### Enum PHP invece di ENUM MySQL

Tutti i campi "a scelta chiusa" sono colonne `VARCHAR` (lunghezza adeguata) validate e castate tramite **enum PHP backed (string)** lato applicativo (`app/Enums/*.php`), mai `ENUM(...)` nativo MySQL. Motivazione: un `ALTER TABLE ... MODIFY ENUM` per aggiungere un valore è un'operazione bloccante/rischiosa su tabelle popolate, mentre un enum PHP si estende senza migration; inoltre Laravel castane nativamente gli enum backed nei model (`casts()`), dando type-safety in PHP che l'`ENUM` di MySQL non offre.

### JSON solo dove utile

Usato solo dove la struttura è realmente variabile: metadati video, payload notifiche, impostazioni, diff audit, snapshot immutabili delle sessioni, pianificazione settimanale e segmenti dropset. I dati con struttura stabile restano in colonne tipizzate.

## Aggiornamenti schema del 28 luglio 2026

- `exercises`: aggiunti `execution_gif_path`, `media_attribution`, `external_ref`; normalizzata `difficulty`.
- `workout_plan_sets`: aggiunto `drop_segments`.
- `workout_session_sets`: aggiunti `is_manual`, `planned_drop_segments`, `drop_segments`.
- `workout_sessions`: aggiunti `recovery_seconds` e `active_duration_seconds`.
- `workout_session_exercise_media`: nuova tabella per foto/video privati associati allo specifico esercizio di una sessione.
- `scheduled_workouts`: rimosso il vincolo che impediva la ripetizione dello stesso giorno del piano nelle settimane successive.
- `food_items`: nuovo catalogo alimenti del coach.
- `nutrition_meal_items` e `nutrition_meal_item_alternatives`: aggiunto `food_item_id` nullable.
- `workout_plans`, `workout_plan_assignments`, `workout_sessions`, `nutrition_plans`, `nutrition_plan_assignments`, `measurements`, `food_items`: aggiunto `is_demo` indicizzato.

### DECIMAL per valori corporei e carichi

Mai `FLOAT`/`DOUBLE` per pesi, misure, RPE: sempre `DECIMAL(p,s)` per evitare errori di arrotondamento binario su valori confrontati/sommati (es. storico peso corporeo, carichi allenamento).

### Politica delle foreign key (cascade / restrict / set null)

Regola coerente applicata a tutto lo schema:

| Ruolo della relazione | Esempio | Comportamento |
|---|---|---|
| **Proprietario del dato** (il record appartiene biograficamente a quell'utente) | `athlete_profiles.user_id`, `measurements.athlete_id`, `progress_photos.athlete_id`, `workout_sessions.athlete_id` | `ON DELETE CASCADE` — se l'account viene definitivamente cancellato (`account_deletion_requests` completata), i suoi dati vanno via con lui. |
| **Autore/attore** (chi ha creato/inviato il record, ma il record "appartiene" ad altro/a nessuno) | `athlete_goals.created_by`, `workout_plan_versions.created_by`, `messages.sender_id`, `audit_logs.user_id` | Colonna **nullable** + `ON DELETE SET NULL` — la cancellazione dell'autore non deve far sparire lo storico (messaggi, log, piani), lo rende solo "anonimo". |
| **Composizione/aggregato** (il figlio non ha senso senza il genitore diretto) | `workout_plan_weeks.workout_plan_version_id`, `workout_session_sets.workout_session_exercise_id`, `messages.conversation_id`, `message_attachments.message_id` | `ON DELETE CASCADE` — cancellare il contenitore cancella i suoi componenti. |
| **Riferimento a catalogo/anagrafica** (entità di riferimento condivisa) | `workout_plan_exercises.exercise_id`, `exercise_muscle_group.muscle_group_id` | `ON DELETE RESTRICT` — non si può cancellare un esercizio/gruppo muscolare in uso; va prima disattivato/soft-deleted. Dove il riferimento è puramente opzionale (`exercises.equipment_id`, `athlete_goals.measurement_type_id`) → nullable + `SET NULL`. |
| **Riferimento incrociato opzionale fra aggregati** | `workout_session_exercises.workout_plan_exercise_id`, `scheduled_workouts.workout_session_id`, `workout_sessions.scheduled_workout_id` | nullable + `SET NULL` — lo storico eseguito sopravvive anche se il collegamento al piano/allo slot pianificato viene meno. |

Limiti noti e mitigazioni applicative (documentati esplicitamente, non nascosti):

1. **"Un solo gruppo muscolare primario per esercizio"**: MySQL non supporta indici unique parziali; il vincolo `exercise_muscle_group` unique è su `(exercise_id, muscle_group_id)` (niente duplicati), ma "massimo un `role = primary`" andrà validato lato applicativo (FormRequest/Service) nel modulo "gestione esercizi".
2. **"Un solo setting globale per chiave"**: `settings.user_id` nullable fa sì che MySQL non consideri due `NULL` come duplicati in un indice unique; l'unicità delle chiavi globali (`user_id IS NULL`) va quindi garantita a livello applicativo (poche chiavi, gestite da un service dedicato), non dal DB.
3. **`consent_records`/`account_deletion_requests` con `ON DELETE CASCADE` su `user_id`**: per un'app reale con obblighi legali di conservazione delle prove di consenso anche dopo la cancellazione dell'account, si dovrebbe valutare l'anonimizzazione invece della cancellazione a cascata. Per lo scope attuale (2 utenti, nessun obbligo legale formalizzato) si è scelto CASCADE per semplicità; segnalato qui come punto da rivedere con un legale/DPO prima di un lancio pubblico.

---

## Elenco tabelle

### 1. `users` (ALTER — tabella esistente dal Modulo 1)

| Colonna | Tipo | Null | Default | Note |
|---|---|---|---|---|
| `ulid` | CHAR(26) | NO | — | generato alla creazione, `UNIQUE` |
| `role` | VARCHAR(20) | NO | `athlete` | `App\Enums\UserRole` |
| `is_active` | BOOLEAN | NO | `true` | sospensione account senza cancellare |
| `last_login_at` | TIMESTAMP | YES | NULL | supporto audit/sicurezza |
| `deleted_at` | TIMESTAMP | YES | NULL | soft delete |
| `first_name` | VARCHAR(100) | YES | NULL | *(Modulo 3)* |
| `last_name` | VARCHAR(100) | YES | NULL | *(Modulo 3)* |
| `display_name` | VARCHAR(100) | YES | NULL | *(Modulo 3)* nome mostrato in UI se diverso dal nome anagrafico |
| `avatar_path` | VARCHAR(255) | YES | NULL | *(Modulo 3)* percorso su disco `public`, non ancora gestito da un form di upload |
| `phone` | VARCHAR(30) | YES | NULL | *(Modulo 3)* |
| `date_of_birth` | DATE | YES | NULL | *(Modulo 3, vedi nota sotto)* |
| `gender` | VARCHAR(20) | YES | NULL | *(Modulo 3, vedi nota sotto)* `App\Enums\Gender` |
| `height_cm` | DECIMAL(5,2) | YES | NULL | *(Modulo 3, vedi nota sotto)* |
| `last_login_ip` | VARCHAR(45) | YES | NULL | *(Modulo 3)* audit, non usato per logica di blocco |

Indici: `UNIQUE(ulid)`, `INDEX(role)`, `INDEX(deleted_at)`.
Motivazione: `role` guida tutta l'autorizzazione applicativa; `is_active` permette al coach di sospendere l'accesso di un atleta senza perdere lo storico; il soft delete è il presupposto tecnico di `account_deletion_requests` (periodo di grazia prima della cancellazione fisica).

> **Nota Modulo 3 (autenticazione):** `date_of_birth`, `gender` e `height_cm` erano stati collocati nel Modulo 2 su `athlete_profiles` (`birth_date`/`gender`/`height_cm`), assumendo che riguardassero solo l'atleta. Il Modulo 3 richiede esplicitamente questi stessi dati su `users`, applicabili a QUALSIASI utente (anche il coach). Per evitare due colonne scollegate con lo stesso significato, sono stati spostati definitivamente su `users` (migration `2026_03_01_000000`) e rimossi da `athlete_profiles` (migration `2026_03_01_000001`, con backfill automatico degli eventuali valori già presenti). `coach_id`, `activity_level` e `started_coaching_on` restano invece su `athlete_profiles`: sono attributi del rapporto di coaching, non della persona.

### 2. `athlete_profiles`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE, UNIQUE |
| `coach_id` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `activity_level` | VARCHAR(20) | YES | NULL | `App\Enums\ActivityLevel` |
| `started_coaching_on` | DATE | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(user_id)`, `INDEX(coach_id)`.
Motivazione: attributi propri del rapporto di coaching (chi segue chi, da quando, con quale livello di attività), non della persona in generale. `birth_date`/`gender`/`height_cm` sono stati rimossi da qui nel Modulo 3 e vivono ora su `users` (vedi nota sopra): così `users` resta la fonte unica di verità per i dati anagrafici di base, riusabile anche per ruoli futuri (es. un secondo coach) senza doverli duplicare per ogni tipo di profilo.

### 3. `athlete_goals`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `measurement_type_id` | BIGINT UNSIGNED | YES | NULL | `measurement_types.id` SET NULL |
| `created_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `goal_type` | VARCHAR(30) | NO | | `App\Enums\GoalType` |
| `title` | VARCHAR(150) | NO | | |
| `starting_value` | DECIMAL(8,2) | YES | NULL | |
| `target_value` | DECIMAL(8,2) | YES | NULL | |
| `target_date` | DATE | YES | NULL | |
| `status` | VARCHAR(20) | NO | `active` | `App\Enums\GoalStatus` |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(athlete_id, status)`.

### 4. `athlete_notes`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `author_id` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `category` | VARCHAR(20) | NO | `general` | `App\Enums\NoteCategory` |
| `body` | TEXT | NO | | |
| `is_private` | BOOLEAN | NO | `true` | visibile solo al coach |
| timestamps | | | | |

Indici: `INDEX(athlete_id, category)`.

### 5. `athlete_limitations`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `muscle_group_id` | BIGINT UNSIGNED | YES | NULL | `muscle_groups.id` SET NULL |
| `title` | VARCHAR(150) | NO | | |
| `description` | TEXT | YES | NULL | |
| `severity` | VARCHAR(20) | NO | `moderate` | `App\Enums\LimitationSeverity` |
| `status` | VARCHAR(20) | NO | `active` | `App\Enums\LimitationStatus` |
| `started_on` | DATE | YES | NULL | |
| `resolved_on` | DATE | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(athlete_id, status)`.
Motivazione: usata dal futuro modulo esercizi/schede per segnalare controindicazioni (es. "spalla destra" → evitare esercizi che la coinvolgono come primaria).

### 6. `equipment`

| Colonna | Tipo | Null | Default |
|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | |
| `name` | VARCHAR(100) | NO | |
| `slug` | VARCHAR(100) | NO | UNIQUE |
| `category` | VARCHAR(20) | NO | `App\Enums\EquipmentCategory` |
| `description` | TEXT | YES | NULL |
| timestamps | | | |

### 7. `athlete_equipment`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `equipment_id` | BIGINT UNSIGNED | NO | | `equipment.id` CASCADE |
| `quantity` | SMALLINT UNSIGNED | YES | NULL | |
| `notes` | VARCHAR(255) | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(athlete_id, equipment_id)`.

### 8. `muscle_groups`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `parent_id` | BIGINT UNSIGNED | YES | NULL | `muscle_groups.id` SET NULL (self) |
| `name` | VARCHAR(100) | NO | | |
| `slug` | VARCHAR(100) | NO | UNIQUE | |
| `body_region` | VARCHAR(20) | NO | | `App\Enums\MuscleBodyRegion` |
| timestamps | | | | |

Indici: `UNIQUE(slug)`, `INDEX(parent_id)`, `INDEX(body_region)`.

### 9. `exercise_categories`

| Colonna | Tipo | Null | Default |
|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | |
| `name` | VARCHAR(100) | NO | |
| `slug` | VARCHAR(100) | NO | UNIQUE |
| `description` | TEXT | YES | NULL |
| timestamps | | | |

### 10. `exercises`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `exercise_category_id` | BIGINT UNSIGNED | YES | NULL | `exercise_categories.id` SET NULL |
| `equipment_id` | BIGINT UNSIGNED | YES | NULL | `equipment.id` SET NULL |
| `title` | VARCHAR(150) | NO | | |
| `slug` | VARCHAR(170) | NO | UNIQUE | |
| `description` | TEXT | YES | NULL | |
| `instructions` | TEXT | YES | NULL | |
| `mistakes_to_avoid` | TEXT | YES | NULL | |
| `coach_notes` | TEXT | YES | NULL | |
| `level` | VARCHAR(20) | NO | `beginner` | `App\Enums\ExerciseLevel` |
| `cover_image_path` | VARCHAR(255) | YES | NULL | disco `public` |
| `execution_gif_path` | VARCHAR(255) | YES | NULL | GIF animata di esecuzione su disco `public` |
| `media_attribution` | VARCHAR(255) | YES | NULL | attribuzione opzionale per import esterni |
| `external_ref` | VARCHAR(100) | YES | NULL | riferimento import univoco |
| `video_url` | VARCHAR(500) | YES | NULL | URL originale inserito |
| `video_provider` | VARCHAR(20) | YES | NULL | `App\Enums\VideoProvider` |
| `video_external_id` | VARCHAR(150) | YES | NULL | id del contenuto sul provider |
| `video_embed_url` | VARCHAR(500) | YES | NULL | URL embed ricavato |
| `video_metadata` | JSON | YES | NULL | risposta oEmbed/dati provider |
| `video_is_embeddable` | BOOLEAN | NO | `true` | fallback se il provider blocca l'embed |
| `video_last_checked_at` | TIMESTAMP | YES | NULL | ultimo controllo embeddability |
| `is_active` | BOOLEAN | NO | `true` | |
| `deleted_at` | TIMESTAMP | YES | NULL | soft delete |
| timestamps | | | | |

Indici: `UNIQUE(slug)`, `INDEX(exercise_category_id)`, `INDEX(equipment_id)`, `INDEX(level)`, `INDEX(is_active)`, `INDEX(video_provider)`.
Motivazione campi video: nessun campo per singolo provider (`instagram_video_url` ecc.) — un solo set di colonne generiche riusabile per qualunque provider attuale o futuro, come richiesto. Soft delete + FK `RESTRICT` a monte (da `workout_plan_exercises`) permettono di "disattivare" un esercizio storicizzato senza romperne i riferimenti nelle schede passate.

### 11. `exercise_muscle_group`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `exercise_id` | BIGINT UNSIGNED | NO | | `exercises.id` CASCADE |
| `muscle_group_id` | BIGINT UNSIGNED | NO | | `muscle_groups.id` RESTRICT |
| `role` | VARCHAR(10) | NO | `secondary` | `App\Enums\ExerciseMuscleRole` |
| timestamps | | | | |

Indici: `UNIQUE(exercise_id, muscle_group_id)`.
Nota: `exercise_id` in CASCADE (se l'esercizio "genitore" della relazione viene rimosso, la riga pivot non ha senso), `muscle_group_id` in RESTRICT (un gruppo muscolare di catalogo non si cancella se referenziato).

### 12. `workout_plans`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `created_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `name` | VARCHAR(150) | NO | | |
| `description` | TEXT | YES | NULL | |
| `is_template` | BOOLEAN | NO | `false` | riutilizzabile per più atleti in futuro |
| `status` | VARCHAR(20) | NO | `draft` | `App\Enums\PlanStatus` |
| `deleted_at` | TIMESTAMP | YES | NULL | soft delete |
| timestamps | | | | |

Indici: `INDEX(status)`, `INDEX(is_template)`.
Motivazione: `workout_plans` è il contenitore logico stabile (per esempio "Piano Ipertrofia"); il contenuto vero e proprio vive nelle versioni (vedi sotto), così una modifica non tocca mai l'assegnazione già in corso.

### 13. `workout_plan_versions`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_id` | BIGINT UNSIGNED | NO | | `workout_plans.id` CASCADE |
| `created_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `version_number` | SMALLINT UNSIGNED | NO | | |
| `status` | VARCHAR(20) | NO | `draft` | `App\Enums\PlanVersionStatus` |
| `is_current` | BOOLEAN | NO | `false` | vedi nota sotto |
| `change_notes` | TEXT | YES | NULL | |
| `published_at` | TIMESTAMP | YES | NULL | |
| `deleted_at` | TIMESTAMP | YES | NULL | soft delete |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_id, version_number)`, `INDEX(workout_plan_id, is_current)`.
Motivazione (cuore del versionamento): ogni modifica sostanziale al piano crea una **nuova riga qui**, mai un update distruttivo della precedente. `workout_plan_assignments` punta a una versione specifica e immutabile: modificare il piano non altera assegnazioni, `scheduled_workouts` o `workout_sessions` già generati/eseguiti, perché tutti risalgono, tramite `workout_plan_day_id`, a una versione congelata nel tempo. Il vincolo "una sola `is_current = true` per piano" non è enforceable con un indice unique parziale in MySQL 8: va garantito a livello applicativo (service di pubblicazione versione).

### 14. `workout_plan_weeks`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_version_id` | BIGINT UNSIGNED | NO | | `workout_plan_versions.id` CASCADE |
| `week_number` | SMALLINT UNSIGNED | NO | | |
| `name` | VARCHAR(100) | YES | NULL | |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_version_id, week_number)`.

### 15. `workout_plan_days`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_week_id` | BIGINT UNSIGNED | NO | | `workout_plan_weeks.id` CASCADE |
| `day_number` | SMALLINT UNSIGNED | NO | | |
| `name` | VARCHAR(150) | NO | | es. "Mobilità Upper" |
| `notes` | TEXT | YES | NULL | |
| `is_rest_day` | BOOLEAN | NO | `false` | |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_week_id, day_number)`.

### 16. `workout_plan_exercises`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_day_id` | BIGINT UNSIGNED | NO | | `workout_plan_days.id` CASCADE |
| `exercise_id` | BIGINT UNSIGNED | NO | | `exercises.id` RESTRICT |
| `order_index` | SMALLINT UNSIGNED | NO | | |
| `superset_group` | VARCHAR(10) | YES | NULL | raggruppa esercizi in superset/circuito |
| `rest_seconds` | SMALLINT UNSIGNED | YES | NULL | |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_day_id, order_index)`, `INDEX(exercise_id)`.

### 17. `workout_plan_sets`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_exercise_id` | BIGINT UNSIGNED | NO | | `workout_plan_exercises.id` CASCADE |
| `set_number` | SMALLINT UNSIGNED | NO | | |
| `set_type` | VARCHAR(20) | NO | `normal` | `App\Enums\SetType` |
| `reps_min` | SMALLINT UNSIGNED | YES | NULL | |
| `reps_max` | SMALLINT UNSIGNED | YES | NULL | |
| `drop_segments` | JSON | YES | NULL | sequenza REPS/carico per serie dropset |
| `target_weight_kg` | DECIMAL(6,2) | YES | NULL | |
| `target_time_seconds` | INT UNSIGNED | YES | NULL | |
| `rest_seconds` | SMALLINT UNSIGNED | YES | NULL | |
| `rpe_target` | DECIMAL(3,1) | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_exercise_id, set_number)`.

### 18. `workout_plan_assignments`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_version_id` | BIGINT UNSIGNED | NO | | `workout_plan_versions.id` RESTRICT |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `assigned_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `start_date` | DATE | NO | | |
| `end_date` | DATE | YES | NULL | |
| `status` | VARCHAR(20) | NO | `active` | `App\Enums\AssignmentStatus` |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(athlete_id, status)`, `INDEX(workout_plan_version_id)`.
Motivazione: collega una **versione specifica e congelata** del piano all'atleta per un periodo; è questo il punto in cui il versionamento diventa concreto.

### 19. `scheduled_workouts`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_plan_assignment_id` | BIGINT UNSIGNED | NO | | `workout_plan_assignments.id` CASCADE |
| `workout_plan_day_id` | BIGINT UNSIGNED | NO | | `workout_plan_days.id` RESTRICT |
| `workout_session_id` | BIGINT UNSIGNED | YES | NULL | `workout_sessions.id` SET NULL (aggiunta in migration successiva, vedi nota) |
| `scheduled_date` | DATE | NO | | |
| `status` | VARCHAR(20) | NO | `pending` | `App\Enums\ScheduledWorkoutStatus` |
| timestamps | | | | |

Indici: `UNIQUE(workout_plan_assignment_id, workout_plan_day_id, scheduled_date)`, `INDEX(scheduled_date, status)`.
Nota tecnica: la colonna `workout_session_id` crea una dipendenza circolare con `workout_sessions` (che a sua volta referenzia `scheduled_workouts`). Risolta con **due migration**: la creazione di `scheduled_workouts` senza questa colonna, poi `workout_sessions`, poi una migration `ALTER` che aggiunge `workout_session_id` a `scheduled_workouts` — tecnica standard Laravel per FK reciproche.

### 20. `workout_sessions`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `scheduled_workout_id` | BIGINT UNSIGNED | YES | NULL | `scheduled_workouts.id` SET NULL |
| `started_at` | TIMESTAMP | NO | | |
| `ended_at` | TIMESTAMP | YES | NULL | |
| `recovery_seconds` | INT UNSIGNED | NO | `0` | recupero accumulato |
| `active_duration_seconds` | INT UNSIGNED | YES | NULL | durata totale meno recuperi |
| `status` | VARCHAR(20) | NO | `in_progress` | `App\Enums\SessionStatus` |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(athlete_id, started_at)`, `INDEX(scheduled_workout_id)`.
Motivazione: `scheduled_workout_id` nullable → supporta sia sessioni pianificate sia allenamenti "liberi" non legati a una scheda.

### 21. `workout_session_exercises`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_session_id` | BIGINT UNSIGNED | NO | | `workout_sessions.id` CASCADE |
| `exercise_id` | BIGINT UNSIGNED | NO | | `exercises.id` RESTRICT |
| `workout_plan_exercise_id` | BIGINT UNSIGNED | YES | NULL | `workout_plan_exercises.id` SET NULL |
| `order_index` | SMALLINT UNSIGNED | NO | | |
| `notes` | TEXT | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(workout_session_id, order_index)`, `INDEX(exercise_id)`.

### 22. `workout_session_sets`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_session_exercise_id` | BIGINT UNSIGNED | NO | | `workout_session_exercises.id` CASCADE |
| `set_number` | SMALLINT UNSIGNED | NO | | |
| `is_manual` | BOOLEAN | NO | `false` | serie aggiunta manualmente e quindi eliminabile |
| `planned_drop_segments` | JSON | YES | NULL | prescrizione dropset congelata |
| `reps` | SMALLINT UNSIGNED | YES | NULL | |
| `drop_segments` | JSON | YES | NULL | esecuzione reale dei segmenti dropset |
| `weight_kg` | DECIMAL(6,2) | YES | NULL | |
| `time_seconds` | INT UNSIGNED | YES | NULL | |
| `rpe` | DECIMAL(3,1) | YES | NULL | |
| `rest_seconds` | SMALLINT UNSIGNED | YES | NULL | |
| `is_completed` | BOOLEAN | NO | `true` | |
| timestamps | | | | |

Indici: `UNIQUE(workout_session_exercise_id, set_number)`.

### Tabelle aggiunte

#### `workout_session_exercise_media`

| Colonna | Tipo | Null | Note |
|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | |
| `workout_session_exercise_id` | BIGINT UNSIGNED | NO | FK CASCADE |
| `uploaded_by` | BIGINT UNSIGNED | YES | FK `users`, `SET NULL` |
| `disk` | VARCHAR(30) | NO | default `workout_media` |
| `file_path` | VARCHAR(255) | NO | percorso privato |
| `original_name` | VARCHAR(255) | NO | nome originale |
| `mime_type` | VARCHAR(100) | NO | immagine o video ammesso |
| `size_bytes` | BIGINT UNSIGNED | NO | dimensione |
| timestamps | | | |

#### `food_items`

Catalogo per coach con nome, descrizione, quantità/unità predefinite, note di preparazione, calorie, macro, fibre e contatore `times_used`. Le voci dei pasti e le alternative possono referenziarlo tramite `food_item_id` nullable con `SET NULL`.

### 23. `workout_session_feedback`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `workout_session_id` | BIGINT UNSIGNED | NO | | `workout_sessions.id` CASCADE, UNIQUE |
| `perceived_effort` | DECIMAL(3,1) | YES | NULL | RPE generale sessione |
| `mood` | VARCHAR(20) | YES | NULL | `App\Enums\WellbeingLevel` |
| `energy_level` | VARCHAR(20) | YES | NULL | `App\Enums\WellbeingLevel` |
| `notes` | TEXT | YES | NULL | |
| `submitted_at` | TIMESTAMP | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(workout_session_id)` (relazione 1:1).

### 24. `measurement_types`

| Colonna | Tipo | Null | Default |
|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | |
| `code` | VARCHAR(50) | NO | UNIQUE |
| `name` | VARCHAR(100) | NO | |
| `unit` | VARCHAR(10) | NO | |
| `category` | VARCHAR(20) | NO | `App\Enums\MeasurementCategory` |
| `is_system` | BOOLEAN | NO | `true` |
| `sort_order` | SMALLINT UNSIGNED | NO | `0` |
| timestamps | | | |

Indici: `UNIQUE(code)`, `INDEX(category)`.

### 25. `measurements`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `measurement_type_id` | BIGINT UNSIGNED | NO | | `measurement_types.id` RESTRICT |
| `entered_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `value` | DECIMAL(8,2) | NO | | |
| `source` | VARCHAR(10) | NO | `athlete` | `App\Enums\MeasurementSource` |
| `recorded_at` | DATE | NO | | |
| `notes` | VARCHAR(255) | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(athlete_id, measurement_type_id, recorded_at)`.

### 26. `progress_photos`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `athlete_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `uploaded_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `angle` | VARCHAR(20) | NO | `front` | `App\Enums\PhotoAngle` |
| `disk` | VARCHAR(30) | NO | `progress_photos` | disco Laravel privato |
| `file_path` | VARCHAR(255) | NO | | nome file casuale, mai l'originale |
| `taken_at` | DATE | NO | | |
| `notes` | VARCHAR(255) | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(athlete_id, taken_at)`.
Motivazione: `disk = progress_photos` (privato, non pubblico) coerente con la configurazione filesystem del Modulo 1; l'esposizione richiederà sempre una route autenticata/firmata, mai un URL pubblico diretto.

### 27. `conversations`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `created_by` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `type` | VARCHAR(10) | NO | `direct` | `App\Enums\ConversationType` |
| `title` | VARCHAR(150) | YES | NULL | usato solo per `type = group` |
| `last_message_at` | TIMESTAMP | YES | NULL | denormalizzato per ordinare le liste senza JOIN pesanti |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(last_message_at)`.

### 28. `conversation_participants`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `conversation_id` | BIGINT UNSIGNED | NO | | `conversations.id` CASCADE |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `last_read_at` | TIMESTAMP | YES | NULL | |
| `joined_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |
| timestamps | | | | |

Indici: `UNIQUE(conversation_id, user_id)`.

### 29. `messages`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `conversation_id` | BIGINT UNSIGNED | NO | | `conversations.id` CASCADE |
| `sender_id` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `body` | TEXT | YES | NULL | nullable se il messaggio è solo un allegato |
| `sent_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(conversation_id, sent_at)`.

### 30. `message_attachments`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `message_id` | BIGINT UNSIGNED | NO | | `messages.id` CASCADE |
| `disk` | VARCHAR(30) | NO | `chat_attachments` | |
| `file_path` | VARCHAR(255) | NO | | nome file casuale |
| `original_name` | VARCHAR(255) | NO | | solo per il download, mai per il path fisico |
| `mime_type` | VARCHAR(100) | NO | | validato lato applicativo (allowlist) |
| `size_bytes` | INT UNSIGNED | NO | | |
| timestamps | | | | |

Indici: `INDEX(message_id)`.

### 31. `message_reads`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `message_id` | BIGINT UNSIGNED | NO | | `messages.id` CASCADE |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `read_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |

Indici: `UNIQUE(message_id, user_id)`. Nessun `updated_at`/`deleted_at`: un "letto" è un evento puntuale immutabile.

### 32. `notifications`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `recipient_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `actor_id` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `type` | VARCHAR(40) | NO | | `App\Enums\NotificationType` |
| `title` | VARCHAR(150) | NO | | |
| `body` | VARCHAR(255) | YES | NULL | |
| `data` | JSON | YES | NULL | payload specifico per tipo |
| `related_type` | VARCHAR(100) | YES | NULL | classe del model collegato (no FK: polimorfico leggero) |
| `related_id` | BIGINT UNSIGNED | YES | NULL | id del model collegato |
| `read_at` | TIMESTAMP | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(recipient_id, read_at)`, `INDEX(related_type, related_id)`.
Motivazione: relazione "polimorfica leggera" (`related_type` + `related_id`) senza FK reale perché punterebbe a tabelle diverse; l'integrità è garantita a livello applicativo, standard per questo pattern anche in Laravel stesso (`morphTo`).

### 33. `settings`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `user_id` | BIGINT UNSIGNED | YES | NULL | `users.id` CASCADE — `NULL` = impostazione globale |
| `key` | VARCHAR(100) | NO | | |
| `value` | JSON | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(user_id, key)` (vedi limite noto sui `NULL` in sezione politica FK).

### 34. `audit_logs`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `user_id` | BIGINT UNSIGNED | YES | NULL | `users.id` SET NULL |
| `action` | VARCHAR(100) | NO | | stringa libera (es. `workout_plan.published`), non enum: il set di azioni è troppo ampio/estensibile per un enum chiuso |
| `auditable_type` | VARCHAR(100) | YES | NULL | polimorfico leggero, come `notifications` |
| `auditable_id` | BIGINT UNSIGNED | YES | NULL | |
| `old_values` | JSON | YES | NULL | |
| `new_values` | JSON | YES | NULL | |
| `ip_address` | VARCHAR(45) | YES | NULL | IPv4/IPv6 |
| `user_agent` | VARCHAR(255) | YES | NULL | |
| `created_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |

Indici: `INDEX(user_id)`, `INDEX(auditable_type, auditable_id)`, `INDEX(created_at)`. Nessun `updated_at`: log append-only, mai modificato.

### 35. `consent_records`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE (vedi limite noto) |
| `type` | VARCHAR(30) | NO | | `App\Enums\ConsentType` |
| `version` | VARCHAR(20) | NO | | versione del testo accettato |
| `granted_at` | TIMESTAMP | NO | | |
| `revoked_at` | TIMESTAMP | YES | NULL | |
| `ip_address` | VARCHAR(45) | YES | NULL | |
| timestamps | | | | |

Indici: `INDEX(user_id, type)`.

### 36. `data_export_requests`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE |
| `status` | VARCHAR(20) | NO | `pending` | `App\Enums\GdprRequestStatus` |
| `file_path` | VARCHAR(255) | YES | NULL | |
| `requested_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |
| `completed_at` | TIMESTAMP | YES | NULL | |
| `expires_at` | TIMESTAMP | YES | NULL | scadenza link di download |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(user_id, status)`.

### 37. `account_deletion_requests`

| Colonna | Tipo | Null | Default | FK |
|---|---|---|---|---|
| `id` | BIGINT UNSIGNED PK | | | |
| `ulid` | CHAR(26) | NO | | UNIQUE |
| `user_id` | BIGINT UNSIGNED | NO | | `users.id` CASCADE (vedi limite noto) |
| `status` | VARCHAR(20) | NO | `pending` | `App\Enums\GdprRequestStatus` |
| `requested_at` | TIMESTAMP | NO | `CURRENT_TIMESTAMP` | |
| `scheduled_for` | TIMESTAMP | YES | NULL | fine periodo di grazia |
| `completed_at` | TIMESTAMP | YES | NULL | |
| `reason` | VARCHAR(255) | YES | NULL | |
| timestamps | | | | |

Indici: `UNIQUE(ulid)`, `INDEX(user_id, status)`.

---

## Diagramma ER (Mermaid)

```mermaid
erDiagram
    USERS ||--o| ATHLETE_PROFILES : "ha profilo"
    USERS ||--o{ ATHLETE_GOALS : "possiede"
    USERS ||--o{ ATHLETE_NOTES : "riguarda"
    USERS ||--o{ ATHLETE_LIMITATIONS : "riguarda"
    USERS ||--o{ ATHLETE_EQUIPMENT : "possiede"
    EQUIPMENT ||--o{ ATHLETE_EQUIPMENT : "posseduto in"
    USERS ||--o{ WORKOUT_PLANS : "crea"
    WORKOUT_PLANS ||--o{ WORKOUT_PLAN_VERSIONS : "ha versioni"
    WORKOUT_PLAN_VERSIONS ||--o{ WORKOUT_PLAN_WEEKS : "compone"
    WORKOUT_PLAN_WEEKS ||--o{ WORKOUT_PLAN_DAYS : "compone"
    WORKOUT_PLAN_DAYS ||--o{ WORKOUT_PLAN_EXERCISES : "compone"
    WORKOUT_PLAN_EXERCISES ||--o{ WORKOUT_PLAN_SETS : "compone"
    EXERCISES ||--o{ WORKOUT_PLAN_EXERCISES : "usato in"
    EXERCISES ||--o{ EXERCISE_MUSCLE_GROUP : "coinvolge"
    MUSCLE_GROUPS ||--o{ EXERCISE_MUSCLE_GROUP : "coinvolto da"
    MUSCLE_GROUPS ||--o{ MUSCLE_GROUPS : "sotto-gruppo di"
    EXERCISE_CATEGORIES ||--o{ EXERCISES : "classifica"
    EQUIPMENT ||--o{ EXERCISES : "richiesto da"
    WORKOUT_PLAN_VERSIONS ||--o{ WORKOUT_PLAN_ASSIGNMENTS : "assegnata come"
    USERS ||--o{ WORKOUT_PLAN_ASSIGNMENTS : "riceve"
    WORKOUT_PLAN_ASSIGNMENTS ||--o{ SCHEDULED_WORKOUTS : "genera"
    WORKOUT_PLAN_DAYS ||--o{ SCHEDULED_WORKOUTS : "pianifica"
    USERS ||--o{ WORKOUT_SESSIONS : "esegue"
    SCHEDULED_WORKOUTS ||--o| WORKOUT_SESSIONS : "diventa"
    WORKOUT_SESSIONS ||--o{ WORKOUT_SESSION_EXERCISES : "compone"
    WORKOUT_PLAN_EXERCISES ||--o{ WORKOUT_SESSION_EXERCISES : "riferisce"
    EXERCISES ||--o{ WORKOUT_SESSION_EXERCISES : "eseguito in"
    WORKOUT_SESSION_EXERCISES ||--o{ WORKOUT_SESSION_SETS : "compone"
    WORKOUT_SESSIONS ||--o| WORKOUT_SESSION_FEEDBACK : "riceve"
    USERS ||--o{ MEASUREMENTS : "registra"
    MEASUREMENT_TYPES ||--o{ MEASUREMENTS : "tipizza"
    MEASUREMENT_TYPES ||--o{ ATHLETE_GOALS : "tipizza"
    USERS ||--o{ PROGRESS_PHOTOS : "carica"
    USERS ||--o{ CONVERSATION_PARTICIPANTS : "partecipa"
    CONVERSATIONS ||--o{ CONVERSATION_PARTICIPANTS : "ha partecipanti"
    CONVERSATIONS ||--o{ MESSAGES : "contiene"
    USERS ||--o{ MESSAGES : "invia"
    MESSAGES ||--o{ MESSAGE_ATTACHMENTS : "ha allegati"
    MESSAGES ||--o{ MESSAGE_READS : "letto da"
    USERS ||--o{ MESSAGE_READS : "legge"
    USERS ||--o{ NOTIFICATIONS : "riceve"
    USERS ||--o{ SETTINGS : "configura"
    USERS ||--o{ AUDIT_LOGS : "genera"
    USERS ||--o{ CONSENT_RECORDS : "concede"
    USERS ||--o{ DATA_EXPORT_REQUESTS : "richiede"
    USERS ||--o{ ACCOUNT_DELETION_REQUESTS : "richiede"
```

*(diagramma semplificato: le colonne non-chiave sono omesse per leggibilità; il dettaglio completo di ogni tabella è nelle sezioni sopra)*

## Descrizione delle relazioni principali

**Utente e profilo**: un `users` (ruolo `athlete`) ha al più un `athlete_profiles` (1:1), che referenzia il proprio coach (`coach_id`, ancora `users`). Obiettivi, note e limitazioni sono 1:N verso l'atleta, con autore separato e "orfanizzabile" (SET NULL) per non perdere lo storico se l'autore viene rimosso.

**Catalogo esercizi**: `exercises` appartiene a una `exercise_categories` e opzionalmente a un `equipment` primario; i muscoli coinvolti passano dalla tabella ponte `exercise_muscle_group` con un `role` (`primary`/`secondary`), evitando colonne rigide `primary_muscle_group_id`/`secondary_muscle_group_id`.

**Versionamento schede (il cuore del modulo)**: `workout_plans` (contenitore) → `workout_plan_versions` (foto immutabile nel tempo) → `workout_plan_weeks` → `workout_plan_days` → `workout_plan_exercises` → `workout_plan_sets`. `workout_plan_assignments` lega un atleta a una **versione specifica**. Quando il coach modifica il piano, si crea una versione N+1: le assegnazioni/sessioni passate restano agganciate alla versione N, quindi non cambiano mai retroattivamente.

**Esecuzione**: `scheduled_workouts` è l'occorrenza di calendario generata da un'assegnazione (una data + un giorno di scheda specifico). Quando l'atleta la esegue, nasce una `workout_sessions` (collegata allo slot pianificato o libera), con `workout_session_exercises`/`workout_session_sets` come registrazione **indipendente** di ciò che è realmente accaduto (può differire dal piano: serie extra, esercizio sostituito, ecc.), più un feedback opzionale 1:1.

**Metriche e foto**: `measurements` registra valori nel tempo per `measurement_types` (catalogo condiviso); `progress_photos` è indipendente, con angolo di scatto e storage privato.

**Messaggistica**: `conversations` ↔ `conversation_participants` (N:N tramite tabella ponte con metadati di lettura) ↔ `messages` ↔ `message_attachments`/`message_reads`.

**Sistema/conformità**: `notifications` e `settings` sono per-utente (o globali se `NULL`); `audit_logs` è trasversale a tutta l'app (polimorfico leggero); `consent_records`, `data_export_requests`, `account_deletion_requests` coprono gli obblighi GDPR di base.

## Diagramma di flusso semplificato (ciclo di vita scheda → sessione)

```mermaid
flowchart TD
    A[Coach crea workout_plans] --> B[Coach crea workout_plan_versions v1]
    B --> C[Aggiunge settimane/giorni/esercizi/serie]
    C --> D[Pubblica la versione: status=published, is_current=true]
    D --> E[Assegna la versione all'atleta: workout_plan_assignments]
    E --> F[Sistema genera scheduled_workouts per le date del piano]
    F --> G{L'atleta allena?}
    G -- sì --> H[Crea workout_sessions collegata allo scheduled_workout]
    H --> I[Registra workout_session_exercises + workout_session_sets]
    I --> J[Facoltativo: workout_session_feedback]
    G -- no / salta --> K[scheduled_workouts.status=skipped/missed]
    D -.modifica futura.-> L[Coach crea workout_plan_versions v2]
    L -.-> M[v1 resta intatta: assignment/sessioni storiche invariate]
    M --> E
```
