# Diff exhaustif du schéma de production (issue #137)

Réalisé le 08/08/2026. **Opération en lecture seule sur la production** : aucune écriture,
aucune fenêtre de maintenance, aucun impact utilisateur.

Méthode conforme au §2 du handoff (`18_HANDOFF_DIFF_SCHEMA_PROD.md`) : **comparaison objet par
objet**, jamais par nom de migration. La table `migrations` n'a servi qu'à *expliquer* les écarts
constatés sur les objets, jamais à conclure qu'un objet existait.

---

## 0. Deux corrections au brief, à lire avant le reste

### 0.1 La base de production ne s'appelle pas `elearning`

Le handoff §3 désigne `elearning`. Le `.env` de la racine Laravel en service pointe en réalité sur
**`e-learning-prod`**. Les deux bases coexistent sur le serveur :

| Base | Tables | Utilisateurs | Dernière activité | Verdict |
|---|---|---|---|---|
| **`e-learning-prod`** | **58** | **301** | **06/08/2026** | **base réellement servie** |
| `elearning` | 55 | 12 | 12/02/2026 | copie figée, abandonnée |

**Conséquence sur les documents antérieurs** : le constat « toute la prod est en MyISAM (55 tables) »
(handoff §4, issu de l'audit doc 11) porte sur `elearning`, c'est-à-dire **la mauvaise base**. Le
constat MyISAM reste vrai pour la vraie base — mais il l'est sur **58 tables**, et il a été établi
sur un périmètre qui n'était pas celui de la production. Tout chiffre repris des docs 07 et 11 est à
re-vérifier avant usage.

Le présent diff porte exclusivement sur **`e-learning-prod`**.

### 0.2 Le recalage du 06/08 n'est pas la seule cause des écarts

Le handoff attribue l'incident au recalage du 06/08 (batch 5, 23 migrations insérées sans être
exécutées). C'est exact, mais incomplet : **`courses.category_id` est enregistrée en batch 1**,
c'est-à-dire depuis l'installation d'origine. Cet écart **préexiste au recalage**. La base de
production était déjà désynchronisée de ses migrations avant l'opération du 06/08 — celle-ci a
aggravé et masqué un problème plus ancien, elle ne l'a pas créé.

---

## 1. Vue d'ensemble

| | Référence (`migrate:fresh` sur `development`) | Production (`e-learning-prod`) |
|---|---|---|
| Moteur | InnoDB | **MyISAM (58/58)** |
| Tables | 64 | 58 |
| Colonnes | 506 | 442 |
| Clés étrangères | 64 | **0** |
| MySQL | 9.6.0 (local) | 8.0.46 |

> Les écarts de représentation entre MySQL 8.0 et 9.6 (largeur d'affichage des entiers, quotage des
> défauts) sont neutralisés par le comparateur (`scripts/schema/diff_schema.py`) : les divergences
> listées au §5 sont réelles, pas des artefacts de version.

**Bilan** : 6 tables manquantes, 17 colonnes manquantes, 3 colonnes en trop, 11 divergences,
12 index manquants, 2 index en trop, 64 clés étrangères absentes.

---

## 2. Ce qui se répare tout seul, et ce qui ne se répare jamais

C'est la distinction structurante, et elle ne se lit pas dans le nombre d'écarts :

- Un écart dont la migration est **non enregistrée** sera comblé au prochain `migrate` : Laravel
  sait qu'il doit la jouer. Ce n'est pas de la dette, c'est du retard de déploiement.
- Un écart dont la migration est **enregistrée** ne sera **jamais** comblé : Laravel la croit
  appliquée et ne la rejouera pas. Seule une migration de rattrapage peut le corriger.

Sur les 17 colonnes manquantes, **une seule** relève du second cas.

| Catégorie | Nombre | Traitement |
|---|---|---|
| Manquant, migration **non enregistrée** | 6 tables + 16 colonnes | Se comble au prochain déploiement |
| Manquant, migration **enregistrée** (le piège) | **1 colonne** (`courses.category_id`) | **Migration de rattrapage** |
| En trop en prod | 3 colonnes + 2 index | Décision produit (§4) |
| Divergent | 11 colonnes | Décision produit (§5) |
| Clés étrangères | 64 absentes | Prérequis #136 (MyISAM → InnoDB) |

---

## 3. Manquant en production

### 3.1 L'écart qui ne se comblera jamais — `courses.category_id`

| Objet | Attendu | Prod | Migration | Statut |
|---|---|---|---|---|
| `courses.category_id` | `bigint unsigned NULL` | **absent** | `2025_10_03_105315_add_category_id_into_courses` | **enregistrée batch 1** |
| `courses.category_id_foreign` (index) | index simple | absent | idem | idem |

