# Plan — Sous-domaine CROPA (`cropa.`)

> **Objectif** : site front dédié au client **CROPA** sur son propre sous-domaine — landing,
> création de compte / connexion, choix d'un **pack** et du programme. Les données sont stockées
> dans la base du backoffice principal `my-events.` (aucune synchronisation à écrire).
>
> **Événement** : 12èmes Journées Pharmaceutiques du CROPA — slug `12jcropa` (id 64 en local),
> 2 & 3 octobre 2026, Hôtel Africa Jade Korba.
>
> **Statut** : **implémenté et testé en local** — 77 tests verts / 229 assertions.
> Reste le déploiement (§11) et les points en attente (§12).
>
> **Dernière mise à jour** : 2026-09-09

---

## 1. Décisions arrêtées

| Sujet | Décision |
|---|---|
| **Authentification** | **Magic link uniquement** — pas de mot de passe, pas d'écran « mot de passe oublié » |
| **Public** | **Pharmaciens uniquement** — domaine forcé sur Pharmacie (`domains.id = 2`) |
| **Structure du choix** | **Pack obligatoire** + un atelier par créneau + dîner samedi optionnel |
| **Prix** | Le **pack** porte le prix ; les ateliers et activités sont « Inclus » (0 TND) |
| **Conjoint** | Ligne de workshop distincte, **même tarif** que la place principale |
| **Paiement en ligne** | **Non** — total + mode déclaratif, lu depuis la table `paiements` de l'événement |
| **Type d'« API »** | **Endpoints web JSON** sur le sous-domaine (session `web`), **pas** de REST/Sanctum |

### Pourquoi pas une vraie API REST

| Approche | Verdict |
|---|---|
| API REST (`routes/api.php` + Sanctum) | ❌ L'auth y est *stateless*, incompatible avec « se connecter et retrouver son inscription ». |
| **Endpoints web JSON** (pattern District414) | ✅ **Retenu**. Session `web`, `Auth::login()`, réponses JSON, CSRF géré nativement. |

Le sous-domaine partage **la même base et les mêmes modèles** (`User`, `Participant`, `Workshop`)
que le backoffice. Les inscriptions remontent automatiquement dans `my-events.` via l'`event_id`.

---

## 2. Le parcours du client

Défini par les documents dans `features/12jcropa/` : `Packs.txt`, 4 captures d'écran,
3 maquettes « conjoints », et le programme final en PDF.

```
Landing  ──►  Étape 0 (infos + jour)  ──►  Étape 1 (pack + programme)  ──►  Confirmation
   │                    │
   │                    ├─ email inconnu → compte créé, connexion immédiate
   │                    └─ email connu   → magic link → étape 1 préremplie
   │
   ├─ bouton « S'inscrire aux journées » (ou « Modifier mon inscription »)
   └─ bouton « Télécharger le programme » (PDF)
```

**Étape 0 — en deux temps.** D'abord **Email et Téléphone** seuls, avec un bouton « Suivant » :
ces deux champs identifient une personne sur la plateforme.

- l'un des deux est **connu** → un lien de connexion part vers l'adresse du compte trouvé, et le
  reste du formulaire ne s'affiche pas (pas de doublon, pas d'écrasement de fiche) ;
- **aucun des deux** n'est connu → le formulaire se déplie : Prénom, Nom, **Spécialité** (6 valeurs
  pharmacie), **Gouvernorat**, les 9 packs à titre informatif, et le radio
  **Vendredi / Samedi / Les 2 journées**.

Modifier l'email ou le téléphone après identification replie le formulaire : l'identification n'est
plus valable.

Quand le compte est retrouvé **par le téléphone**, l'adresse de destination peut différer de celle
saisie : le message la rappelle **masquée** (`la••••••••@gmail.com`) pour que la personne sache où
chercher son mail, sans divulguer l'adresse complète.

> Ni `users.email` ni `users.phone` ne portent d'index unique en base, et il existe déjà 1 email et
> 22 téléphones en doublon : la recherche retient le compte **le plus récent**. `register()` refait
> la même vérification, pour qu'un contournement du front ne crée pas de doublon.

**Étape 1** — filtrée par le jour retenu :

| Jour choisi | Packs proposés | Programme affiché |
|---|---|---|
| Vendredi | 1, 2, 3 | Vendredi (3 créneaux + activités) |
| Samedi | 4, 5, 6 | Samedi (1 créneau + activités + **dîner**) |
| Les 2 journées | 7, 8, 9 | Vendredi **et** Samedi |

