# DATABASE — Modèle conceptuel et relationnel

> **Phase 1.** Ce document précède toute migration. Il fait référence pour la Phase 2.
> Version 1.0 — 9 août 2026.

---

## 1. Schéma conceptuel

```mermaid
erDiagram
    USERS ||--o{ SHOOTING_USER : "est affecté"
    SHOOTINGS ||--o{ SHOOTING_USER : "mobilise"
    CLIENTS ||--o{ SHOOTINGS : "commande"
    USERS ||--o{ SHOOTINGS : "crée"

    SHOOTINGS ||--o{ PARTICIPANTS : "contient"
    SHOOTINGS ||--o{ IMPORTS : "reçoit"
    IMPORTS ||--o{ IMPORT_ROWS : "détaille"
    IMPORTS ||--o{ PARTICIPANTS : "génère"

    PARTICIPANTS ||--o{ PARTICIPANT_NOTES : "porte"
    PARTICIPANTS ||--o{ SHOOTING_EVENTS : "historise"
    PARTICIPANTS ||--o{ PHOTOS : "possède"
    PARTICIPANTS |o--|| PHOTOS : "photo principale"

    USERS ||--o{ PARTICIPANT_NOTES : "rédige"
    USERS ||--o{ SHOOTING_EVENTS : "déclenche"
    USERS ||--o{ PHOTOS : "téléverse"
    USERS ||--o{ ACTIVITY_LOGS : "agit"

    SHOOTINGS ||--o{ SHOOTING_EVENTS : "regroupe"
    SHOOTINGS ||--o{ PHOTOS : "stocke"
    SHOOTINGS ||--o{ PORTAL_ACCESS_LOGS : "trace"
```

### Lecture du schéma en une phrase

Un **client** commande des **shootings**. Chaque shooting mobilise plusieurs **utilisateurs** (photographes) et contient des **participants**, issus d'un **import**. Chaque action sur un participant produit un **événement** immuable, et éventuellement des **notes** et des **photos**. Le **portail** expose uniquement les photos publiées, et journalise ses accès.

---

## 2. Trois décisions structurantes

### 2.1 Pas de table `photographers` — un photographe est un `User`

Le cahier des charges listait `users` **et** `photographers`. Les fusionner évite deux sources de vérité pour une même identité et une jointure sur chaque action de l'écran critique. Les champs spécifiques (téléphone, avatar) vivent sur `users`.

**Évolution possible :** si un jour un photographe externe doit apparaître dans les statistiques sans avoir de compte, on ajoutera un booléen `is_placeholder` — sans restructuration.

### 2.2 Un participant appartient à **un seul** shooting

Le cahier des charges envisageait `participants` + `shooting_participants` (registre global réutilisable). **Écarté pour la V1**, pour quatre raisons :

1. **RGPD** — supprimer un shooting doit supprimer ses données personnelles. Un registre partagé rend cette opération ambiguë.
2. **Réalité métier** — les listes viennent du client, une par événement, sans identifiant stable entre événements.
3. **Rapprochement risqué** — identifier « la même personne » entre deux séminaires par nom + e-mail produit des faux positifs, dont le coût (montrer à quelqu'un le portrait d'un autre) est inacceptable.
4. **Simplicité** — une jointure de moins sur la requête la plus fréquente de l'application.

**Coût assumé :** Sophie Martin présente à deux séminaires occupe deux lignes. C'est correct : ce sont deux prestations distinctes.

**Évolution :** ajouter plus tard une table `people` et une colonne nullable `participants.person_id`, sans migration destructive.

### 2.3 Une photo appartient à **un seul** participant

Le cahier des charges prévoyait un pivot `photo_participant`. Un portrait corporate représente une personne ; les photos de groupe sont hors périmètre V1. Une clé étrangère `photos.participant_id` (nullable, pour les photos importées en lot mais pas encore rapprochées) est plus simple et plus rapide.

**Évolution :** si des photos de groupe deviennent nécessaires, on ajoute le pivot et on migre — la logique métier passe déjà par `PhotoService`, jamais par des requêtes directes.

### 2.4 Pas de table `shooting_daily_stats` en V1

Le cahier des charges la prévoyait. À l'échelle réelle (≤ 2 000 événements par shooting), un `GROUP BY DATE(occurred_at)` sur un index dédié s'exécute en quelques millisecondes. Une table d'agrégats introduirait un risque de désynchronisation pour zéro gain mesurable.

