# ARCHITECTURE — Application de gestion de shootings corporate

> **Phase 1 — Document d'architecture.** Aucun code applicatif n'est écrit à ce stade.
> Version 1.0 — 9 août 2026.

---

## 1. Objectif et critère de réussite unique

> **« Un photographe doit pouvoir identifier une personne et valider sa prise de vue en moins de 5 secondes, sur smartphone, dans une salle mal éclairée, avec un réseau médiocre. »**

Toutes les décisions d'architecture ci-dessous sont arbitrées par ce critère, puis par : simplicité de déploiement → robustesse → maintenabilité → évolutivité.

---

## 2. Contraintes d'hébergement PlanetHoster (N0C / World)

Recherche effectuée le 9 août 2026 sur la documentation officielle `kb.n0c.com` et `planethoster.com`.

### 2.1 Confirmé par la documentation

| Point | Constat | Source |
|---|---|---|
| PHP | 8.0 → **8.4** sélectionnable par compte. PHP-CLI dispo en SSH (`/opt/alt/phpXX/usr/bin/php`) | kb.n0c.com/knowledge-base/php/ |
| Extensions PHP | Activables/désactivables depuis le panneau N0C. **La liste exacte n'est pas publiée.** | idem |
| Base de données | MariaDB illimitées (PostgreSQL aussi). Version non documentée. | planethoster.com/World-Hosting |
| SSH | Inclus sur tous les plans World. Clés gérées dans le panneau MG. | kb.n0c.com/…/ssh-access |
| Composer | **Non préinstallé** — installation manuelle dans `~/.local/bin/composer`. | kb.n0c.com/…/how-to-install-composer |
| Cron | Supporté, granularité à la minute. PlanetHoster déconseille toutefois un script *toutes les minutes en journée*. | kb.n0c.com/…/cron-tasks-and-emails-in-n0c |
| Node.js | Supporté, mais **piloté par Phusion Passenger** (le port est imposé). Pas de démon libre. | kb.n0c.com/…/nodejs-application-management |
| Document root | **Configurable par domaine et sous-domaine.** On peut pointer directement sur `/home/USER/app/public`. | kb.n0c.com/…/domain-management |
| Stockage disque | 10 Go (Standard) / 50 Go (Plus) / 100 Go (Ultra) NVMe | planethoster.com/World-Hosting |
| Stockage objet | **N0C Storage, compatible S3**, 50 / 250 / 500 Go inclus selon le plan | idem |
| Redis / Memcached | Instances dédiées incluses sur tous les plans World | idem |
| Limites PHP | `memory_limit`, `upload_max_filesize`, `post_max_size`, `max_execution_time` éditables dans le panneau | kb.n0c.com/…/php |
| Isolation | Noyau CloudLinux + CageFS/LVE. AutoPeakPower : boost CPU/RAM ×2,5 par blocs de 4 h, 12 boosts/mois. | kb.planethoster.com/…/autopeakpower |
| Serveur web | LiteSpeed — `.htaccess` honoré, le `public/.htaccess` de Laravel fonctionne tel quel. | — |

### 2.2 À VÉRIFIER par vous, dans votre panneau, avant la Phase 2

Ces trois points conditionnent des choix techniques. Ils ne sont pas documentés publiquement.