Un seul choix par créneau (radio), et « cliquez sur votre choix actuel pour le désélectionner ».

### Les 9 packs

| Pack | Groupe | Prix | Dîner vendredi inclus |
|---|---|---|---|
| 1 — Vendredi, journée | `pack_ven` | 120 | non |
| 2 — Vendredi, single | `pack_ven` | 420 | **oui** |
| 3 — Vendredi, double | `pack_ven` | 660 | **oui** |
| 4 — Samedi, journée | `pack_sam` | 120 | non |
| 5 — Samedi, single | `pack_sam` | 420 | non |
| 6 — Samedi, double | `pack_sam` | 660 | non |
| 7 — 2 journées | `pack_2j` | 200 | non |
| 8 — 2 nuitées single | `pack_2j` | 760 | **oui** |
| 9 — 2 nuitées double | `pack_2j` | 1150 | **oui** |

### Le dîner du samedi et le conjoint

Gala 170 TND ou Buffet 80 TND, avec la question « Avez-vous un conjoint ? » **sous l'option
retenue**. Le supplément est une **ligne distincte au même tarif**, comme le montre le
récapitulatif de `conjoints 3.jpg` :

```
Buffet au restaurant de l'hôtel — Samedi              80 TND
Buffet au restaurant de l'hôtel — Samedi — Conjoint   80 TND
Pack (7) : Les 2 Journées                            200 TND
──────────────────────────────────────────────────────────────
Total                                                360 TND
```

---

## 3. Architecture : les packs sont des `Workshop`

**Aucune nouvelle table.** Le mécanisme de choix exclusif existait déjà dans le projet —
`workshops.input = 'radio'` (valeur par défaut) + `groupe` partagé
(cf. `resources/views/backend/participants/workshops.blade.php:526`) :

```php
$flag = $workshop->input == 'radio' ? $workshop->groupe : $workshop->id;
// -> name="workshop[{$flag}]"  → un seul choix par groupe
```

Tout est un `Workshop` de l'événement, distingué par `groupe` et `f2` :

| Bloc | `groupe` | `f2` | `ordre` | Prix |
|---|---|---|---|---|
| Packs Vendredi | `pack_ven` | `pack` | 1-3 | 120 / 420 / 660 |
| Packs Samedi | `pack_sam` | `pack` | 4-6 | 120 / 420 / 660 |
| Packs 2 journées | `pack_2j` | `pack` | 7-9 | 200 / 760 / 1150 |
| Ven 10h-11h30 | `ven_1000` | `formation` | 20-22 | 0 |
| Ven 11h30-13h | `ven_1130` | `formation` | 30-32 | 0 |
| Ven 14h30-16h | `ven_1430` | `formation` | 40-42 | 0 |
| Activités Ven 17h | `ven_social` | `social` | 50-52 | 0 |
| Sam 14h30-15h30 | `sam_1430` | `formation` | 60-62 | 0 |
| Activités Sam 17h | `sam_social` | `social` | 70-72 | 0 |
| Dîner samedi | `sam_diner` | `addons` | 80-81 | 170 / 80 |
| Conjoints *(masqués)* | `sam_diner_conjoint` | `addons` | 82-83 | 170 / 80 |
| Vendredi soir *(masqué)* | — | `addons` | 90 | — |

**`ordre` 1 à 9 pour les packs** : le pack apparaît ainsi **en premier** dans les sessions choisies
au backoffice et dans le mail de pré-inscription. Les lignes masquées sont repoussées à 90 pour ne
pas s'intercaler entre les packs.

**Bénéfice** : exports, page catering, badges et suivi backoffice continuent de fonctionner sans
modification, puisque tout reste des `Workshop`.

---

## 4. Ce que le programme final a corrigé

Le PDF `CROPA - Programme - 2026-09-09.pdf` (texte extrait des flux compressés — le document est
partiellement vectorisé) a révélé deux écarts avec les maquettes :

1. **Les salles sont désormais connues** (Salle 1 / 2 / 3) — elles étaient vides en base.

> **Erratum** : j'avais d'abord lu dans le PDF que Micronutrition passait à 11h30-13h. Le client a
> corrigé : cet atelier est bien **le vendredi de 10h à 11h30**. Les trois créneaux du vendredi
> comptent donc 3 ateliers chacun.

Et un point que `Packs.txt` ne disait pas : **le dîner du vendredi est inclus dans les packs
2, 3, 8 et 9** — les descriptions ont été enrichies en conséquence.

