# Cahier de recette — chantier schéma de production (#137 et décisions liées)

**Périmètre livré** —
Back : PR [#146](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/146) (rattrapage #137 + abandon du raccourci catégorie, décision A2) ·
[#149](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/149) (divergences D2-D3, D5-D11) ·
[#150](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/150) (purge `user_points` + garde anti-doublon, #143) ·
[#151](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/151) (réponses de quiz longues, #142) ·
[#154](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/154) (garde-fou D4 « jamais de cours orphelin », #152).
Docs : PR [#145](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/pull/145) (rapport de diff + procédure de recalage corrigée + outillage).

> **Préalable** : les 5 PR back mergées sur `development` et le staging déployé
> (`php artisan migrate` inclus — c'est lui qui applique les corrections de schéma).
> L'écran de réaffectation (#153, front) n'est PAS dans ce périmètre : au S5, le refus
> s'observe par le message d'erreur, c'est le comportement attendu à ce stade.

**Convention** : ☐ → ✅ / ❌ + constat. Dérouler sur staging avec un compte admin,
un compte manager et un compte apprenant.

---

## S0 · Déploiement (vérification technique, 5 min)

- ☐ **S0.1** `php artisan migrate --force` sur staging se termine **sans erreur** — en
  particulier sans errno 1060 (« Duplicate column ») : c'est le cas miroir de #137 que
  les gardes d'idempotence doivent absorber.
- ☐ **S0.2** Rejouer `php artisan migrate --force` une seconde fois → « Nothing to
  migrate », aucune erreur (idempotence).
- ☐ **S0.3** Relancer le diff outillé (`scripts/schema/`, procédure au §9 du rapport
  `19_DIFF_SCHEMA_PROD.md`) entre une base vierge locale et le staging → **zéro écart**
  sur les colonnes du chantier (`courses.category_id` absente des deux côtés,
  `option_text` en `text`, `user_points` purgée, divergences D2-D11 résorbées).

## S1 · Catalogue filtré par catégorie (A2 — #137/#141, PR #146)

**Le test discriminant : le filtre qui renvoyait une 500.**

- ☐ **S1.1** (Manager) Catalogue des formations, filtrer par une catégorie qui contient
  des cours. → La liste s'affiche (**plus d'erreur**) et ne contient QUE les cours dont
  la sous-catégorie appartient à cette catégorie.
- ☐ **S1.2** (Manager) Filtrer par l'autre catégorie. → Le contenu change en conséquence
  (le filtre est discriminant, pas décoratif).
- ☐ **S1.3** (Apprenant) Le catalogue front filtré par catégorie fonctionne comme avant
  (il passait déjà par la jointure — non-régression).
- ☐ **S1.4** (Manager) Ouvrir la fiche d'un cours. → La catégorie affichée est bien celle
  de sa sous-catégorie (avant : champ vide/null en silence).
- ☐ **S1.5** (Manager) Créer un cours en choisissant une sous-catégorie, puis filtrer le
  catalogue par la catégorie parente. → Le nouveau cours apparaît. *(C'était le trou du
  backfill : les cours créés après coup échappaient au filtre.)*

## S2 · Réponses de quiz longues (#142, PR #151)

- ☐ **S2.1** (Manager) Créer une question avec une réponse de ~300 caractères. → Enregistrée
  et affichée **intégralement**, côté admin comme côté apprenant (pas de troncature).
- ☐ **S2.2** (Manager) Tenter une réponse > 1000 caractères. → Refus **propre** avec un
  message de validation ciblé sur le champ (pas d'erreur serveur).
- ☐ **S2.3** (Apprenant) Dérouler un quiz existant contenant des réponses longues
  (28 cas réels en prod). → Affichage complet, réponse correcte évaluée normalement.

## S3 · Points et badges (#143, PR #150)

- ☐ **S3.1** (Apprenant) Déclencher une action à points (ex. terminer un cours). → Les
  points s'ajoutent au total, conformément au barème paramétré (`point_master`).
- ☐ **S3.2** (Apprenant) Re-déclencher la même action. → **Pas de double attribution**
  (le total ne bouge plus pour cette action).
- ☐ **S3.3** (Apprenant) Les badges continuent de s'attribuer normalement (système
  distinct, non touché — non-régression).

## S4 · Défauts et divergences (#144, PR #149)

- ☐ **S4.1** (Manager) Créer un quiz **sans toucher** au seuil de réussite. → Le quiz
  reçoit un seuil de **80 %** (jamais « pas de seuil » = toujours réussi).
- ☐ **S4.2** (Manager) Créer un quiz sans préciser « obligatoire ». → Il est explicitement
  **non obligatoire** (ne bloque pas la progression), pas dans un état indéfini.
- ☐ **S4.3** (Admin) Créer une catégorie sans toucher à l'ordre. → Elle prend le rang 0 et
  le **drag & drop** de réorganisation (catégories ET cours) fonctionne comme avant.
- ☐ **S4.4** (Apprenant) Parcourir un cours complet (leçons, quiz, progression). → Aucun
  comportement changé : les resserrages de schéma sont invisibles à l'usage.

## S5 · Jamais de cours orphelin (D4 — #152, PR #154)

- ☐ **S5.1** (Admin) Tenter de supprimer une **sous-catégorie qui porte des cours**. →
  Refus avec un message clair indiquant le **nombre de cours** à réaffecter. La
  sous-catégorie et les cours sont intacts.
- ☐ **S5.2** (Admin) Réaffecter ces cours à une autre sous-catégorie (édition du cours,
  unitairement — l'écran de masse arrive avec #153), puis re-supprimer. → Suppression OK.
- ☐ **S5.3** (Admin) Tenter de supprimer une **catégorie** dont une sous-catégorie porte
  des cours. → Refus avec le même type de message (le garde remonte la chaîne).
- ☐ **S5.4** (Admin) Supprimer une catégorie dont les sous-catégories sont **vides**. →
  OK (des sous-catégories vides ne bloquent pas).
- ☐ **S5.5** (Admin) Créer un cours : la sous-catégorie est **exigée** (impossible de
  créer un cours sans rattachement).

## S6 · Non-régression générale (15 min)

- ☐ **S6.1** (Apprenant) Parcours nominal complet : s'inscrire à un cours → dérouler les
  supports → réussir le quiz final → certificat. Rien de cassé.
- ☐ **S6.2** (Manager) CRUD cours complet (créer, modifier, changer le statut, supprimer).
- ☐ **S6.3** (Admin) Le renouvellement d'abonnement Stripe reste fonctionnel *(si testable
  sur staging ; sinon, vérification technique : la colonne `subscriptions.subscription_item_id`
  existe et le webhook ne journalise pas d'erreur)*.

---

## Suites de la recette

| Constat | Geste |
|---|---|
| Tout ✅ | Fermer #137, #141 (sans objet), #142, #143, #144, #152 — à la main, conformément à `00_STATUTS_ISSUES.md` |
| Un ❌ sur S0-S5 | Commenter l'issue correspondante avec le constat ; la PR fautive repasse en dev |
| S5 jugé trop contraignant sans l'écran | Prioriser #153 (front) — le garde reste, c'est l'outillage qui manque |

---

## Résultats — recette sur STAGING déployé (08/08/2026)

Environnement : `staging.efektiv-academie-dev.com` + `api-staging.efektiv-academie-dev.com`,
**données réelles** (43 cours, 414 supports, 132 inscriptions, 96 utilisateurs).
Déploiement : `development` @ `b903f32`, les 7 PR mergées. Vérifications exécutées
côté serveur sur le code réellement déployé ; fixtures préfixées `RECETTE137`,
toutes supprimées en fin de passe.

**Verdict : 22 contrôles ✅, 0 ❌.** Aucun blocage sur le périmètre du chantier.

| Section | Résultat |
|---|---|
| **S0** Déploiement | ✅ `migrate` sans erreur (6 migrations) · rejoué → « Nothing to migrate » · API et front **HTTP 200** |
| **S0.3** Diff objet par objet | ✅ **64 tables / 506 colonnes des deux côtés — 0 manquante, 0 en trop** · les 11 divergences du chantier résorbées |
| **S1** Catalogue par catégorie | ✅ filtre exécuté **sans erreur SQL** (Formations Groupe 10, Parcours 7, Tutoriels 17, Formation Construction 9 / 43) · discriminant · catégorie dérivée de la sous-catégorie · **un cours créé après le correctif apparaît bien dans le filtre** |
| **S2** Réponses longues | ✅ `option_text` = `text` · 845 réponses intactes, max **295 caractères** · validation `max:1000` active |
| **S3** Points et badges | ✅ colonnes mortes purgées · **index unique `(user_id, point_master_id)` posé** · 112 attributions et 55 badges conservés |
| **S4** Défauts | ✅ quiz sans seuil → **80** · sans drapeau → `no` · catégorie sans rang → **0** |
| **S5** Cours orphelins | ✅ suppression refusée **409** avec le compte de cours (« 9 cours sont encore rattachés… ») · sous-catégorie vide supprimable **200** · garde remonté à la catégorie · `subcategory_id` **NOT NULL** |
| **S6** Non-régression | ✅ volumétrie intacte · cohérence `is_complete` ↔ `completed_courses` : **0 incohérence** · colonne Stripe présente |

> **Note de méthode sur S5.3.** Le premier passage a renvoyé 404 au lieu de 409. Diagnostic :
> le jeu d'essai avait tiré `Subcategory::first()`, dont la catégorie parente **n'existe pas**
> (cf. constat ci-dessous) — 404 « enregistrement introuvable » était donc la réponse correcte.
> Rejoué sur une sous-catégorie dont la catégorie existe : **409 avec le bon message**. Le
> garde n'était pas en cause ; le jeu d'essai l'était.

### Reste à valider en interface (hors périmètre serveur)

Les contrôles ci-dessus portent sur le code déployé et les données réelles, exécutés côté
serveur. Restent à confirmer par un passage humain dans l'interface, sans enjeu de schéma :
rendu du filtre catalogue et du message de refus côté admin (S1.1, S5.1), affichage d'une
réponse longue côté apprenant (S2.3), parcours apprenant complet jusqu'au certificat (S6.1).

---

## Deux constats découverts pendant la recette (hors périmètre, non bloquants pour le staging)

### 1. La production ne peut PAS recevoir ce déploiement en l'état — issue #157

Rejouer `migrate` sur une **réplique fidèle de la structure de production** échoue à
`2026_08_06_100001_create_login_events_table` :

```
SQLSTATE[HY000] 1824 Failed to open the referenced table 'users'
```

Cause : les tables des lots L1/L2 naissent en **InnoDB** (moteur par défaut) et déclarent une
clé étrangère vers `users`, qui est en **MyISAM** — InnoDB ne peut pas référencer MyISAM.
**Le blocage préexiste à ce chantier** : il tient aux migrations L1/L2, pas aux nôtres. Il
confirme que **#136 (conversion InnoDB) est un prérequis dur au déploiement en production**,
pas une amélioration. Le staging, déjà converti, n'est pas concerné.

### 2. Des références pendouillantes bloqueront la création des FK de #136

Héritage direct de MyISAM, qui n'a jamais fait respecter les clés étrangères :

| Lien | Production | Staging |
|---|---|---|
| `subcategories → categories` | **1** | **30** |
| `lessons → sections` | **89** | **231** |
| `enrollments → courses` | 0 | **4** |
| `enrollments → users` | 0 | **3** |

Ces lignes référencent des parents qui n'existent plus. La conversion InnoDB de #136
**échouera (errno 1452)** sur la création des FK correspondantes tant qu'elles n'auront pas
été traitées (réaffectation ou purge — décision produit). Aucun cours n'est concerné :
`courses → subcategories` et `courses → course_types` sont **à 0 des deux côtés**.

### 3. Trois divergences propres au staging (hors chantier)

Le diff objet par objet du staging déployé fait apparaître 3 écarts qui n'existaient pas
en production et n'appartiennent donc pas au périmètre #137 :
`invitation_user.sent_by` (nullabilité), `questions.order_no` (défaut),
`users.is_active` (nullabilité + défaut `0` au lieu de `1`). À traiter avec #136, ou à
verser au prochain diff — ils n'affectent pas la recette.
