# MIGRATIONS_NEEDED.md

> This file lists database and cache operations that need to be run by Gabin
> after Claude's changes. Per CLAUDE.md §3.1, Claude never runs migrations
> or fixture reloads autonomously.

---

## ✅ 2026-08-02 — Trois colonnes `article` — **APPLIQUÉE en local**

> Exception ponctuelle à la règle CLAUDE.md §3.1, comme le 2026-07-28 : Gabin a
> explicitement demandé l'exécution pendant la session, la page article étant
> inaccessible sans elle. Sauvegarde préalable dans
> `var/backup/tcaindustries-avant-metatitle.sql` (hors git). La règle reste en
> vigueur pour les sessions suivantes.

**Fait le 2026-08-02**, base locale `tca-industries-dev` :
- `--dry-run` d'abord, 5 requêtes annoncées, puis `Version20260802094500` appliquée, 3 requêtes
- `cache:clear`
- `app:article:sync --slug=bobinage-moteur-electrique-fonctionnement-etapes-prix --force`, 1 mise à jour

Vérifié après coup : `/blog/bobinage-moteur-electrique-fonctionnement-etapes-prix/` rend en 200 avec un seul `<h1>`, le `<title>` issu de `meta_title` (58 car.), la meta description issue de `meta_description` (156 car., non tronquée), 2 tableaux, un seul JSON-LD `FAQPage` à 4 questions, et l'encart de relecture complet (10 points techniques, 5 questions atelier). `/blog/` liste 0 article et le sitemap ne contient plus aucune URL d'article.

### À rejouer sur staging et prod

```bash
php bin/console doctrine:migrations:migrate --dry-run --no-interaction   # répétition
php bin/console doctrine:migrations:migrate --no-interaction             # Version20260802094500
php bin/console cache:clear
```

Tant que la migration n'est pas passée sur un environnement, **`/blog/` et `/blog/{slug}/` y renvoient une erreur SQL**, l'entité `Article` sélectionnant trois colonnes absentes. Le reste du site n'est pas touché.

La migration a été **écrite à la main**, `make:migration` n'a pas été utilisé, ce qui évite au passage le diff fantôme des deux `ADD CONSTRAINT` décrit plus bas.

### Ce que la migration crée

| Colonne | Type | Rôle |
|---|---|---|
| `meta_title` | `VARCHAR(70) NULL` | Balise `<title>` propre à l'article, distincte du H1, plafonnée à 60 caractères |
| `meta_description` | `VARCHAR(255) NULL` | Meta description rédigée, remplace la troncature de l'extrait à 160 caractères |
| `review_notes` | `LONGTEXT NULL` | Encart de relecture TCA, affiché au-dessus de l'article hors production uniquement |

`Version20260802094500` est gardée par `hasTable()` / `hasColumn()`, donc rejouable depuis n'importe quel état, comme les deux précédentes.

### Ce qui change dans le code autour

- `templates/blog/article.html.twig` : le `<title>` et la meta description viennent des nouveaux champs. Repli sur le H1 suivi de la marque, et sur l'extrait **raccourci sur un mot entier** au lieu d'être coupé à 160 caractères en plein milieu.
- 🆕 `templates/blog/_review_panel.html.twig` : encart de relecture, rendu seulement si `app.environment != 'prod'` et si `reviewNotes` est rempli. CSS inline volontaire, ce bloc n'existe pas en prod.
- `BlogController::show()` : en production le comportement est inchangé, un article non publié reste en 404. **Hors production**, le brouillon est servi sur son URL directe pour que TCA puisse le relire. La liste `/blog/`, le sitemap et le bloc articles liés restent filtrés sur `isPublished` dans tous les environnements.
- `ArticleType` et `templates/admin/article/_form.html.twig` : trois nouveaux champs au back-office, avec compteurs de longueur.
- `AdminArticleController::snapshot()` : les trois champs entrent dans le dirty gate (CLAUDE.md §3.2).

### Publication remise à zéro

Les 6 articles qui étaient `is_published = 1` sont repassés à `0`, en base et dans `ArticleFixtures`. **Les 22 articles sont donc hors ligne**, le sitemap ne contient plus aucune URL `/blog/`. C'était la demande, aucun article ne doit être accessible publiquement avant relecture TCA.