1. **Extensions PHP disponibles** — il faut impérativement : `pdo_mysql`, `mbstring`, `openssl`, `tokenizer`, `xml`, `ctype`, `json`, `fileinfo`, `curl`, `zip` (import XLSX), `gd` **ou** `imagick` (vignettes), `phar` (Composer), `intl` (normalisation d'accents fiable), `exif`, `bcmath`, `opcache`.
   ```bash
   php -m
   ```
2. **Version de MariaDB** — si < 10.2.7, les index `utf8mb4` par défaut de Laravel cassent (contournable par `Schema::defaultStringLength(191)`, mais autant le savoir).
   ```bash
   mysql -e "SELECT VERSION();"
   ```
3. **Tolérance aux processus longs** — un `php artisan queue:work` en démon est-il toléré ou tué ? *Aucune politique officielle publiée.* On part du principe que **non** (voir §6.4). Un rapport tiers non vérifié évoque un plafond de 50 processus.

### 2.3 Conséquences directes sur l'architecture

| Contrainte | Décision |
|---|---|
| Pas de démon fiable | ❌ Pas de Reverb / Soketi / WebSocket auto-hébergé. ✅ **Polling Livewire** (§6). |
| Pas de démon fiable | ❌ Pas de `queue:work` permanent. ✅ **Queue `database` déclenchée par cron** avec `--stop-when-empty`. |
| Passenger impose le port Node | Node.js sert **uniquement au build** (Vite/Tailwind), en local ou ponctuellement en SSH. **Aucun processus Node en production.** |
| Document root configurable | Déploiement Laravel propre, sans le hack `public_html` + symlinks. |
| LVE / CPU limité | Le polling doit être **extrêmement bon marché** (§6.2). Aucune requête d'agrégation sur un poll. |
| 10 Go disque (Standard) | ⚠️ **Risque majeur** — voir §11.2. Le stockage objet S3 de N0C est la porte de sortie, d'où l'abstraction Storage obligatoire dès la V1. |

---

## 3. Stack technique retenue

| Couche | Choix | Justification |
|---|---|---|
| Framework | **Laravel 13** (stable depuis mars 2026, PHP 8.3+) | Compatible PHP 8.3/8.4 de N0C. Repli : Laravel 12 (PHP 8.2+) si votre compte est bloqué en 8.2. |
| PHP | **8.3** (cible), 8.4 acceptable | 8.3 est le plus sûr sur N0C ; ne pas viser 8.5, indisponible. |
| BDD | **MariaDB** (celle de N0C) | Fournie, illimitée, pas de dépendance supplémentaire. |
| UI temps réel | **Livewire 3** | Élimine toute API JSON custom et tout front SPA. Un seul langage côté serveur. |
| JS d'appoint | **Alpine.js** (livré avec Livewire) | Uniquement pour : focus recherche, raccourcis clavier, thème sombre, drag & drop. |
| CSS | **Tailwind CSS 4** + Blade | Build local, on ne déploie que le CSS compilé. |
| Build | **Vite** | Exécuté en local ou en SSH ponctuel — jamais un démon. |
| Auth | **Laravel Breeze (stack Blade + Livewire)** | Le plus léger. Pas de Jetstream (trop de surface pour 2 rôles). |
| Permissions | **Enum `UserRole` + Policies natives** | 2 rôles seulement. Spatie/permission serait de la sur-ingénierie ; migration possible plus tard sans casse. |
| Import Excel | **maatwebsite/excel** (PhpSpreadsheet) | Standard, lecture par chunks. Repli `openspout` si problème mémoire. |
| Images | **Intervention Image v3** | Détection auto Imagick → GD. Une seule API pour les deux. |
| PDF rapport | **spatie/laravel-pdf** (Browsershot) ❌ → **dompdf** ✅ | Browsershot exige Chromium/Puppeteer : impossible sur N0C. Dompdf est pur PHP. Graphiques rendus en **SVG serveur**, pas en JS. |
| Queue | **`database`**, drainée par cron | Pas de démon (§2.3). Redis dispo mais inutile ici et ajoute une dépendance. |
| Cache / session | **`file`** en V1 (Redis en option documentée) | Un seul serveur applicatif : le cache fichier suffit et supprime un point de panne. |
| Tests | **Pest** | Concis, lisible ; l'exigence §47 est couverte en Feature tests. |

### 3.1 Ce qu'on n'utilise PAS, et pourquoi

- **Docker en production** — non supporté par N0C, et inutile pour un mono-serveur.
- **Redis obligatoire** — disponible, mais une dépendance de plus pour zéro gain à cette échelle. Le driver reste configurable via `.env`.
- **WebSockets** — non fiables sur N0C (§2.2). Le polling couvre le besoin réel : « voir en quelques secondes qu'un collègue a shooté quelqu'un ».
- **Laravel Scout / Meilisearch / Elasticsearch** — inutile : la recherche est toujours scopée à un shooting de ≤ 2 000 lignes (§8).
- **API REST publique en V1** — prévue dans le modèle (uuid partout, logique en Services) mais non exposée.

---

## 4. Rôles et permissions

### 4.1 Deux rôles applicatifs, une seule table `users`

**Décision : pas de table `photographers` séparée.** Un photographe doit s'authentifier — c'est donc un `User`. Une table distincte imposerait une jointure sur chaque action et créerait deux sources de vérité pour l'identité.

```
UserRole (enum PHP)
├── SUPER_ADMIN   → accès total
└── PHOTOGRAPHER  → accès aux seuls shootings auxquels il est affecté
```

Le **participant n'est pas un utilisateur** : il n'a pas de compte. Il accède au portail par un token d'URL + éventuel code d'accès (§9).

### 4.2 Matrice d'accès

| Action | Super Admin | Photographe affecté | Photographe non affecté | Participant |
|---|:---:|:---:|:---:|:---:|
| Créer / modifier / supprimer un shooting | ✅ | ❌ | ❌ | ❌ |
| Voir la liste de tous les shootings | ✅ | ❌ | ❌ | ❌ |
| Voir un shooting | ✅ | ✅ | ❌ | ❌ |
| Importer des participants | ✅ | ❌ | ❌ | ❌ |
| Rechercher un participant | ✅ | ✅ | ❌ | ❌ |
| Marquer « Shootée » / annuler | ✅ | ✅ | ❌ | ❌ |
| Ajouter / modifier une note | ✅ | ✅ | ❌ | ❌ |
| Supprimer la note **d'un autre** | ✅ | ❌ | ❌ | ❌ |
| Uploader / associer une photo | ✅ | ✅ | ❌ | ❌ |
| Publier une photo | ✅ | ✅ | ❌ | ❌ |
| Supprimer une photo | ✅ | ❌ | ❌ | ❌ |
| Gérer les photographes | ✅ | ❌ | ❌ | ❌ |
| Voir le rapport / exporter | ✅ | ✅ (son shooting) | ❌ | ❌ |
| Paramètres application | ✅ | ❌ | ❌ | ❌ |
| Portail : rechercher son nom | — | — | — | ✅ (token+code) |
| Portail : télécharger sa photo | — | — | — | ✅ si autorisé |

### 4.3 Implémentation

- `ShootingPolicy`, `ClientPolicy`, `UserPolicy`, puis `ParticipantPolicy`, `PhotoPolicy`, `ParticipantNotePolicy`.
- Un **Gate global** : `Gate::before(fn($u) => $u->isSuperAdmin() ? true : null)`.

  ⚠️ **Piège** : ce raccourci court-circuite les policies *avant* qu'elles ne
  s'exécutent. Une règle censée s'appliquer **aussi** au super administrateur
  (« on ne peut pas désactiver son propre compte ») ne serait donc jamais
  atteinte. Les abilities concernées sont listées explicitement dans
  `AppServiceProvider::ABILITIES_WITHOUT_SUPER_ADMIN_BYPASS`, où le `before`
  rend la main à la policy.
- Un `ShootingScope` : toute requête sur `Shooting` d'un photographe est automatiquement filtrée par ses affectations (via un `whereHas` dans un scope explicite `visibleTo($user)` — préféré à un Global Scope, plus lisible et débuggable).
- Le portail participant passe par un **middleware dédié** (`EnsurePortalAccess`) et un **guard sans session utilisateur** : aucun risque d'escalade depuis le portail vers l'admin.

---

### 4.4 Accord en nombre — `fr_plural()`

`Str::plural()` applique les règles de **l'anglais**. Sur une application
intégralement francophone, il produit « 6 participants **restes** » au lieu de
« restent », « personne **restantes** » au lieu de « personnes restantes », et
accorde zéro au pluriel là où le français l'accorde au singulier.

Toute l'application passe donc par `fr_plural($n, 'singulier', 'pluriel')` et
`fr_count()` (`app/helpers.php`), qui exigent la forme plurielle explicite dès
qu'elle n'est pas un simple ajout de « s ». Un test unitaire verrouille le
comportement.

## 5. Arborescence fonctionnelle et URLs

```
PUBLIC
  /login
  /forgot-password, /reset-password
  /s/{portalToken}                     Portail participant (accueil + recherche)
  /s/{portalToken}/unlock              Saisie du code d'accès (si activé)
  /s/{portalToken}/p/{photoUuid}/file  Image (URL signée, expirante)
  /s/{portalToken}/p/{photoUuid}/download

PHOTOGRAPHE (auth)
  /shootings                           Mes shootings
  /shootings/{shooting}                ★ ÉCRAN SHOOTING (recherche + action)   ← écran critique
  /shootings/{shooting}/participants   Listes rapides / filtres
  /shootings/{shooting}/participants/{participant}
  /shootings/{shooting}/photos         Upload & association
  /shootings/{shooting}/report         Rapport
  /profile

SUPER ADMIN (auth + role)
  /admin                               Dashboard global
  /admin/clients                       CRUD clients
  /admin/shootings                     Liste + filtres + statuts
  /admin/shootings/create              Assistant de création (wizard 4 étapes)
  /admin/shootings/{shooting}/edit
  /admin/shootings/{shooting}/import   Assistant d'import Excel (4 étapes)
  /admin/shootings/{shooting}/portal   Configuration du portail
  /admin/photographers                 CRUD utilisateurs photographes
  /admin/settings                      Paramètres, RGPD, rétention
  /admin/activity                      Journal d'audit
```

**Convention :** ID entiers en interne (performance des index), **UUID exposés** dès qu'une URL peut fuiter (photos, portail). Les shootings admin utilisent un `slug` lisible.

---

## 6. Temps réel multi-photographes

### 6.1 Le besoin réel

Il ne s'agit pas de « temps réel » au sens strict, mais d'éviter qu'un photographe shoote quelqu'un qui vient de l'être. Une latence de **5 à 10 secondes est parfaitement acceptable** ; en revanche, une **collision doit être détectée à 100 %, immédiatement**, au moment du clic.

Ces deux exigences se traitent séparément :

| Exigence | Mécanisme | Fiabilité |
|---|---|---|
| Voir l'activité des collègues | Polling Livewire ~8 s | Best effort |
| Ne jamais écraser l'action d'un collègue | **Verrou SQL au moment du clic** | Garantie forte |

C'est le point clé : **la robustesse ne repose pas sur le polling**, elle repose sur la transaction. Le polling n'est qu'un confort.

### 6.2 Polling économe (compatible LVE)

Le piège serait de faire un `wire:poll` qui recalcule les statistiques et re-rend la liste. Avec 5 photographes toutes les 8 s, cela ferait ~2 250 agrégations/heure et déclencherait la limitation CPU de CloudLinux.

**Solution : un compteur de révision.**

- La table `shootings` porte une colonne `revision` (unsigned bigint).
- Toute écriture significative (shoot, annulation, note, photo, publication) fait `increment('revision')` — opération atomique, sans lecture préalable.
- Le poll appelle une méthode Livewire qui lit **uniquement** `SELECT revision FROM shootings WHERE id = ?` (index primaire, ~0,1 ms).
- Si `revision` est inchangée → aucune propriété Livewire ne change → **Livewire ne re-rend rien** et renvoie une réponse quasi vide.
- Si elle a changé → on recharge les stats (elles-mêmes mises en cache 5 s) et la fiche participant ouverte.

**Coût mesuré** (124 participants, MariaDB 12.3) :

| | Requêtes | SQL | Total |
|---|---|---|---|
| Battement à vide | 3 | 1,3 ms | 7,9 ms |
| Rendu complet | 9 | 11,5 ms | 21,8 ms |

Les trois requêtes du battement sont l'utilisateur authentifié, la réhydratation du modèle par Livewire, et la lecture de `revision` — toutes sur clé primaire. **Aucune ne touche `participants` ni `shooting_events`, et un test de non-régression le vérifie** : c'est ce qui empêchera qu'on rebranche un jour les statistiques sur le battement.

> ⚠️ **Le cache ne contient que des scalaires.** Les statistiques mises en cache
> stockent les dates sous forme de chaînes ISO et reconstruisent les objets
> `Carbon` à la lecture. Sérialiser des objets riches dans le cache de base de
> données produit, dès qu'un contexte n'a pas leur classe chargée (console,
> worker), une erreur d'« objet incomplet » parfaitement opaque — et le
> symptôme n'apparaît qu'au **second** appel, une fois le cache peuplé. Un test
> vérifie explicitement l'aller-retour.

À cinq photographes : environ 2 250 requêtes par heure, soit ~18 secondes de CPU cumulées. Négligeable au regard des limites LVE.

Réglages : `wire:poll.visible.8s` — l'attribut `.visible` suspend le polling quand l'onglet est en arrière-plan, ce qui compte réellement sur mobile (batterie + data).

### 6.3 Protection contre les actions simultanées

Toute la logique est dans `MarkParticipantAsShotAction`, jamais dans un contrôleur ni dans une vue.

```
DB::transaction(function () {
    $p = Participant::whereKey($id)->lockForUpdate()->first();   // verrou ligne

    if ($p->shooting_status === ShootingStatus::SHOT) {
        // Quelqu'un a déjà agi.
        if (!$force) {
            throw new AlreadyShotException($p->last_shot_by, $p->last_shot_at);
            // → « Sophie Martin vient d'être photographiée par Laurent à 14:37. »
            //   L'UI propose alors explicitement : [ NOUVELLE PRISE DE VUE ]
        }
        $type = EventType::RESHOOT;
    } else {
        $type = EventType::SHOT;
    }

    ShootingEvent::create([...]);                 // append-only, jamais écrasé
    $p->fill([...])->save();                      // dénormalisation de lecture
    $p->shooting()->increment('revision');
});
```

Trois invariants :
1. **`shooting_events` n'est jamais modifié ni supprimé.** L'historique est la source de vérité ; les colonnes de `participants` ne sont qu'un cache de lecture reconstructible.

   Concrètement, `Participant::recomputeShotState()` **rejoue le journal** (empilement des prises de vue, dépilement des annulations) plutôt que d'ajuster les compteurs au coup par coup. Une première prise de vue, un reshoot, puis l'annulation du reshoot doit restaurer exactement la première — un simple décrément se trompe d'auteur et d'horodatage. Rejouer une poignée de lignes coûte une requête et rend le cache exact par construction ; c'est aussi ce qui permettra de le reconstruire à tout moment.
2. **Idempotence** : chaque clic envoie un `client_action_uuid`. Un rejeu (réseau instable en salle, double-tap) est détecté par un index unique et ignoré silencieusement. C'est indispensable en conditions réelles de séminaire.
3. **Le verrou est pris sur la ligne participant**, jamais sur la table — aucune contention même à 5 photographes.

**Vérifié sur le moteur, pas seulement dans le code.** `tests/Integration/ConcurrentShotTest.php` ouvre une **seconde connexion MariaDB** et prouve que `lockForUpdate()` bloque réellement : la connexion B expire sur `Lock wait timeout` tant que A détient le verrou. Ce test ne peut pas tourner sous `RefreshDatabase` (transaction unique) ni sur SQLite, où `lockForUpdate()` est silencieusement inopérant — il passerait alors en donnant une fausse assurance sur la garantie la plus critique de l'application. D'où la suite `Integration` distincte, avec `DatabaseTruncation`.

### 6.4 Files d'attente sans démon

```cron
* * * * * /opt/alt/php83/usr/bin/php /home/USER/app/artisan schedule:run >/dev/null 2>&1
```

Et dans `routes/console.php` :

```
Schedule::command('queue:work --stop-when-empty --max-time=50 --tries=3')
    ->everyMinute()->withoutOverlapping();
```

Le worker démarre, vide la file, s'arrête. Aucun processus permanent. Latence maximale d'un job : ~60 s — sans conséquence, car **aucune action photographe ne passe par la file** (tout est synchrone et instantané). La file ne sert qu'aux traitements lourds : import, vignettes, PDF, e-mails d'alerte.

---

## 7. Import Excel

### 7.1 Constat sur un fichier réel

Le fichier `cooper_clients.xlsx` fourni montre déjà tous les pièges :
- en-tête **`Phone`** en anglais alors que les autres sont en français → le mapping ne peut pas être deviné par un simple dictionnaire français ;
- accents dans les noms (`Cortés`, `Sansé`, `Bouthé`) → normalisation obligatoire ;
- téléphones formatés avec espaces (`06 08 23 91 68`) → normalisation E.164 souple.

### 7.2 Processus en 4 étapes, avec table de staging

```
1. UPLOAD      → fichier stocké sur disque privé, ligne `imports` créée (status=pending)
2. MAPPING     → lecture de l'en-tête + 10 premières lignes
                 pré-remplissage heuristique (fr+en+accents : prenom/prénom/firstname/first name…)
                 + détection par le contenu si l'en-tête est absent ou exotique
                 l'utilisateur corrige : Prénom→[A], Nom→[B], Email→[C], Téléphone→[D]
                 colonnes non mappées → conservées dans participants.extra (JSON)
3. VALIDATION  → toutes les lignes → table `import_rows` (staging)
                 chaque ligne reçoit un statut : ok | warning | error
                 « 487 participants vont être importés — 12 anomalies détectées »
                 [ VOIR LES ANOMALIES ]   [ IMPORTER LES PARTICIPANTS ]
4. COMMIT      → insertion par lots de 200 dans `participants`
                 attribution des références P0001…, suppression du fichier source
```

**Pourquoi une table de staging ?** Elle rend le rapport d'anomalies consultable après coup, indéfiniment, et sépare nettement « ce que le fichier contient » de « ce qu'on en a fait ».

**Synchrone, et non en file d'attente.** L'architecture initiale prévoyait deux jobs. Décision revue à l'implémentation : sur PlanetHoster, la file n'est vidée que par le cron, à la minute. Faire patienter un administrateur soixante secondes devant un fichier de 500 lignes qui s'analyse en moins d'une seconde n'aurait aucun sens — mesuré à **moins de 3 s pour 500 participants**, import compris.

Le plafond est fixé à **5 000 lignes** (`SpreadsheetReader::MAX_ROWS`) ; au-delà, le fichier est refusé avec un message explicite plutôt que de risquer un timeout. Toute la logique vit dans `ExcelImportService` : la basculer en tâche de fond, si des fichiers beaucoup plus gros l'imposaient un jour, ne toucherait pas à l'écran.

**Ré-import.** Un second import sur un shooting déjà peuplé **ajoute les nouveaux venus, ignore les doublons et n'écrase jamais rien** — un participant déjà photographié conserve son statut, son historique et sa référence. La numérotation reprend où elle s'était arrêtée.

### 7.3 Règles de validation

| Contrôle | Sévérité | Comportement |
|---|---|---|
| Ligne entièrement vide | ignorée | non comptée |
| Nom **et** prénom absents | **error** | ligne rejetée |
| Prénom absent seul | warning | importé, signalé |
| Nom absent seul | warning | importé, signalé |
| Email invalide | warning | **importé**, email vidé, signalé |
| Téléphone non normalisable | warning | **importé**, valeur brute conservée, signalé |
| Doublon dans le fichier | warning | **une seule ligne importée** |
| Doublon avec l'existant (même shooting) | warning | ignoré, signalé |
| Colonne non mappée | info | stockée dans `extra` |

**Règle d'or (§6 du cahier des charges) : jamais de blocage global pour une donnée facultative.** Seule l'absence simultanée de nom et prénom rejette une ligne — sans identité, l'enregistrement est inutilisable.

**Détection de doublon** : clé = `full_name_normalized` + `email` normalisé. Deux « Jean Martin » sans e-mail sont signalés comme *doublon potentiel* mais **tous deux importés** — dans un séminaire de 500 personnes, les homonymes existent réellement. L'alerte §57 les remonte pour arbitrage humain.

---

## 8. Recherche

### 8.1 Normalisation

À chaque écriture (mutateur de modèle, donc jamais oublié) :

```
« Élodie »        → « elodie »
« DUPONT »        → « dupont »
« Jean-Pierre »   → « jean pierre »     (tirets → espaces)
« O'Brien »       → « o brien »
« 06 08 23 91 68 »→ « 0608239168 »      (chiffres seuls)
```

Trois colonnes persistées + une colonne agrégée :
`first_name_normalized`, `last_name_normalized`, `full_name_normalized`, et `search_blob` = `« prenom nom email telephone_chiffres »`.

### 8.2 Stratégie de requête — délibérément simple

Pas de Scout, pas de FULLTEXT, pas de trigram. La recherche est **toujours scopée à un shooting** (≤ 2 000 lignes, index sur `shooting_id`). Un `LIKE '%terme%'` sur ce sous-ensemble s'exécute en moins d'une milliseconde.

```
« martin sophie »  → découpé en termes
                   → WHERE shooting_id = ? AND search_blob LIKE '%martin%'
                                           AND search_blob LIKE '%sophie%'
```

Le `AND` de termes indépendants résout **gratuitement l'inversion prénom/nom** — c'est la raison de ce choix plutôt qu'un `LIKE` sur la chaîne complète.

### 8.3 Tolérance aux fautes de frappe

En deux temps, pour ne jamais payer le coût quand ce n'est pas nécessaire :

1. Recherche exacte (ci-dessus). **99 % des cas s'arrêtent ici.**
2. **Si et seulement si 0 résultat** : repli en PHP sur les noms du shooting (mis en cache 5 min, ~2 000 chaînes courtes) avec `levenshtein()`, distance ≤ 2, plafonné aux 5 meilleurs.
   → « Aucun résultat pour *martn*. Vouliez-vous dire **Sophie Martin** ? »

Coût nul en régime normal, filet de sécurité efficace quand il faut.

### 8.4 Ergonomie

- `wire:model.live.debounce.250ms` — assez réactif, sans marteler le serveur à chaque frappe.
- Recherche déclenchée à partir de **2 caractères**.
- **Un seul résultat → fiche ouverte automatiquement** : c'est ce qui fait passer le workflow de 3 clics à 1.
- `Cmd/Ctrl + K` focus, `↑/↓` navigation, `Entrée` ouvrir, `Échap` retour, `S` marquer shooté (avec confirmation visuelle, jamais destructif). Aucun raccourci obligatoire.

---

## 9. Portail participant

### 9.1 Modèle de sécurité — défense en profondeur

| Couche | Mesure |
|---|---|
| URL | `/s/{token}` — token aléatoire de 32 caractères (`Str::random(32)`), non devinable, non séquentiel |
| Accès | Code d'accès optionnel par shooting, **haché** en base (`Hash::make`), jamais en clair |
| Activation | Portail activable/désactivable à tout moment, et **désactivé par défaut** |
| Énumération | **Recherche exigeant ≥ 3 caractères ET renvoyant au maximum 5 résultats.** Aucune liste, aucune pagination, aucun tri, aucun caractère générique. |
| Débit | Rate limiting par IP **et** par token : 10 recherches/min, 60/heure, 200/jour |
| Robots | En-tête `X-Robots-Tag: noindex, nofollow, noarchive, noimageindex` sur **toutes** les routes portail + `robots.txt` |
| Fichiers | Photos stockées **hors du document root**, sur un disque privé. Aucune URL directe. |
| Livraison | Servies par un contrôleur → vérification du token, du code, de la publication, puis **URL signée expirant en 10 minutes** |
| Traçabilité | Chaque recherche et chaque téléchargement journalisés (IP hachée) — purge automatique à 30 jours |
| Bots | Honeypot + délai minimal de soumission. **Pas de CAPTCHA en V1** (friction disproportionnée) ; hook prévu si abus constaté. |

### 9.2 Point de conception essentiel

Le participant ne peut afficher que **son propre portrait**, et uniquement après avoir saisi un nom qui correspond. Il n'existe **aucune route** renvoyant plus de 5 participants, et aucune renvoyant une photo non publiée. L'énumération massive est structurellement impossible : même en itérant sur tout le dictionnaire des prénoms français, l'attaquant est arrêté à 200 requêtes/jour.

> ⚠️ **Piège de déploiement.** La signature d'une URL couvre **l'hôte complet**.
> Si `APP_URL` ne correspond pas exactement à l'adresse réellement visitée
> (`http` au lieu de `https`, avec ou sans `www`, port différent), *toutes* les
> images du portail répondent 403 — alors que le reste de l'application
> fonctionne normalement. `php artisan app:doctor` le vérifie en production.

### 9.3 Parcours

```
/s/abc87HG54
   └─ [code d'accès si activé]
        └─ « Retrouvez votre portrait »   [ Votre prénom et votre nom ]  [RECHERCHER]
             ├─ 0 résultat   → « Aucun portrait trouvé à ce nom. »
             ├─ 1 résultat   → photo principale + [ ↓ TÉLÉCHARGER MA PHOTO ]
             ├─ 2-5 résultats→ désambiguïsation par prénom + initiale du nom uniquement
             └─ trouvé mais non publié → « Votre portrait n'est pas encore disponible. »
```

Aucun menu. Aucun tableau de bord. Aucune information sur les autres participants. Logo client optionnel en en-tête.

---

## 10. Gestion des photos

### 10.1 Abstraction du stockage — non négociable

Trois disques déclarés dans `config/filesystems.php` :

```
'photos_original'  → privé   (originaux)
'photos_derived'   → privé   (previews + vignettes)
'public'           → assets seulement (logos, avatars)
```

**Aucun chemin absolu dans le code métier.** Chaque `Photo` porte une colonne `disk`, ce qui permet de basculer vers **N0C Storage (S3), Cloudflare R2 ou Backblaze B2** en changeant `.env`, sans migration de données pour les nouvelles photos et avec une commande de migration pour les anciennes. Étant donné la contrainte de 10 Go (§11.2), ce n'est pas une précaution théorique : c'est la porte de sortie prévue.

`storage:link` n'est **jamais** utilisé pour les photos — cela les rendrait accessibles par URL devinable.

### 10.2 Arborescence logique

```
shootings/{shooting_uuid}/originals/{photo_uuid}.jpg
shootings/{shooting_uuid}/previews/{photo_uuid}.webp     1600 px, ~200 Ko
shootings/{shooting_uuid}/thumbs/{photo_uuid}.webp        400 px,  ~25 Ko
```

Un dossier par shooting = suppression RGPD triviale (`Storage::deleteDirectory`) et sauvegarde sélective.

### 10.3 Dérivés

À l'upload, un job en file :
1. calcule le `sha256` (déduplication + intégrité),
2. lit les dimensions,
3. génère preview 1600 px et vignette 400 px en **WebP** (~40 % plus léger que JPEG à qualité égale),
4. **retire toutes les métadonnées des dérivés** (EXIF, GPS, données objectif) — l'original les conserve intactes.

Le dashboard ne charge **que** des vignettes de ~25 Ko. Une grille de 100 portraits pèse 2,5 Mo, pas 2 Go.

`Intervention\Image` choisit Imagick s'il est présent, sinon GD. Si aucun des deux n'est disponible, l'upload reste possible mais l'application affiche un avertissement explicite en administration plutôt que d'échouer silencieusement.

### 10.4 Association photo → participant

| Méthode | Fonctionnement |
|---|---|
| 1. Manuelle | Upload → recherche « Sophie Martin » → [ASSOCIER] |
| 2. Drag & drop | Depuis la fiche participant, association immédiate |
| 3. Lot | 50–500 fichiers. Le nom contient `P0042` → **proposition automatique** de rapprochement |
| 4. Nom | `sophie-martin.jpg` → normalisation → rapprochement sur `full_name_normalized` |

**Règle de sûreté : rapprochement automatique uniquement si la correspondance est unique et exacte.** Toute ambiguïté (deux « Martin », ou une correspondance approximative) va dans une file **« à valider »** avec les candidats proposés. Une photo associée à la mauvaise personne dans un portail participant est un incident RGPD ; le doute impose toujours la validation humaine.

Concrètement, trois niveaux sont distingués et **visibles à l'écran** :

| `match_confidence` | Origine | Signalement |
|---|---|---|
| `exact` | Référence `P0042` trouvée dans le nom | aucun — l'identifiant vient de l'application |
| `probable` | Nom de la personne reconnu | badge **« À vérifier »** |
| `manual` | Association faite à la main | aucun |
| *(aucun)* | Rien de concluant | reste dans l'onglet « À associer » |

Le rapprochement par nom exige que **tous** les mots du nom du participant figurent dans le fichier : `martin.jpg` ne désigne donc ni Sophie ni Julien Martin, et n'associe rien. Les numéros de série (`_001`) sont ignorés, et une référence doit être isolée : `IMG_00420.jpg` ou `CP0042.jpg` ne valent pas `P0042`.

Import par lot en 3 vagues asynchrones : upload → analyse (nom de fichier + hash) → génération des dérivés. Un plafond configurable (défaut 20 fichiers par requête HTTP) évite les timeouts LiteSpeed.

### 10.5 Deux statuts distincts

Le cahier des charges (§20) est repris intégralement en base dès la V1 ; seuls trois états sont exposés dans l'interface V1.

```
shooting_status   : to_shoot | shot
photo_status      : none | imported | to_retouch | retouched | validated | published
                    ▲──── exposé en V1 ────▲            ▲── prêt pour V2 ──▲
```

Un participant peut être `shot` et `photo_status = none` — c'est précisément l'alerte la plus utile du rapport (§57).

**Seules les photos `published` sont visibles dans le portail participant.** Aucune exception dans le code.

---

## 11. Risques techniques

### 11.1 🔴 Critique — Les workers de file peuvent être tués

**Impact :** import, vignettes, PDF ne s'exécutent jamais.
**Mitigation :** architecture cron + `--stop-when-empty` dès la V1 (§6.4) — on ne dépend jamais d'un démon.
**Vérification :** dès la Phase 2, une commande `php artisan app:doctor` diagnostique file, cron, extensions et permissions.

### 11.2 🔴 Critique — Volume disque

Le cahier des charges cite 5 000 photos. À 15 Mo par JPEG plein format : **75 Go d'originaux** — 7× le plan Standard (10 Go).

**Mitigations, par ordre d'application :**
1. **Ne pas téléverser les RAW.** L'application reçoit des JPEG exportés (2–5 Mo), pas les fichiers boîtier. À définir explicitement comme règle d'usage.
2. Paramètre **« recompresser l'original »** (qualité 92, bord long 4000 px) activable par shooting → ~1,5 Mo/photo → 5 000 photos ≈ 7,5 Go.
3. **Basculer les originaux vers N0C Storage (S3, 50–500 Go inclus dans votre plan)** — d'où l'abstraction du §10.1.
4. **Purge automatique après rétention** (§12) : suppression des originaux X jours après la fin du shooting, conservation des previews.
5. Tableau de bord admin affichant l'occupation disque par shooting, avec seuil d'alerte.

> **Décision à prendre en Phase 7 :** originaux en local ou sur N0C Storage dès le départ. Recommandation : **local en V1** (plus simple, plus rapide), **avec la commande de migration S3 écrite et testée** pour pouvoir basculer en une commande.

### 11.3 🟠 Élevé — Limite d'inodes

3 fichiers par photo × 5 000 photos = 15 000 inodes par shooting, plus ~25 000 pour `vendor/`. La limite N0C n'est pas publiée.
**Mitigation :** vérifier la limite en Phase 12 ; `--no-dev` en production ; `node_modules` **jamais** déployé (build en local) ; purge des dérivés à l'archivage.

### 11.4 🟠 Élevé — Réseau instable en salle de séminaire

Les sous-sols d'hôtels et les centres de congrès sont des zones blanches. C'est la panne la plus probable en usage réel, et elle survient au pire moment.
**Mitigation V1 :** UI optimiste (le bouton bascule instantanément), indicateur d'état de connexion visible, **idempotence par `client_action_uuid`** (§6.3) pour qu'un rejeu ne crée jamais de doublon, et file de reprise Alpine gardant l'action en attente et la rejouant au retour du réseau.
**V2 :** vrai mode hors ligne (PWA + IndexedDB) — l'idempotence de la V1 en est le prérequis, c'est pourquoi elle est faite maintenant.

### 11.5 🟡 Moyen — Imagick absent, GD gourmand

GD décompresse en mémoire : un JPEG 45 Mpx ≈ 180 Mo.
**Mitigation :** `memory_limit` à 512 Mo (éditable au panneau), plafond de dimensions à l'upload, traitement un fichier à la fois en file, message d'erreur explicite plutôt qu'un échec muet.

### 11.6 🟡 Moyen — Timeouts LiteSpeed sur les gros imports

**Mitigation :** rien de lourd en HTTP. L'upload dépose le fichier ; tout le reste est en file, avec état visible et reprise possible.

### 11.7 🟡 Moyen — Étranglement CPU LVE sous polling

**Mitigation :** compteur `revision` (§6.2), `.visible`, stats en cache 5 s. Charge mesurée en Phase 6 avant validation.

### 11.8 🟢 Faible — Version de MariaDB inconnue

**Mitigation :** `Schema::defaultStringLength(191)` dans `AppServiceProvider` — sans effet si la version est récente, sauve la migration sinon.

---

## 12. Sécurité et RGPD

### 12.1 Nature des données

Noms, prénoms, e-mails professionnels, téléphones, **et portraits photographiques** — ces derniers étant des données biométriques au sens large, ce qui élève le niveau d'exigence.

### 12.2 Mesures intégrées dès la conception

| Exigence | Mise en œuvre |
|---|---|
| Minimisation | Aucun champ collecté au-delà du strict nécessaire. Les colonnes Excel non mappées vont dans `extra` et sont **supprimables en un clic**. |
| Droit à l'effacement | Suppression d'un participant → ses photos, notes et événements. Suppression d'un shooting → l'intégralité de son arborescence de stockage. |
| Rétention | `retention_days` par shooting (défaut configurable, ex. 180 j). Une tâche planifiée quotidienne purge ce qui est échu, **après notification à l'admin 7 jours avant**. |
| Archivage | Un shooting archivé conserve les statistiques agrégées et perd les données personnelles si demandé. |
| Portabilité | Export CSV/Excel de tout shooting. |
| Traçabilité | `activity_logs` : qui, quoi, quand, IP. Conservation 12 mois. |
| Contrôle d'accès | Policies systématiques ; aucun accès direct au stockage. |
| Non-indexation | `X-Robots-Tag: noindex` + `robots.txt` + fichiers hors document root. |
| Chiffrement | HTTPS obligatoire (`URL::forceScheme('https')` en production), mots de passe bcrypt, code d'accès portail haché. |
| Sous-traitance | Hébergement France/Canada PlanetHoster. Aucun service tiers ne reçoit de donnée personnelle en V1. |

### 12.3 Sécurité applicative

- Rate limiting sur login (5 tentatives / 15 min / IP + e-mail).
- Politique de mot de passe : 12 caractères minimum, vérification contre les fuites connues (`Password::uncompromised()` — nécessite un accès sortant, dégradation propre si indisponible).
- CSRF partout, en-têtes de sécurité (`X-Frame-Options`, `X-Content-Type-Options`, CSP stricte).
- Validation exclusivement en **Form Requests**, jamais dans les contrôleurs.
- `.env` hors document root (garanti par le §2.1 : le root pointe sur `public/`).
- Uploads : validation MIME **réelle** (`finfo`, pas l'extension), renommage systématique en UUID, aucun exécutable stocké.

---

## 13. Stratégie de déploiement PlanetHoster

### 13.1 Disposition sur le serveur

```
/home/USER/
├── shooting-app/            ← code (HORS document root)
│   ├── app/ config/ ...
│   ├── public/              ← DOCUMENT ROOT du sous-domaine
│   ├── storage/
│   │   └── app/private/     ← photos (jamais servi par le web)
│   └── .env
└── backups/
```

Le sous-domaine `portrait.mondomaine.fr` est pointé sur `/home/USER/shooting-app/public` via le panneau N0C (§2.1). Pas de symlink, pas de `.htaccess` de redirection.

### 13.2 Séquence de déploiement

```bash
git pull
php -d memory_limit=-1 ~/.local/bin/composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache route:cache view:cache event:cache
php artisan storage:link       # assets publics uniquement — pas les photos
```

Les assets (`public/build/`) sont **compilés en local et versionnés** : aucun `npm` requis en production, ce qui élimine toute dépendance à Node côté serveur.

### 13.3 Cron unique

```cron
* * * * * /opt/alt/php83/usr/bin/php /home/USER/shooting-app/artisan schedule:run >/dev/null 2>&1
```

Tout le reste (queue, alertes, purge RGPD, sauvegardes) est déclaré dans le Scheduler Laravel. **Un seul cron à configurer, jamais à modifier.**

### 13.4 Sauvegardes

Trois éléments, documentés dans `BACKUP.md` :
1. **Base MariaDB** — `mysqldump` quotidien, rétention 30 jours.
2. **Photos** — `storage/app/private/shootings/` (le seul répertoire réellement irremplaçable).
3. **Configuration** — `.env` (chiffré).

Un job Scheduler produit le dump ; une copie hors serveur (N0C Storage ou rsync) est indispensable — une sauvegarde sur le même disque ne protège de rien.

---

## 14. Découpage des phases

| Phase | Contenu | Livrable vérifiable |
|---|---|---|
| **1** | Architecture, modèle de données ✅ | `ARCHITECTURE.md`, `DATABASE.md` |
| **2** | Projet Laravel, auth, rôles, policies, `app:doctor` | Connexion des 2 rôles, tests de permission verts |
| **3** | Clients, photographes, shootings, affectations | Créer un shooting complet et y affecter 3 photographes |
| **4** | Import Excel (4 étapes) | Importer `cooper_clients.xlsx`, puis 500 lignes générées |
| **5** | **Écran shooting mobile** (recherche, SHOOTÉE, notes, progression) | Workflow complet en < 5 s sur téléphone |
| **6** | Multi-photographes, `revision`, verrous, collisions | Test simultané à 2 navigateurs |
| **7** | Photos : upload, dérivés, 4 méthodes d'association, photo principale | 100 photos importées et associées par lot |
| **8** | Portail participant + sécurité | Recherche, affichage, téléchargement signé |
| **9** | Statistiques (globales, photographe, jour, heure) | Dashboard temps réel |
| **10** | Rapport client + exports PDF/Excel/CSV | PDF envoyable tel quel au client |
| **11** | Sécurité, RGPD, perfs, tests complets | Jeu de tests §47 vert, audit charge 1 000 participants |
| **12** | Déploiement PlanetHoster | `DEPLOYMENT.md` + mise en production |

**Règle appliquée à chaque phase :** annonce → fichiers concernés → implémentation → vérification → tests → correction → documentation mise à jour.

---

## 15. Qualité de code

```
app/
├── Actions/          MarkParticipantAsShot, UndoShot, AssociatePhoto…  (1 classe = 1 intention)
├── Enums/            UserRole, ShootingStatus, ParticipantShootingStatus,
│                     PhotoStatus, EventType, ImportStatus, ImportIssueCode
├── Services/         ShootingStatsService, SearchService, ImageDerivativeService,
│                     ExcelImportService, ReportService, StorageQuotaService
├── Policies/         Shooting, Participant, Photo, ParticipantNote
├── Http/
│   ├── Requests/     validation uniquement ici
│   ├── Middleware/   EnsurePortalAccess, SetLocale, SecurityHeaders
│   └── Controllers/  minces — CRUD et livraison de fichiers seulement
├── Livewire/
│   ├── Shooting/     SearchBar, ParticipantCard, StatsHeader, QuickLists
│   ├── Import/       Wizard (4 étapes)
│   └── Portal/       SearchForm, PhotoDisplay
├── Jobs/             ProcessImportRows, CommitImport, GeneratePhotoDerivatives,
│                     GenerateReportPdf, SendEndOfDayAlert, PurgeExpiredData
└── Models/
```

**Interdits explicites :** logique métier dans une vue Blade ; contrôleur > 5 méthodes ; requête sans `select` explicite dans une boucle ; JavaScript pour ce que Livewire fait déjà ; `TODO` non tracé dans la documentation.

**Systématique :** `$with` sur les modèles pour prévenir les N+1, `->chunkById()` sur tout traitement de masse, factories et seeders pour chaque modèle, tests Feature pour chaque parcours du §47.

---

## 16. Questions ouvertes avant la Phase 2

1. **Extensions PHP, MariaDB, tolérance aux processus** — les trois vérifications du §2.2.
2. **Photos hors JPEG ?** RAW/TIFF attendus ou seulement des JPEG exportés ? Cela change tout le dimensionnement (§11.2).
3. **Volume annuel** — combien de shootings par an, combien de photos conservées ? Détermine le plan N0C nécessaire.
4. **Nom de domaine** — `portrait.mondomaine.fr` confirmé, ou autre ?
5. **Envoi d'e-mails** — SMTP PlanetHoster, ou service tiers (Postmark/Brevo) ? Le SMTP mutualisé a une délivrabilité incertaine pour les alertes de fin de journée.
6. **Langue** — interface français uniquement en V1, ou prévoir l'anglais (clients internationaux) ? Cela ne coûte presque rien maintenant, cher plus tard.