**Impact — visible par les apprenants, en production, maintenant.**
`CourseController.php:68` filtre le catalogue sur cette colonne :

```php
if (isset($request->category_id)) {
    $data = $data->where('category_id', $request->category_id);
}
```

La colonne étant absente, **tout filtrage du catalogue par catégorie renvoie une erreur SQL 1054
(`Unknown column`)**, donc une 500. Le champ est par ailleurs déclaré dans `Course::$fillable` et la
relation `Course::category()` existe : le modèle promet une colonne que la base n'a pas.

C'est l'écart qui a fait échouer EA-001 le 08/08 (errno 1072), et c'est le cœur de l'issue #137.

> ⚠️ **Décision requise (§7.1)** : créer la colonne rétablit la cohérence du schéma et supprime la
> 500, mais la colonne naît vide → le filtre renverra une **liste vide** au lieu d'une erreur. Le
> comportement reste faux tant que les valeurs ne sont pas reconstituées depuis
> `subcategories.category_id`. Le rattrapage de données est une décision produit, pas un geste
> technique : il n'est **pas** inclus dans la migration.

### 3.2 Tables manquantes (6) — se comblent au prochain déploiement

Toutes issues des lots L1/L2, migrations **non enregistrées** : `migrate` les créera normalement.

| Table | Migration | Fonctionnalité |
|---|---|---|
| `login_events` | `2026_08_06_100001` | Pilotage — connexions |
| `lesson_views` | `2026_08_06_100003` | Pilotage — consultation des supports |
| `quiz_attempts` | `2026_08_06_100004` | Pilotage — tentatives de quiz |
| `media_progress` | `2026_08_06_100005` | Pilotage — progression média |
| `resource_downloads` | `2026_08_06_100006` | Pilotage — téléchargements |
| `support_types` | `2026_08_06_200001` | Typage des supports |

### 3.3 Colonnes manquantes (16) — se comblent au prochain déploiement

| Colonne | Migration (non enregistrée) | Fonctionnalité |
|---|---|---|
| `categories.slug`, `courses.slug`, `subcategories.slug` | `2026_05_13_000001` | URLs lisibles |
| `lessons.slug`, `quizzes.slug` | `2026_05_13_000002` | URLs lisibles |
| `learning_paths.slug` | `2026_06_09_131454` | URLs lisibles |
| `learning_paths.subcategory_id` | `2026_06_12_000000` | Rattachement parcours |
| `users.cta_url` | `2026_06_16_000000` | CTA utilisateur |
| `users.last_login_at` | `2026_08_06_100002` | Pilotage |
| `lessons.support_type_id` | `2026_08_06_200002` | Typage des supports |
| `lessons.duration_seconds`, `lessons.duration_source` | `2026_08_07_100001` | Durées |
| `quizzes.duration_seconds`, `quizzes.duration_source` | `2026_08_07_100001` | Durées |
| `enrollments.is_complete`, `sections.description` | `2026_08_07_200001` (EA-001) | Complétion / descriptif |

---

## 4. En trop en production

| Colonne | Type prod | Référencée par le code ? | Verdict |
|---|---|---|---|
| `subscriptions.subscription_item_id` | `varchar(125) NULL` | **Oui** — `Subscription::$fillable` + `StripeWebhookController:91,101` | **Pas une colonne morte : une colonne oubliée des migrations** |
| `user_points.action_key` | `varchar(191) NULL` | Écrite mais **hors `$fillable`** | Décision (§7.3) |
| `user_points.points` | `int NULL` | Écrite mais **hors `$fillable`** | Décision (§7.3) |