Slugs concernés, si une reprise est nécessaire : `branchement-moteur-triphase-380v-couplage-etoile-triangle`, `bobinage-moteur-electrique-fonctionnement-etapes-prix`, `reparation-moteur-electrique-220v-diagnostic-solutions`, `reparation-pompe-piscine-diagnostic-remise-en-etat`, `reparation-pompe-relevage-causes-panne-intervention`, `reparation-pompe-immergee-reparer-ou-remplacer`.

---

## 🔴 2026-07-29 — Reprise de la migration + FAQ des 6 articles publiés

Diagnostic rejoué sur un **dump réel de la production** importé dans un conteneur
`mariadb:11.4` (`compose.yml`). Ce qui suit est mesuré, pas supposé, et corrige
deux points de la section du 2026-07-28 ci-dessous.

### Ce que le dump de prod montre

| Constat | Réalité mesurée |
|---|---|
| Moteur des tables | **InnoDB sur les 18 tables** — pas MyISAM |
| `FK_D082D6EF94C34ABE` / `FK_8AE0BA6612469DE2` | présentes et réelles |
| `article.faq` | **existe déjà** (`longtext CHECK json_valid`) |
| `Version20260728142850` | **absente** de `doctrine_migration_versions` |
| `Version20260611212240` | enregistrée avec `executed_at = NULL` |

Deux conséquences :

1. **La prod est à moitié migrée.** Le DDL provoquant un commit implicite,
   l'`ALTER TABLE article ADD faq` a été validé avant que la migration n'échoue
   sur la clé étrangère. Le code a été reculé d'un pull, pas la base. Rejouer la
   migration telle quelle échouerait donc sur `Duplicate column name 'faq'`,
   avant même d'atteindre la clé étrangère.
2. **`Version20260611212240` n'a jamais été exécutée** : `executed_at NULL` est
   la signature d'un `migrations:version --add`. La table `testimonial` a été
   créée autrement (vraisemblablement `schema:update --force`), puis la
   migration tamponnée. C'est pour ça que ses `ADD CONSTRAINT` en doublon
   n'avaient encore jamais explosé.

Sur MariaDB 11.4 l'erreur remontée est `1005 / errno 121`, pas `1826` — c'est la
même cause (nom de contrainte déjà pris), seul le code diffère de MySQL 8.

### Ce qui a été corrigé dans le code

- `Version20260728142850` **et** `Version20260611212240` sont désormais
  **rejouables** : l'ajout de colonne et le `CREATE TABLE testimonial` sont
  gardés par `hasTable()` / `hasColumn()`. Sur une base déjà à moitié migrée
  elles n'émettent aucun SQL et se contentent d'enregistrer la version ; sur une
  base vierge elles créent tout normalement.

  → **Plus besoin de `doctrine:migrations:version --add` à la main.** L'arbre de
  décision de la section du 2026-07-28 (« Reprise sur staging ») est caduc :
  `doctrine:migrations:migrate` converge maintenant depuis n'importe quel état.
- 🆕 **`app:article:backfill-faq`** — remplit la FAQ des 6 articles publiés qui
  étaient partis en ligne avec le placeholder « ⚠️ Section FAQ à compléter »
  visible par les visiteurs, et sans aucun JSON-LD `FAQPage`.

⚠️ **`app:article:extract-faq` ne traite pas ces 6 articles** : il ne sait lire
qu'un accordéon `.tca-faq__item`, or leur corps ne contient que le placeholder.
L'étape « extract-faq » de la section du 2026-07-28 est donc inopérante pour eux,
c'est `backfill-faq` qui prend le relais. Les deux commandes sont complémentaires
et sans recouvrement.

Le contenu des 29 paires est repris **verbatim de `docs/faq.md`** (recoupement
automatique effectué : alignement strict avec `ArticleFixtures` sur les 6 slugs).

### Ordre à respecter — staging puis prod

Identique sur les deux environnements. Sauvegarder la base avant.