**Retenu :** agrégats calculés à la demande, mis en cache 5 secondes (`ShootingStatsService`). **Si** un shooting dépassait 20 000 événements, la table serait ajoutée derrière la même interface de service, sans toucher aux vues.

---

## 3. Tables

Toutes les tables : `id` bigint auto-incrémenté, `created_at`, `updated_at`. Moteur InnoDB, `utf8mb4_unicode_ci`.

### 3.1 `users`

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique |
| `first_name`, `last_name` | string(100) | |
| `email` | string(180) | **unique** |
| `password` | string | bcrypt |
| `role` | string(20) | enum `UserRole` : `super_admin` \| `photographer` |
| `phone` | string(30) | nullable |
| `avatar_path` | string | nullable |
| `is_active` | bool | défaut `true` — un compte désactivé ne peut plus se connecter mais **son historique est conservé** |
| `last_login_at` | timestamp | nullable |
| `dark_mode` | string(10) | `system` \| `light` \| `dark` |
| `remember_token`, `email_verified_at` | | standard Laravel |
| `deleted_at` | timestamp | soft delete |

**Index :** `email` (unique), `role`, `is_active`.

### 3.2 `clients`

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique |
| `name` | string(180) | société |
| `contact_name`, `contact_email`, `contact_phone` | string | nullable |
| `logo_path` | string | nullable — affiché sur le portail et le rapport PDF |
| `notes` | text | nullable, interne |
| `deleted_at` | timestamp | soft delete |

**Index :** `name`.

### 3.3 `shootings`

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique — utilisé pour les chemins de stockage |
| `client_id` | FK → clients | `restrict` |
| `created_by` | FK → users | `restrict` |
| `name` | string(180) | ex. « Séminaire Cooper 2026 » |
| `slug` | string(200) | unique |
| `location` | string(180) | ex. « Marseille » |
| `address` | text | nullable |
| `starts_on`, `ends_on` | date | |
| `start_time`, `end_time` | time | nullable — horaires indicatifs |
| `status` | string(20) | enum `ShootingStatus` |
| `internal_notes` | text | nullable |
| **Portail** | | |
| `portal_enabled` | bool | défaut `false` |
| `portal_token` | string(32) | **unique**, `Str::random(32)`, régénérable |
| `portal_access_code` | string | nullable, **haché** |
| `portal_allow_download` | bool | défaut `true` |
| `portal_show_all_photos` | bool | défaut `false` — sinon photo principale seule |
| **Alertes** | | |
| `alert_time` | time | nullable, ex. `18:00` |
| `alert_emails_enabled` | bool | défaut `false` |
| `alert_sent_for` | date | nullable — garde-fou anti-doublon d'envoi |
| **RGPD** | | |
| `retention_days` | int | nullable — hérite du paramètre global si vide |
| `purge_after` | date | nullable — calculé, affiché à l'admin |
| **Concurrence** | | |
| `revision` | bigint unsigned | défaut 0 — compteur de polling (§6.2 ARCHITECTURE) |
| `archived_at`, `deleted_at` | timestamp | nullable |

**Index :** `slug` (unique), `portal_token` (unique), `client_id`, `status`, `starts_on`, `(status, starts_on)`.

### 3.4 `shooting_user` (pivot)

| Colonne | Type |
|---|---|
| `shooting_id` | FK → shootings, `cascade` |
| `user_id` | FK → users, `cascade` |
| `assigned_at` | timestamp |
| `assigned_by` | FK → users, nullable |

**Index :** `(shooting_id, user_id)` **unique**, `user_id` (« mes shootings »).

### 3.5 `participants` — table centrale

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique |
| `shooting_id` | FK → shootings, `cascade` | |
| `import_id` | FK → imports, nullable, `set null` | |
| `reference` | string(10) | `P0001` — **unique par shooting** |
| `first_name`, `last_name` | string(100) | `last_name` non nul en pratique |
| `email` | string(180) | nullable |
| `phone` | string(30) | nullable, normalisé |
| **Recherche** | | |
| `first_name_normalized` | string(100) | |
| `last_name_normalized` | string(100) | |
| `full_name_normalized` | string(200) | |
| `search_blob` | string(500) | « prénom nom email chiffres_tel » |
| **Statuts** | | |
| `shooting_status` | string(20) | enum : `to_shoot` \| `shot` |
| `photo_status` | string(20) | enum : `none` \| `imported` \| `to_retouch` \| `retouched` \| `validated` \| `published` |
| **Dénormalisation de lecture** | | reconstructible depuis `shooting_events` |
| `first_shot_at` | timestamp | nullable |
| `last_shot_at` | timestamp | nullable |
| `first_shot_by`, `last_shot_by` | FK → users, nullable, `set null` | |
| `shot_count` | tinyint unsigned | 0 = jamais ; ≥ 2 = reshoot |
| `notes_count` | tinyint unsigned | défaut 0 |
| `photos_count` | smallint unsigned | défaut 0 |
| `primary_photo_id` | FK → photos, nullable, `set null` | |
| `extra` | json | nullable — colonnes Excel non mappées |
| `deleted_at` | timestamp | soft delete |