Le programme mentionne aussi des éléments **non sélectionnables** (Ouverture, Conférences,
Symposium, Pause café, Déjeuners) : ce sont des éléments de programme, pas des choix d'inscription.

---

## 5. Réservé aux pharmaciens

Le domaine est **figé sur Pharmacie** (`domains.id = 2`) : aucun sélecteur de domaine, et la liste
Spécialité est celle de `Domain::find(2)->specialities` — 6 valeurs (Pharmacien d'officine,
assistant, hospitalier, grossiste, biologiste, Étudiant en pharmacie).

**Le blocage n'est pas strict, et c'est délibéré.** `users.domain` est du texte libre : sur
12 064 comptes, seuls **584** portent `Pharmacie`, **1 437** ont le champ **vide** et **933** sont
en `"Autre domaine"`. Un blocage sur « domaine ≠ Pharmacie » exclurait 95 % de la base, dont des
pharmaciens dont le profil a été créé pour un autre congrès.

La règle retenue (`CropaRegistrationService::estAutreDomaine()`) :

- `null`, `''`, `"Autre"`, `"Autre domaine"` → **laissé passer**, le domaine est ensuite fixé à Pharmacie
- `Médecine`, `Médecine Dentaire`, `Vétérinaire`, `Para Médical`, `Administrateur Santé` → **bloqué**

Le message de refus **nomme le domaine trouvé** et donne le **29752873**, pour qu'un pharmacien mal
référencé ait un recours.

---

## 6. Point de sécurité : le magic link

`MagicLinkService::generateToken()` réutilisait **indéfiniment** le même token de 6 caractères pour
un email — permanent, jamais expiré, jamais rotaté, donnant un accès complet au compte. Acceptable
comme raccourci ; **inacceptable** dès lors qu'il devient le **seul** moyen de connexion à CROPA.

**Correctif appliqué**, migration additive :

- `magic_links.expires_at` **nullable** + index sur `email` ;
- TTL **optionnel** (`MagicLinkService::DEFAULT_TTL_HOURS` = 24 h), passé en paramètre ;
- `invalidate()` disponible pour consommer un token ;
- `throttle:10,1` sur l'envoi.

**Décision du client pour CROPA** : le lien est **non expirable et réutilisable** — il est donc
émis **sans TTL** et n'est **pas** rotaté après usage, afin qu'un inscrit puisse revenir sur un
ancien mail pour modifier son inscription. Le risque a été signalé (un mail transféré ou une boîte
compromise donne un accès permanent au compte) ; le client l'a tranché en connaissance de cause.
L'outillage d'expiration reste en place et disponible pour d'autres usages.

> `expires_at` étant nullable et le TTL optionnel, les tokens déjà émis (District414, CCT) restent
> valides et le comportement de ces instances est **inchangé** — vérifié : leurs 12 tests passent.
>
> `updated_at` n'a **pas** été ajouté : le modèle a `$timestamps = false`, l'ajouter aurait cassé
> les insertions existantes.

---

## 7. Mail de pré-inscription

Le système de la plateforme est **branché**. Le mécanisme d'origine
(`ManageController::sendPreregisterEmail()`) n'est appelé que depuis le backoffice ; le parcours
CROPA passe donc par un Job dédié.

**`App\Jobs\SendCropaPreregisterEmail`**, dispatché après chaque enregistrement de choix :

- template `emails.pre-registration.12jcropa`, avec repli sur `default` ;
- expéditeur, sujet, reply-to, BCC et CC pris sur l'événement ;
- `editLink` = magic link **à durée limitée** (la connexion CROPA ne se fait que par ce moyen) ;
- incrémente `participants.notified`, comme le backoffice ;
- un échec d'envoi est journalisé sans jamais casser une inscription enregistrée.

Un Job **distinct** de `ResendPreregisterEmail` : celui-ci sert District414, dont les données
(hôtel, district, club) et le token permanent ne s'appliquent pas ici.

**Config vérifiée sur l'événement 64** — tout est déjà renseigné :