```bash
# 1. état des lieux — à lire avant d'agir
php bin/console doctrine:migrations:status

# 2. schéma
php bin/console doctrine:migrations:migrate --dry-run --no-interaction   # rehearsal
php bin/console doctrine:migrations:migrate --no-interaction

# 3. contenu — dans cet ordre, les deux commandes sont à blanc par défaut
php bin/console app:article:extract-faq             # articles à FAQ HTML (brouillons)
php bin/console app:article:extract-faq --force
php bin/console app:article:backfill-faq            # les 6 articles publiés
php bin/console app:article:backfill-faq --force

# 4.
php bin/console cache:clear
```

Sur une base déjà à moitié migrée, l'étape 2 affiche
`was executed but did not result in any SQL statements` — c'est le comportement
attendu de la garde, pas une anomalie.

### Vérifications faites en local sur le dump de prod

- Chaîne complète rejouée **sur base vierge** : 3 migrations, 20 requêtes, schéma
  final **identique colonne par colonne** à celui de la prod (220 colonnes),
  2 clés étrangères aux bonnes règles, `schema:update --dump-sql` → in sync.
- **Scénario staging simulé** (`testimonial` et `article.faq` présentes, versions
  2 et 3 absentes de `doctrine_migration_versions`) : `migrate` passe en
  **0 requête SQL**, enregistre les deux versions, schéma inchangé. C'est
  exactement l'état qui faisait échouer la reprise.
- `backfill-faq` : 6 articles remplis, placeholder retiré, **idempotent** au
  second passage.
- Les 6 pages articles rendent en 200 avec 1 `<h1>`, l'accordéon complet et
  **exactement un** JSON-LD `FAQPage` au bon nombre de questions.