**`subscriptions.subscription_item_id`** inverse la lecture naïve : la production a raison, c'est la
**référence qui est fausse**. Le webhook Stripe écrit cette colonne ; aucune migration ne la crée.
Toute installation neuve (staging reconstruit, environnement local, futur serveur) **casse le
renouvellement d'abonnement Stripe**. Correctif : une migration qui l'ajoute — la prod n'en est pas
affectée (garde d'idempotence), les environnements neufs sont réparés.

**`user_points.action_key` / `points`** révèlent un bug latent. `PointSystemRepository::handleConsecutiveLoginDays()`
les écrit :

```php
UserPoint::create([
    'user_id' => $user_id, 'point_master_id' => $pointMaster->id,
    'points' => $pointMaster->points, 'action_key' => $pointMaster->action_key,
]);
```

mais `UserPoint::$fillable` ne contient que `user_id` et `point_master_id` : Laravel **écarte
silencieusement** les deux autres. Les colonnes existent en prod et sont **toujours NULL**. L'index
unique `user_points_user_id_action_key_unique` (§6) est donc inopérant (MySQL autorise les NULL
multiples dans un index unique).

### Index en trop

| Index | Table | Remarque |
|---|---|---|
| `user_points_user_id_action_key_unique` | `user_points` | Inopérant (colonne toujours NULL) — lié à la décision §7.3 |
| `completed_courses_learner_id_foreign` | `completed_courses` | Index simple, redondant une fois l'unique `(learner_id, course_id)` posé. Sans risque. |

---

## 5. Divergences — la catégorie silencieuse

Existent des deux côtés, avec une définition différente. Aucune ne lève d'erreur : c'est ce qui les
rend dangereuses.

| # | Colonne | Référence | Production | Impact | Traitement |
|---|---|---|---|---|---|
| D1 | `options.option_text` | `varchar(125)` | **`text`** | **28 réponses de quiz dépassent 125 caractères (max 295)** | **Décision §7.2 — apprenants** |
| D2 | `lessons.course_id` | `bigint unsigned` | `int` | Bloque la FK `lessons → courses` | Prérequis #136 |
| D3 | `learning_paths.course_type_id` | `bigint unsigned` | `bigint` (signé) | Bloque la FK | Prérequis #136 |
| D4 | `courses.subcategory_id` | `NULL` autorisé | `NOT NULL` | Prod plus stricte ; `nullOnDelete` (PR #95) ne peut pas s'appliquer | Prérequis #136 |
| D5 | `categories.order_no` | `NULL`, défaut `NULL` | `NOT NULL`, défaut `0` | Tri des catégories | Décision §7.4 |
| D6 | `courses.order_no` | défaut `NULL` | défaut `0` | Tri des cours | Décision §7.4 |
| D7 | `quizzes.is_required` | `NOT NULL` | `NULL` autorisé | Quiz obligatoire ou non | Décision §7.4 |
| D8 | `quizzes.order_no` | `NOT NULL` | `NULL` autorisé | Ordre des quiz | Décision §7.4 |
| D9 | `quizzes.passing_percentage` | `NOT NULL` | `NULL` autorisé | **Seuil de réussite** | Décision §7.4 |
| D10 | `sections.order_no` | `NOT NULL` | `NULL` autorisé | Ordre des sections | Décision §7.4 |
| D11 | `user_points.point_master_id` | `NOT NULL` | `NULL` autorisé | Attribution des points | Décision §7.4 |

**Vérification en données (lecture seule, 08/08)** : `quizzes.is_required`, `quizzes.order_no`,
`quizzes.passing_percentage`, `sections.order_no` et `user_points.point_master_id` comptent
**0 valeur NULL** en production. Un resserrage en `NOT NULL` passerait donc techniquement — mais il
changerait le comportement à l'écriture (une création qui omet le champ échouerait au lieu d'insérer
NULL). D'où la décision §7.4.

D5 à D11 portent toutes sur des migrations du **batch 5** (le recalage du 06/08) : les colonnes
existent, mais avec une définition qui n'est pas celle de la migration. Elles ont donc été posées
manuellement en production, à la main, avec des choix différents. Le recalage a supposé
« les objets existent » — ils existaient, mais **pas tels que le code les définit**. C'est
exactement l'angle mort que la comparaison de noms ne pouvait pas voir.

---

## 6. Index manquants (12)

| Index | Table | Cause |
|---|---|---|
| `categories_slug_unique`, `courses_slug_unique`, `subcategories_slug_unique` | | colonne `slug` manquante (§3.3) |
| `lessons_slug_unique`, `quizzes_slug_unique`, `learning_paths_slug_unique` | | idem |
| `courses_category_id_foreign` | `courses` | colonne manquante (§3.1) |
| `learning_paths_subcategory_id_foreign` | `learning_paths` | colonne manquante (§3.3) |
| `lessons_support_type_id_foreign` | `lessons` | colonne manquante (§3.3) |
| `lessons_course_id_foreign` | `lessons` | **colonne présente** — index jamais créé (MyISAM) |
| `users_parent_id_foreign` | `users` | **colonne présente** — index jamais créé (MyISAM) |
| `completed_courses_learner_course_unique` | `completed_courses` | migration `2026_08_05_000002` non enregistrée |

Les deux index sur colonnes présentes (`lessons.course_id`, `users.parent_id`) sont un enjeu de
**performance** : `lessons` (313 lignes) et `users` (301 lignes) restent petites, l'impact est
aujourd'hui négligeable. Ils reviendront naturellement avec la conversion InnoDB (#136).

**`completed_courses_learner_course_unique`** : vérifié en production, **0 doublon** sur
`(learner_id, course_id)` pour 135 lignes → la migration s'appliquera sans erreur.

## 6.bis Clés étrangères — 64 attendues, 0 réelles

Conforme à l'avertissement du handoff §4 : MyISAM ignore silencieusement les clés étrangères. Les
64 FK de la référence n'existent **dans aucune** des tables de production. Ce n'est pas un écart à
corriger colonne par colonne : c'est **l'issue #136** (conversion MyISAM → InnoDB), prérequis à toute
migration créant une FK. Les divergences D2, D3 et D4 devront être résorbées **avant** cette
conversion, sinon les FK correspondantes échoueront à la création.

---

## 7. Décisions attendues

Chacune fait l'objet d'une issue. **Les quatre touchent à un comportement visible par les apprenants** —
aucune n'est appliquée automatiquement.

| # | Sujet | Enjeu apprenant | Issue |
|---|---|---|---|
| 7.1 | Rattrapage de données `courses.category_id` | Le filtre catalogue par catégorie renverra une **liste vide** tant que les valeurs ne sont pas reconstituées depuis `subcategories.category_id` | #141 |
| 7.2 | `options.option_text` : `text` ou `varchar(125)` | Aligner sur la référence **tronquerait 28 réponses de quiz** (jusqu'à 295 caractères) | #142 |
| 7.3 | `user_points.action_key` / `points` | Câbler (`$fillable`) **modifie l'attribution des points** ; supprimer acte l'abandon de la piste de gamification par action | #143 |
| 7.4 | Nullabilité et défauts (D5–D11) | Resserrer peut faire **échouer des créations** de quiz/section ; `passing_percentage` porte le **seuil de réussite** | #144 |

---

## 8. Ce qui bloque le prochain déploiement (hors périmètre initial)

Constat non anticipé par le handoff, découvert en croisant migrations non enregistrées et objets
réellement présents. **C'est le miroir exact du problème #137** : là où le recalage a enregistré des
migrations non appliquées, on trouve ici des migrations appliquées mais non enregistrées.

Trois migrations non enregistrées ciblent des colonnes **qui existent déjà** en production :

| Migration | Colonne | Garde `hasColumn` ? | Au prochain `migrate` |
|---|---|---|---|
| `2026_03_05_144426_add_order_no_column_to_courses` | `courses.order_no` | **non** | **échec — errno 1060** |
| `2026_03_20_074911_add_is_active_to_users_table` | `users.is_active` | **non** | **échec — errno 1060** |
| `2026_03_20_080000_add_order_no_to_lessons_and_quizzes` | `lessons.order_no`, `quizzes.order_no` | oui | passe |

Sans correctif, **le prochain déploiement en production s'arrête sur la première**, exactement comme
EA-001 s'est arrêtée sur `category_id` le 08/08. Correctif porté par la PR de rattrapage : ajout des
gardes d'idempotence manquantes (aucun changement de comportement).

---

## 9. Reproduire ce diff

L'outillage est versionné pour que le contrôle soit rejouable — c'est ce qui rend la procédure
corrigée (`15_BASCULE_SERVEUR_VERS_GIT.md`, étape 5) exécutable plutôt qu'incantatoire.

```bash
# 1. Schéma de référence : ce que le code CROIT être le schéma
mysql -u root -e "DROP DATABASE IF EXISTS efektiv_refschema; CREATE DATABASE efektiv_refschema CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
DB_DATABASE=efektiv_refschema /opt/homebrew/opt/php@8.2/bin/php artisan migrate:fresh --force

# 2. Extraction des deux schémas (structure seule, lecture seule)
sed 's/@@DB@@/efektiv_refschema/g' scripts/schema/extract_schema.sql | mysql -u root -N --batch > /tmp/ref.tsv
sed 's/@@DB@@/e-learning-prod/g' scripts/schema/extract_schema.sql | ssh ubuntu@57.129.1.122 \
  'cd /var/www/api.efektiv-academie.com/e-learning-api && MYSQL_PWD=$(grep "^DB_PASSWORD=" .env | cut -d= -f2-) mysql -u root -N --batch' > /tmp/prod.tsv

# 3. Comparaison objet par objet
python3 scripts/schema/diff_schema.py /tmp/ref.tsv /tmp/prod.tsv
```

---

## 10. Suites

| Livrable | Statut |
|---|---|
| Rapport (ce document) | PR « docs/EA-137 » |
| Correctif procédure étape 5 + outillage | PR « docs/EA-137 » |
| Migrations de rattrapage idempotentes + gardes manquantes | PR « fix/EA-137 » |
| Décisions produit (§7) | issues #141 à #144 |
| Conversion MyISAM → InnoDB + 64 FK | issue #136 (prérequis D2/D3/D4) |
| Re-vérifier les chiffres des docs 07 et 11 (établis sur `elearning`) | à planifier — §0.1 |