| Champ | Valeur |
|---|---|
| `pre_registration_sender_email` | `cropa@mediknode.com` |
| `pre_registration_sender_name` | `CROPA` |
| `pre_registration_email_subject` | `12ème Journées Pharmaceutiques CROPA : {whois} - Demande de Pré-inscription` |
| `pre_registration_reply_to_email` | `cropa@mediknode.com` |
| `pre_registration_bcc_email` | `cropa@mediknode.com` |
| `has_paiement` | `1` (le tableau des montants s'affiche) |

**Pourquoi un template dédié plutôt que `default`** : le template `default` filtre les lignes sur
`@if ($workshop->workshop->visible_fe)`. Le supplément conjoint étant masqué du formulaire
(`visible_fe = 0`) **mais facturé**, il aurait disparu du mail et rendu le total incohérent. Le
template `12jcropa` affiche toutes les lignes réellement enregistrées, triées par `ordre`.

---

## 8. Mode de paiement

Les modes proposés viennent de la table **`paiements`** (une ligne par événement), comme pour les
autres congrès de la plateforme — ils ne sont pas codés en dur, les libellés variant d'un
événement à l'autre (« Virement bancaire », « Espèce - Chèque », « Bon de commande », « Prise en
charge »…).

`CropaRegistrationService::modesPaiement()` lit `visible_fe = 1`, trié par `order`, et renvoie les
`name` — la valeur enregistrée dans `participants.paiement_mode` est ce `name`, exactement comme le
fait le backoffice (cf. `ManageController` et `backend/participants/workshops.blade.php:679`).

**Déjà saisis pour l'événement 64** : `Virement bancaire` et `Chèque`, tous deux visibles côté site.

Le champ reste **optionnel** (le règlement se fait hors ligne), mais un libellé non configuré est
**refusé** par `CropaSessionsRequest` : on n'enregistre pas un mode inventé. Si aucun mode n'est
saisi au backoffice, le bloc n'apparaît pas du tout.

---

## 9. Fichiers

### Créés

| Fichier | Rôle |
|---|---|
| `app/Console/Commands/SeedCropaPacks.php` | `cropa:seed-packs` — structure des 33 workshops, idempotent |
| `app/Services/CropaRegistrationService.php` | Métier : user, participant, programme, choix, capacité, total, modes de paiement |
| `app/Jobs/SendCropaPreregisterEmail.php` | Mail de pré-inscription |
| `app/Http/Controllers/Frontend/CropaPageController.php` | Landing + étape 0 |
| `app/Http/Controllers/Frontend/CropaAuthController.php` | check-email / register / magic-login / logout |
| `app/Http/Controllers/Frontend/CropaRegistrationController.php` | Étape 1 / sessions / store / confirmation |
| `app/Http/Requests/CropaRegisterRequest.php` | Validation étape 0 (messages FR) |
| `app/Http/Requests/CropaSessionsRequest.php` | Validation étape 1 |
| `database/migrations/2026_09_09_100000_add_expires_at_to_magic_links_table.php` | `expires_at` + index email |
| `resources/views/frontend/cropa/layouts/master.blade.php` | Layout dédié |
| `resources/views/frontend/cropa/landing.blade.php` | Landing : cover + contenu + 2 boutons |
| `resources/views/frontend/cropa/home.blade.php` | Étape 0 |
| `resources/views/frontend/cropa/inscription.blade.php` | Étape 1 |
| `resources/views/frontend/cropa/confirmation.blade.php` | Récapitulatif |
| `resources/views/frontend/cropa/partials/option.blade.php` | Une option + bloc conjoint |
| `resources/views/emails/cropa/magic-login.blade.php` | Email lien de connexion |
| `resources/views/emails/pre-registration/12jcropa.blade.php` | Mail de pré-inscription CROPA |
| `tests/Feature/Frontend/Cropa/CropaRegistrationTest.php` | 62 tests Feature |

### Modifiés

| Fichier | Modification |
|---|---|
| `routes/web.php` | Groupe `Route::domain(cropa.…)` — 10 routes nommées, dans le bloc `mediknode` |
| `config/app.php` | `cropa_sub_domain` + `cropa_event_slug` |
| `.env.mediknode` | `APP_CROPA_SUB_DOMAIN=cropa`, `CROPA_EVENT_SLUG=12jcropa` |
| `.env.testing` | `APP_CROPA_SUB_DOMAIN=cropa` |
| `app/Services/MagicLinkService.php` | TTL optionnel, rotation, `invalidate()` — **rétrocompatible** |
| `app/Models/MagicLink.php` | `expires_at` + `isExpired()` |
| `app/Providers/AppServiceProvider.php` | Singleton `CropaRegistrationService` |

### Routes