**Index :**
```
(shooting_id, reference)          UNIQUE
(shooting_id, shooting_status)     ← listes rapides « À shooter » / « Shootés »
(shooting_id, photo_status)        ← « Sans photo » / « Photos disponibles »
(shooting_id, last_name_normalized, first_name_normalized)   ← tri alphabétique
(shooting_id, last_shot_by)        ← stats par photographe
search_blob                        ← recherche
uuid                               UNIQUE
```

> **Note sur la dénormalisation.** `first_shot_at`, `shot_count`, `notes_count`, `photos_count` dupliquent une information dérivable. C'est délibéré : afficher 500 participants avec leur statut sans sous-requête, et calculer la progression par un simple `COUNT` filtré. `shooting_events` reste la source de vérité ; une commande `app:rebuild-participant-cache` permet de reconstruire ces colonnes en cas de doute.

### 3.6 `shooting_events` — historique immuable

**Aucun UPDATE, aucun DELETE n'est jamais émis sur cette table.**

| Colonne | Type | Notes |
|---|---|---|
| `shooting_id` | FK → shootings, `cascade` | |
| `participant_id` | FK → participants, nullable, `cascade` | nullable pour les événements de shooting |
| `user_id` | FK → users, nullable, `set null` | |
| `type` | string(30) | enum `EventType` (§4.5) |
| `occurred_at` | timestamp | **indexé** — distinct de `created_at` |
| `occurrence_number` | smallint unsigned | 1 = première prise, 2+ = reshoot |
| `client_action_uuid` | char(36) | nullable, **unique** — idempotence (§6.3 ARCHITECTURE) |
| `meta` | json | nullable — ex. `{"photo_id":12,"previous_status":"to_shoot"}` |
| `ip` | string(45) | nullable |

**Index :**
```
(shooting_id, occurred_at)              ← timeline, stats par jour/heure
(participant_id, occurred_at)           ← historique d'une fiche
(shooting_id, type, occurred_at)        ← « portraits réalisés » par période
(shooting_id, user_id, occurred_at)     ← production par photographe
client_action_uuid                      UNIQUE
```

Cette table alimente **à elle seule** les statistiques par jour, par heure, par photographe, le taux de reshoot, la première et la dernière prise de vue, la cadence et la meilleure plage horaire.

### 3.7 `participant_notes`

| Colonne | Type | Notes |
|---|---|---|
| `participant_id` | FK → participants, `cascade` | |
| `shooting_id` | FK → shootings, `cascade` | dénormalisé pour l'index « avec notes » |
| `user_id` | FK → users, nullable, `set null` | auteur |
| `body` | text | |
| `edited_at` | timestamp | nullable |
| `deleted_at` | timestamp | soft delete |

**Index :** `(participant_id, created_at)`, `(shooting_id, deleted_at)`.

Plusieurs notes par participant, chacune horodatée et attribuée. Une modification met à jour `edited_at` **et** insère un `EventType::NOTE_UPDATED` — le contenu précédent est conservé dans `meta`.

### 3.8 `photos`

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique — **seul identifiant exposé publiquement** |
| `shooting_id` | FK → shootings, `cascade` | |
| `participant_id` | FK → participants, nullable, `set null` | nullable = en attente de rapprochement |
| `uploaded_by` | FK → users, nullable, `set null` | |
| `disk` | string(30) | ex. `photos_original` — permet la migration S3 |
| `original_path` | string(255) | |
| `preview_path` | string(255) | nullable tant que le job n'a pas tourné |
| `thumb_path` | string(255) | nullable |
| `original_filename` | string(255) | conservé pour le rapprochement et l'export |
| `mime` | string(60) | |
| `size` | bigint unsigned | octets |
| `width`, `height` | int unsigned | nullable |
| `checksum` | char(64) | sha256 — déduplication |
| `status` | string(20) | enum `PhotoStatus` |
| `is_primary` | bool | défaut `false` |
| `sort_order` | smallint unsigned | défaut 0 |
| `published_at` | timestamp | nullable |
| `derivatives_generated_at` | timestamp | nullable |
| `match_confidence` | string(20) | nullable : `exact` \| `probable` \| `manual` — trace du mode d'association |
| `deleted_at` | timestamp | soft delete |