- Smoke test : 38 routes publiques, 30 en 200 (les 8 autres sont les routes
  authentifiées en 302 et l'inscription désactivée en 404).
- Le diff fantôme a disparu : sur InnoDB, `schema:update --dump-sql` ne repropose
  plus les deux `ADD CONSTRAINT`. Passer le dev sur le conteneur supprime la
  cause racine décrite en « Empêcher la récidive » ci-dessous.

### Deux points à traiter côté contenu (non corrigés — décision Gabin)

1. **L'article « Branchement moteur triphasé 380V » a perdu son paragraphe
   d'introduction en base.** Il démarre au milieu du sujet, sur « Avant tout
   raccordement, la plaque signalétique… ». Le mot-clé principal n'apparaît plus
   dans les 100 premiers mots (CLAUDE.md §6.5). Perte probable lors d'une des
   deux éditions BO du 2026-07-28. Le texte d'origine est intact dans
   `ArticleFixtures` (batch1) — à recoller via le back-office.
2. **`ArticleFixtures` n'est plus la source de vérité des articles publiés.**
   Leurs corps ont divergé en BO, souvent en mieux : `bobinage` a gagné une
   section « Classes d'isolation : B, F, H », `pompe-piscine` une section
   « pompe à chaleur piscine », et 3 images ont été changées. **Ne pas relancer
   `doctrine:fixtures:load`** : au-delà de la purge, ça écraserait ce travail
   éditorial. C'est aussi pourquoi `backfill-faq` ne touche que la FAQ.

---

## 🔴 2026-07-28 — Clés étrangères dupliquées : `dmm` cassé sur staging

### Le problème

Les deux mêmes instructions étaient présentes dans **trois** migrations :

```sql
ALTER TABLE contact_reply ADD CONSTRAINT FK_D082D6EF94C34ABE ...
ALTER TABLE cookie        ADD CONSTRAINT FK_8AE0BA6612469DE2 ...
```

| Migration | Rôle réel | FK |
|---|---|---|
| `Version20260605175213` | création de toutes les tables | légitimes |
| `Version20260611212240` | table `testimonial` | **doublon retiré** |
| `Version20260728142850` | `article.faq` | **doublon retiré** |

**Pourquoi c'est passé inaperçu** : les tables locales sont en **MyISAM**, moteur qui parse et ignore silencieusement les clés étrangères. Elles n'atteignent donc jamais `information_schema`, chaque `make:migration` les repropose, et chaque migration les rejoue dans le vide. Sur staging en InnoDB, la migration initiale les crée réellement, puis la suivante échoue :

```
SQLSTATE[HY000]: General error: 1826 Duplicate foreign key constraint name 'FK_8AE0BA6612469DE2'
```

Reproduit et corrigé en base jetable : migration initiale → passage InnoDB → le rejeu déclenche bien l'erreur 1826 → les migrations 2 et 3 corrigées passent, schéma final conforme (`article.faq` JSON, `testimonial` présente, 2 clés étrangères).

### Reprise sur staging

MySQL ne sait pas annuler du DDL : la migration 2 a probablement créé `testimonial` **avant** d'échouer sur la clé étrangère, sans être enregistrée comme exécutée. À vérifier d'abord :

```sql
SELECT (SELECT COUNT(*) FROM information_schema.TABLES
        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'testimonial')      AS table_testimonial,
       (SELECT COUNT(*) FROM doctrine_migration_versions
        WHERE version LIKE '%20260611212240%')                                AS migration_enregistree;
```

- **`1` et `0`** (cas le plus probable) : la table existe mais la migration n'est pas enregistrée. Il faut la marquer comme faite avant de continuer, sinon `CREATE TABLE testimonial` échouera :
  ```bash
  php bin/console doctrine:migrations:version 'DoctrineMigrations\Version20260611212240' --add --no-interaction
  php bin/console doctrine:migrations:migrate --no-interaction
  ```
- **`0` et `0`** : rien n'a été appliqué, `php bin/console doctrine:migrations:migrate --no-interaction` suffit.
- **`1` et `1`** : la migration 2 est déjà passée, `dmm` suffit également.

Puis, une fois le schéma à jour, la migration de contenu des FAQ d'articles :

```bash
php bin/console app:article:extract-faq            # à blanc
php bin/console app:article:extract-faq --force
php bin/console cache:clear
```

### ⚠️ Empêcher la récidive

Tant que les tables locales sont en MyISAM, **chaque `make:migration` réintroduira ces deux lignes** et recassera staging. Deux options :

1. **Recommandé** — aligner le local sur staging en repassant les tables en InnoDB. Le diff fantôme disparaît définitivement :
   ```sql
   ALTER TABLE contact_message  ENGINE=InnoDB;
   ALTER TABLE contact_reply    ENGINE=InnoDB;
   ALTER TABLE cookie_category  ENGINE=InnoDB;
   ALTER TABLE cookie           ENGINE=InnoDB;
   ```
   (puis les autres tables au fil de l'eau ; penser aussi à `default_storage_engine=InnoDB` dans le `my.ini` du WAMP, sinon toute nouvelle table repartira en MyISAM)
2. **À défaut** — relire systématiquement chaque migration générée et supprimer tout `ADD CONSTRAINT` portant sur `contact_reply` ou `cookie`.

---

## ✅ Session 2026-07-28 — Champ FAQ de l'entité Article (BO, texte simple) — **APPLIQUÉE**

> Exception ponctuelle à la règle CLAUDE.md §3.1 : Gabin a explicitement demandé
> que la migration soit exécutée pendant la session. Sauvegarde préalable dans
> `var/backup/tcaindustries-avant-faq.sql` (hors git). La règle reste en vigueur
> pour les sessions suivantes.

**Fait le 2026-07-28**, base locale `tcaindustries` :
- `Version20260728142850` appliquée → `article.faq JSON DEFAULT NULL`
- `app:article:extract-faq --force` → 22 articles migrés, 101 paires, 0 résidu HTML
- `cache:clear`

Vérifié après coup : 22 articles intacts (6 publiés), les 6 pages articles publiées rendent en 200 avec 1 seul `<h1>`, 1 `<section id="faq">` et exactement 1 JSON-LD `FAQPage` ; accueil, `/blog/`, `/contact/`, `/a-propos/`, `/sitemap.xml` et les pages services en 200.

Rien à lancer de ton côté. Ce qui suit documente ce qui a été fait.

### Détail de ce qui a changé

- 🆕 **Colonne `article.faq`** (`JSON`, nullable) : les paires question/réponse d'un article, en **texte simple**. Rendues en accordéon `.tca-faq` (mêmes classes que les pages services) et en JSON-LD `FAQPage`.
- 🆕 **`App\Service\FaqExtractor`** : lit l'accordéon `.tca-faq__item` d'un HTML et supprime le `<section id="faq">`. `SchemaService::faqFromContentHtml()` délègue désormais à ce service (aucun changement de comportement).
- 🆕 **`ArticleFaqItemType`** + `CollectionType` répétable dans `ArticleType` (ajout / suppression / réordonnancement, JS `public/js/admin-article-faq.js`).
- 🆕 **Twig `strip_faq_section`** : si `article.faq` est rempli, l'ancien `<section id="faq">` resté dans le HTML est retiré au rendu → jamais deux accordéons ni deux `FAQPage`.
- 🆕 **Commande `app:article:extract-faq`** : migre les articles déjà en base (FAQ HTML → colonne `faq`). **Dry run par défaut**, écrit seulement avec `--force`.
- 🔁 **`ArticleFixtures`** : la FAQ reste écrite en HTML dans le fichier (source de vérité) mais est extraite au chargement → le contenu stocké est nettoyé et la FAQ arrive structurée.
- 🔁 **Dirty gate** : `AdminArticleController::snapshot()` inclut `faq` ; `Article::getFaq()` réordonne les clés de chaque paire (MySQL réordonne les clés des objets JSON stockés, ce qui faisait échouer la comparaison stricte) ; `admin-article-edit.js` compare la collection entière (ajout / suppression / ordre) et écoute en délégation.

⚠️ **Ne pas lancer `doctrine:fixtures:load`** (purge la base). Les fixtures produisent le même résultat que la commande, elles ne servent qu'à une reconstruction complète.

### Redéploiement sur un autre environnement (préprod / prod)

L'ordre à respecter, la colonne étant sélectionnée par Doctrine dès le déploiement du code :

```powershell
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:article:extract-faq            # à blanc
php bin/console app:article:extract-faq --force    # si le tableau est conforme
php bin/console cache:clear
```

### Deux dérives préexistantes constatées (non traitées)

1. `DATABASE_URL` déclare `serverVersion=mariadb-11.4.0` alors que le serveur est **MySQL 8.4.7**. Sans effet ici, mais Doctrine génère du SQL pour la mauvaise plateforme.
2. Toutes les tables sont en **MyISAM** (défaut du WAMP), qui ignore silencieusement les clés étrangères. D'où les deux `ADD CONSTRAINT` sur `contact_reply` et `cookie` que chaque diff repropose et que chaque migration réapplique dans le vide, depuis `Version20260605175213`.
   > **Correction du 2026-07-29** — vrai du seul poste de dev. Le dump de production montre **18 tables InnoDB sur 18** et les deux clés étrangères bien réelles. C'est cette croyance qui a fait passer les doublons pour des no-ops inoffensifs. Voir la section du 2026-07-29 en tête de fichier.

---

## ⭐ Session 2026-06-12 — Entité Testimonial (BO avis clients)

### What changed

- 🆕 **New Entity `App\Entity\Testimonial`** (table `testimonial`) : id, uuid, authorName (100), authorRole (100, nullable), quote (TEXT), position, isActive, createdAt, updatedAt
- 🆕 **`TestimonialRepository`** avec `findAllOrdered()` et `findActive()`
- 🆕 **`TestimonialType` form** (authorName, authorRole, quote)
- 🆕 **`AdminTestimonialController`** sous `/security/dashboard/avis` (architecture Bart : liste drag & drop, toggle AJAX, dirty-gate, exports CSV/SQL). Défensif contre table manquante.
- 🆕 **Twig extension `testimonials()`** dans `src/Twig/TestimonialsExtension.php` → fallback `[]` si table absente
- 🆕 **`TestimonialFixtures`** : 5 brouillons **masqués** (isActive=false) — remplacer le texte par les vrais avis Google validés par TCA via le BO, puis activer le toggle
- 🆕 **Admin templates** dans `templates/admin/testimonial/` + 3 JS (`admin-testimonial-{list,edit,new}.js`)
- 🆕 **Lien sidebar** "Avis clients"
- 🔁 **Homepage** : la section avis consomme `testimonials()` — tant qu'aucun avis n'est actif, fallback sur les placeholders actuels (aucun changement visuel avant activation)

### Actions Gabin must run

```powershell
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console doctrine:fixtures:load --no-interaction   # ⚠️ purge la base — voir discussion du 2026-06-12 sur ce que ça écrase
php bin/console cache:clear
```

### Workflow avis (quand TCA valide les 5 avis Google)

1. BO → Avis clients → Modifier chaque brouillon : coller le texte intégral + le vrai prénom/nom de l'auteur.
2. Activer le toggle de chaque avis complété.
3. La homepage bascule automatiquement des placeholders vers les vrais avis.

---

## ⭐ Rebuild session — Entité Marque (BO marques partenaires)

### What changed

- 🆕 **New Entity `App\Entity\Marque`** (table `marque`) : id, uuid, name, category, logoPath, websiteUrl, position, isActive, createdAt, updatedAt
- 🆕 **`MarqueRepository`** avec `findAllOrdered()` et `findActive()`
- 🆕 **`MarqueType` form** avec upload logo (PNG/SVG/JPG/WebP, 2 Mo max)
- 🆕 **`AdminMarqueController`** sous `/security/dashboard/marques` (ROLE_INTERN read, ROLE_ADMIN write). Défensif contre table manquante (try/catch + UI d'alerte).
- 🆕 **Twig extension `marques()`** dans `src/Twig/MarquesExtension.php` → fallback `[]` si table absente
- 🆕 **`MarqueFixtures`** : 16 marques TCA pré-remplies (WEG, SERMES, TSURUMI, EBARA, PEDROLLO, DAB, FAG, NTN, NSK, KOYO, OPTIBELT, GATES, IRIS, RENOLD, BLICKLE, WEICON), placeholder logo TCA × 16
- 🆕 **Admin templates** dans `templates/admin/marque/` (index, new, edit, show, _form)
- 🆕 **Lien sidebar** "Marques partenaires" ajouté
- 🔁 **Composant `_components/marques-distribuees.html.twig`** consomme `marques()`, fallback : logo TCA × 12 si table absente / vide

### Actions Gabin must run

```powershell
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console doctrine:fixtures:load --no-interaction
php bin/console cache:clear
```

Les uploads logos vont dans `public/uploads/marques/` (créé auto à l'upload).

---

## After session of 2026-05-19 — Phase 1 / 2 foundation work

### What changed in this session

- 🔁 **Routes migrated to trailing slash**: `/contact`, `/cgu`, `/mentions-legales`, `/confidentialite`, `/politique-cookies` → `/contact/`, `/cgu/`, `/mentions-legales/`, `/politique-confidentialite/`, `/politique-cookies/`.
- 🔁 **Privacy route renamed**: `/confidentialite` → `/politique-confidentialite/` (route name unchanged: `app_legal_privacy`).
- 🆕 **10 new service routes** created (`app_service_*`), all with trailing slashes.
- 🆕 **New routes**: `/a-propos/` (app_about), `/blog/` (app_blog_index), `/robots.txt` (app_robots).
- 🔁 **`SiteParameterFixtures` rewritten** with TCA branding (name, address, phone, logo, favicon, tagline, etc.).
- 🔁 **`SeoFixtures` rewritten** with TCA SEO metadata: 12 piliers + Contact + About + Blog + 4 legal + 6 auth + 3 error pages.

### Actions Gabin must run, in order

```powershell
# 1. Clear the cache — route definitions changed, the URL generator and router cache must be rebuilt
php bin/console cache:clear

# 2. Reload all fixtures — this purges and reseeds:
#    - SiteParameter (TCA name, phone, address, logo, etc.)
#    - PageSeo (24 routes with TCA titles, meta descriptions, OG/Twitter tags)
#    - LegalPage (the existing HTML bodies are still Skeleton-branded; see TODO below)
#    - ErrorPage (TCA-tone 404/500/403 messages)
#    - Cookies, integrations, social networks, users (unchanged — still mould defaults)
php bin/console doctrine:fixtures:load --no-interaction

# 3. (Optional sanity check) Verify the new routes
php bin/console debug:router | grep "app_service_\|app_about\|app_blog_\|app_legal_\|app_robots\|app_contact\|app_home"
```

### No schema migration required this session

No new Doctrine entity was added in this batch. The blog will introduce
`App\Entity\Article` in a later session; that one **will** need a migration,
and this file will be updated then.

---

## After session of 2026-05-19 — Blog system

### What changed

- 🆕 **New Doctrine entity** `App\Entity\Article` for blog articles. Table `article` with unique index on `slug`, composite index on `(is_published, published_at)`.
- 🆕 **Repository** `App\Repository\ArticleRepository`.
- 🆕 **BlogController** routes: `/blog/` (listing with optional `?categorie=` filter) and `/blog/{slug}/` (single article).
- 🆕 **Templates** `blog/index.html.twig` (T5) and `blog/article.html.twig` (T6).
- 🆕 **Fixture** `App\DataFixtures\ArticleFixtures` with 1 placeholder article (the real 18 articles of the calendar will follow per `seo-strategy.md §4`).

### Actions Gabin must run, in order

```powershell
# 1. Generate the migration file from the new Article entity
php bin/console make:migration

# 2. Inspect the generated migration in migrations/Version*.php
#    (sanity-check that only the `article` table is created)

# 3. Apply it to the database
php bin/console doctrine:migrations:migrate --no-interaction

# 4. Reload fixtures (now includes ArticleFixtures with the placeholder)
php bin/console doctrine:fixtures:load --no-interaction

# 5. Verify the blog routes
php bin/console debug:router | grep "app_blog_"
```

### Verifying the blog

After the migration + fixture reload, navigate to:
- `/blog/` — should list the 1 placeholder article in card grid
- `/blog/branchement-moteur-triphase-380v-couplage-etoile-triangle/` — full article view with author block + CTA toward `/reparation-moteur-electrique/`

### Known follow-ups (not blockers — for next sessions)

1. **Legal page bodies (`mentions.html`, `privacy.html`, `cgu.html`, `cookies.html` in `src/DataFixtures/data/`)** — June 2026: rewritten with TCA content including SIRET 84774777100024, contact email, gérant Vincent Marques. Two placeholders remain in `mentions.html`: (a) capital social / RCS / TVA intracommunautaire (TCA to provide), (b) hébergeur name + address + phone (Vultek to provide). After resolving those, run `doctrine:fixtures:load` to push new content to the `legal_page` table.

2. **Article author** — June 2026: `ArticleFixtures.php` no longer sets `authorName`/`authorRole` (TCA preference: anonymous technical articles). Reload fixtures to remove the existing "Équipe technique TCA" byline from the placeholder article.
3. **`SiteParameter.contactEmail`** — TCA confirmed `contact@tca-industries.fr` (2026 batch). Already set in `SiteParameterFixtures.php`. Run `doctrine:fixtures:load` again to push the new value to the DB (current row still has `NULL`); or update directly in the back-office.
4. **`SiteParameter.siret`** — TCA confirmed `84774777100024` (2026 batch). Already set in `SiteParameterFixtures.php`. Run `doctrine:fixtures:load` again to push the new value to the DB (current row still has `NULL`); or update directly in the back-office.
5. **SEO content updates (TCA batch June 2026)** — `SeoFixtures.php` was updated for: "30 ans" → "1983 / 40 ans" on home + à-propos, and "Intervention dans la journée" → "Délai adapté à l'urgence" on home + 4 service routes. Run `doctrine:fixtures:load` to refresh `page_seo` rows. The site templates already show the new wording, but DB-driven meta tags (title/description served via `SeoExtension`) still serve old values until reload.
6. **Favicon** is the SVG symbol logo. Generating proper `.ico` + PNG sizes (16, 32, 192, 512) is a Phase 5 task.
7. **Photo conversion** — 119 JPGs in `public/images/` are being converted to WebP and triaged into subfolders by `scripts/process_images.py` (running). The `IMAGES_REPORT.md` at project root has the full mapping and per-page assignments.