| Méthode | URI | Nom | Rôle |
|---|---|---|---|
| `GET` | `/` | `cropa.landing` | Landing : cover, contenu, boutons |
| `GET` | `/inscription/informations` | `cropa.home` | Étape 0 |
| `POST` | `/check-email` | `cropa.check-email` | Email connu ? |
| `POST` | `/register` | `cropa.register` | Créer le compte |
| `GET` | `/a/{token}` | `cropa.magic-login` | Connexion |
| `POST` | `/logout` | `cropa.logout` | Déconnexion |
| `GET` | `/inscription` | `cropa.inscription` | Étape 1 *(auth)* |
| `GET` | `/api/sessions` | `cropa.sessions` | Programme JSON *(auth)* |
| `POST` | `/inscription` | `cropa.inscription.store` | Enregistrer *(auth)* |
| `GET` | `/confirmation` | `cropa.confirmation` | Récapitulatif *(auth)* |

---

## 10. Vérifications effectuées

**77 tests verts / 229 assertions** — dont : magic link CROPA non expirable et réutilisable,
blocage par domaine (et non-blocage des domaines indéterminés), pack obligatoire, un seul choix par
créneau, pack d'un autre jour refusé, filtrage vendredi/samedi/2 jours, atelier complet refusé,
conjoint ajouté/retiré, supplément jamais proposé directement, pack en tête du récapitulatif, mail
dispatché, audit écrit, isolation inter-événements, modes de paiement lus depuis la table (mode non configuré refusé, bloc masqué si la table est vide), et non-résurrection d'une inscription supprimée au backoffice.

**Non-régression** : les 12 tests magic link existants passent → District414 et CCT intacts.
Suite complète : 63 échecs, **tous préexistants** — vérifié en rejouant la suite sur un worktree à
HEAD (63 échecs identiques avant les changements).

**`vendor/bin/pint`** appliqué sur tous les fichiers touchés.

**Parcours réel sur `php artisan serve`**, avec les vraies données de l'événement 64 :

| Scénario | Total | Conforme à |
|---|---|---|
| Pack (7) + Huiles + Yoga + Buffet + Conjoint | **360 TND** | `conjoints 3.jpg` ✓ |
| Pack (9) + Gala + Conjoint | **1490 TND** | 1150 + 170 + 170 ✓ |
| Pack (1) seul, ateliers inclus | **120 TND** | ✓ |

Également vérifié en direct : landing avec le vrai titre, bouton « S'inscrire aux journées » puis
« Modifier mon inscription » après inscription, bouton programme et son lien PDF, refus 403 d'un
compte `Médecine` avec message nommant le domaine, passage d'un compte au domaine vide, mail de
pré-inscription parti avec le pack en première ligne et la ligne Conjoint présente (total 460 TND),
et **non-régression** de `events.` (200) / `my-events.` (302).

**Données de test supprimées** après chaque vérification.

---

## 11. Déploiement