**Index :**
```
(shooting_id, participant_id)
(shooting_id, status)
(participant_id, is_primary)
(shooting_id, checksum)      ← détection de doublon à l'import de masse
uuid                          UNIQUE
```

**Contrainte applicative :** une seule photo `is_primary = true` par participant, garantie dans une transaction par `SetPrimaryPhotoAction`. `participants.primary_photo_id` la duplique pour éviter une jointure sur le portail.

### 3.9 `imports`

| Colonne | Type | Notes |
|---|---|---|
| `uuid` | char(36) | unique |
| `shooting_id` | FK → shootings, `cascade` | |
| `user_id` | FK → users, nullable | |
| `original_filename` | string(255) | |
| `stored_path`, `disk` | string | fichier source conservé pour audit |
| `status` | string(20) | enum `ImportStatus` |
| `column_mapping` | json | ex. `{"first_name":"A","last_name":"B","email":"C","phone":"D"}` |
| `detected_headers` | json | en-têtes bruts lus dans le fichier |
| `total_rows`, `valid_rows`, `warning_rows`, `error_rows`, `imported_count` | int | |
| `started_at`, `finished_at` | timestamp | nullable |
| `failure_reason` | text | nullable |

### 3.10 `import_rows` — staging

| Colonne | Type | Notes |
|---|---|---|
| `import_id` | FK → imports, `cascade` | |
| `row_number` | int | numéro de ligne dans le fichier source |
| `raw` | json | valeurs brutes de la ligne |
| `parsed` | json | nullable — valeurs normalisées |
| `severity` | string(10) | `ok` \| `warning` \| `error` |
| `issues` | json | nullable — liste de codes `ImportIssueCode` |
| `participant_id` | FK → participants, nullable, `set null` | rempli au commit |

**Index :** `(import_id, severity)`, `(import_id, row_number)`.

Cette table rend l'import **reprenable** et conserve le rapport d'anomalies après coup.

### 3.11 `activity_logs` — audit transverse

Distincte de `shooting_events` : celle-ci est la **timeline métier** vue par les photographes, celle-là le **journal d'administration** (§39).

| Colonne | Type | Notes |
|---|---|---|
| `user_id` | FK → users, nullable, `set null` | |
| `action` | string(60) | `shooting.created`, `import.completed`, `photographer.deactivated`, `report.exported`… |
| `subject_type`, `subject_id` | morph nullable | objet concerné |
| `description` | string(255) | nullable |
| `properties` | json | nullable — avant/après |
| `ip` | string(45), `user_agent` string(255) | nullable |

**Index :** `(subject_type, subject_id)`, `(user_id, created_at)`, `created_at` (purge à 12 mois).

### 3.12 `portal_access_logs`

| Colonne | Type | Notes |
|---|---|---|
| `shooting_id` | FK → shootings, `cascade` | |
| `ip_hash` | char(64) | **IP hachée avec sel** — minimisation RGPD |
| `action` | string(20) | `search` \| `view` \| `download` \| `unlock_failed` |
| `query_hash` | char(64) | nullable — jamais la requête en clair |
| `matched` | bool | un résultat a-t-il été trouvé |
| `photo_id` | FK → photos, nullable, `set null` | |

**Index :** `(shooting_id, created_at)`, `(ip_hash, created_at)`.

Purge automatique à 30 jours. Sert à détecter une tentative d'énumération et à prouver qui a téléchargé quoi.

### 3.13 `settings`

Clé/valeur typée : logo société, rétention par défaut, heure d'alerte par défaut, taille max d'upload, activation de la recompression, expéditeur des e-mails.

### 3.14 Tables Laravel standard

`password_reset_tokens`, `sessions`, `jobs`, `job_batches`, `failed_jobs`, `cache`, `cache_locks`.

---

## 4. Énumérations PHP

### 4.1 `UserRole`
`super_admin` · `photographer`

### 4.2 `ShootingStatus`
`draft` · `upcoming` · `in_progress` · `completed` · `archived`

Transition automatique `upcoming → in_progress → completed` selon les dates, via une tâche planifiée quotidienne — mais **toujours forçable manuellement** (un shooting peut déborder).