1. `.env.online.mediknode` : `APP_CROPA_SUB_DOMAIN=cropa` et `CROPA_EVENT_SLUG=12jcropa`
2. **DNS** : enregistrement A `cropa.mediknode.com` → IP du serveur
3. **SSL / vhost** : ajouter `cropa.mediknode.com` au certificat (SAN Let's Encrypt ou wildcard)
4. `php artisan migrate` (ajoute `magic_links.expires_at`)
5. **Créer les 33 workshops** :

   ```bash
   php artisan cropa:seed-packs --dry-run   # contrôle, n'écrit rien
   php artisan cropa:seed-packs             # applique
   ```

   La commande retrouve l'événement **par son slug** (`CROPA_EVENT_SLUG`), pas par un id codé
   en dur : elle fonctionne quel que soit l'`event_id` en production. Elle est **idempotente**
   (rejouable sans créer de doublon) et signale en fin d'exécution les lignes de l'événement
   qu'elle ne gère pas, s'il y en a.

   Vérifié sur un événement **vierge** (le cas de la production, qui n'a aucun workshop) :
   **31 créations**, 29 visibles au formulaire, tous les groupes aux bons prix, packs en
   `ordre` 1-9. Les 2 lignes du vendredi soir n'apparaissent pas — elles avaient été saisies
   à la main en local et sont de toute façon masquées.
6. `php artisan config:cache && php artisan route:cache`
7. **Vérifier les modes de paiement** de l'événement (table `paiements`, saisie backoffice).
   En local : `Virement bancaire` et `Chèque`, tous deux `visible_fe = 1`. S'ils manquent en
   production, le bloc « Mode de paiement » n'apparaîtra simplement pas au formulaire.
8. Renseigner au backoffice : **`cover`** (affiche) et **`content`** (texte de la landing) —
   les deux sont vides en base ; puis `has_program` + `link_program` quand le PDF est fourni
9. Vérifier `robots.txt` (actuellement `Disallow: /`)

> **Local** : ajouter `127.0.0.1 cropa.mediknode.local` au fichier hosts.
> `APP_DOMAIN` reste `mediknode.local` — les entrées `medik.com` ne sont pas utilisées.
>
> **Ne pas décommenter `SESSION_DOMAIN`** dans `.env.mediknode` : chaque sous-domaine doit garder
> son cookie isolé, un visiteur CROPA ne devant pas hériter d'une session ouverte sur `events.`.

---

## 12. Points en attente

### Côté client

1. **Dîner + Soirée animée du vendredi** — masqués (`visible_fe = 0`, `ordre = 90`) en attendant sa
   réponse, car le programme les annonce comme inclus dans les packs 2, 3, 8 et 9. Leur prix est
   resté à 50 TND (valeur d'origine) puisque leur statut n'est pas tranché. Ils restent en base :
   le catering du backoffice continue de les voir.
2. **`robots.txt`** — le site doit-il être indexable ?

### Contenu à saisir au backoffice

- **`cover`** et **`content`** de l'événement 64 : vides. La landing affiche un message d'attente
  pour l'affiche et masque le bloc de contenu.
- **`link_program`** : le PDF du programme n'est pas encore déposé (`has_program = 0`), le bouton
  est donc masqué. Il apparaîtra dès que les deux champs seront renseignés.

### Écarts assumés par rapport aux maquettes

- **Civilité** et **Pays** ne sont pas dans l'étape 0 : les maquettes ne les montrent pas.
  Les deux champs restent acceptés côté serveur si le client les redemande.
- **Pas de composant Livewire** : le projet n'en contient aucun (`resources/views/livewire/` vide,
  aucune classe). En introduire un ici aurait ajouté un risque inutile — l'endpoint
  `/livewire/update` serait à autoriser sur ce sous-domaine. Le JS suit District414 et CCT.
- **Sémantique de `capacity`** : la colonne est `NOT NULL default(25)`. Le projet traite **`0` = pas
  de limite** (cf. `ParticipantWorkshopObserver`), convention suivie ici : packs et dîners à 0,
  ateliers à 35.
- **`ParticipantWorkshop::insert()` écarté** au profit de `create()` ligne à ligne : un insert de
  masse contourne l'observer, donc pas de journal d'audit ni d'alerte de capacité. Un test le vérifie.

### Corrigé : le code de pré-inscription était attribué trop tôt

Le code (`P2609090100024`) était généré dès la création du participant, à l'étape 0, et incrémentait
`events.last_pre_register`. Conséquence : **chaque visiteur qui abandonnait le formulaire consommait
un numéro**, et l'écran de choix affichait un code alors que rien n'était encore validé. En local,
le compteur était monté à 23 pour seulement 3 participants réels.

Le code est désormais attribué dans `enregistrerChoix()`, à la validation, via
`attribuerCodePreInscription()` qui incrémente le compteur **sous verrou** (`lockForUpdate`) — deux
validations simultanées ne peuvent pas obtenir le même numéro. Il est conservé tel quel si le
participant revient modifier son choix. Quatre tests couvrent ce comportement.

### Corrigé : résurrection d'une inscription supprimée

Une inscription supprimée au backoffice **réapparaissait** dès que l'internaute revisitait une page
CROPA : `findOrCreateParticipant()` était appelé par toutes les pages, y compris celles en lecture
seule (`/confirmation`, `/api/sessions`), et recréait donc une ligne vide.

`CropaRegistrationService::participant()` lit désormais sans créer, et seules les pages qui
engagent réellement une inscription (étape 1 et enregistrement) utilisent `findOrCreate…`.
`/confirmation` redirige vers le formulaire si l'inscription n'existe plus. Un test le vérifie.

> À noter : `Participant` **importe** `SoftDeletes` mais ne l'utilise pas, et la table n'a pas de
> colonne `deleted_at` — la suppression est bien physique, contrairement à ce que suggère le
> `CLAUDE.md`.

### Comportement à surveiller

Le mail de pré-inscription est envoyé **à chaque enregistrement** de la sélection. Un participant
qui ajuste son choix trois fois reçoit trois mails (et `notified` s'incrémente d'autant). C'est le
comportement du backoffice ; à confirmer si le client préfère un envoi unique, ou un mail
uniquement au premier enregistrement.