### 4.3 `ParticipantShootingStatus`
`to_shoot` · `shot`

### 4.4 `PhotoStatus`
`none` · `imported` · `to_retouch` · `retouched` · `validated` · `published`

V1 expose `none`, `imported`, `published`. Les trois autres existent en base et dans l'enum dès le départ, pour n'imposer aucune migration future.

### 4.5 `EventType`
```
shot                    première prise de vue
reshoot                 nouvelle prise de vue (occurrence_number ≥ 2)
shot_undone             annulation d'un marquage
note_added
note_updated
note_deleted
photo_added
photo_associated
photo_detached
photo_deleted
photo_published
photo_unpublished
primary_photo_set
participants_imported   (participant_id null)
photographer_assigned   (participant_id null)
photographer_removed    (participant_id null)
report_exported         (participant_id null)
portal_download
```

### 4.6 `ImportStatus`
`pending` · `mapping` · `validating` · `ready` · `importing` · `completed` · `failed`

### 4.7 `ImportIssueCode`
`empty_row` · `missing_first_name` · `missing_last_name` · `missing_identity` · `invalid_email` · `invalid_phone` · `duplicate_in_file` · `duplicate_in_shooting` · `unmapped_column`

---

## 5. Requêtes critiques et index correspondants

| Requête | Fréquence | Index utilisé |
|---|---|---|
| Recherche participant dans un shooting | **très élevée** | `search_blob` + `shooting_id` |
| Compteurs du dashboard | élevée (cache 5 s) | `(shooting_id, shooting_status)` |
| Poll de révision | très élevée | clé primaire `shootings` |
| Liste « À shooter » | élevée | `(shooting_id, shooting_status)` |
| Liste « Avec notes » | moyenne | `participants.notes_count > 0` |
| Historique d'une fiche | moyenne | `(participant_id, occurred_at)` |
| Production par jour | rapport | `(shooting_id, type, occurred_at)` |
| Production par heure | rapport | idem |
| Production par photographe | rapport | `(shooting_id, user_id, occurred_at)` |
| Shootés sans photo (alerte clé) | rapport | `(shooting_id, shooting_status)` + `photos_count = 0` |
| Recherche portail | modérée, limitée | `search_blob` + `shooting_id` |
| Mes shootings (photographe) | faible | `shooting_user.user_id` |

**Objectif de performance :** toute requête de l'écran shooting sous **50 ms** avec 2 000 participants et 5 000 photos. Vérifié en Phase 11 avec un jeu de données de charge.

---

## 6. Suppression et RGPD — comportement en cascade

| Action | Effet |
|---|---|
| Supprimer un participant | Soft delete + suppression de ses photos (fichiers inclus) et de ses notes. Ses `shooting_events` sont **conservés anonymisés** (les statistiques du rapport restent justes). |
| Supprimer un shooting | Soft delete. Après confirmation explicite : suppression définitive de `shootings/{uuid}/` et de toutes les lignes liées. |
| Désactiver un photographe | `is_active = false`. **Aucune donnée supprimée** — son historique de production reste attribué. |
| Purge de rétention | Tâche quotidienne : les shootings dont `purge_after` est échu voient leurs originaux supprimés, previews et statistiques conservés. Notification à l'admin 7 jours avant. |
| Archiver un shooting | Retiré des vues actives, données conservées, dérivés supprimables pour libérer du disque. |

---

## 7. Données de démonstration (seeder)

```
1  super administrateur    laurent@… (mot de passe affiché à l'exécution)
3  photographes            Laurent · Nicolas · Mikaël
2  clients                 Cooper · Pierre Fabre
3  shootings
   ├─ « Séminaire Cooper 2026 »  — in_progress, 3 jours, ~120 participants, 67 % shootés,
   │                               photos partiellement associées, quelques reshoots,
   │                               événements répartis sur des plages horaires réalistes
   ├─ « Convention Pierre Fabre » — upcoming, ~80 participants, 0 % shootés
   └─ « Assemblée Cooper 2025 »   — completed, 100 %, portail actif, rapport exploitable
~100 faux participants  noms francophones accentués (Élodie, Cortés, Sansé, Jean-Pierre)
                        pour éprouver réellement la normalisation de recherche
```

Les horodatages d'événements sont générés selon une courbe d'activité crédible (creux du déjeuner, pic en fin de matinée) afin que les graphiques par heure du rapport soient représentatifs dès le premier lancement.
