# État des lieux — Rationalisation des imports (Réservoir / Recherche / Planning)

- **Date** : 2026-07-21
- **Périmètre** : tous les chemins d'import de données (SERP, CSV métriques, GSC, briefs YTG, SE-Ranking, backlinks) vus depuis les surfaces Réservoir, Recherche (`/keywords/recherche`), Planning (`/keywords/planning`), avec l'infra transverse (`crates/import-core`, `services/analytics`, `crates/gsc-worker`, `scripts/`).
- **Méthode** : 4 inventaires code + 1 synthèse documentaire (sous-agents), vérification croisée des claims critiques (grep) avant assemblage.
- **Bug déclencheur** : `docs/bugs/2026-07-21-planning-serp-import.md` — symptôme du problème structurel décrit ici.

---

## 1. Résumé exécutif

Le problème n'est pas un manque d'infrastructure — les upserts partagés (`upsertCanonical`, `upsertLinkSources`), la route SERP unique (`/keywords/serp/import`) et la détection unifiée (`detectImportFile`) existent déjà. Le problème est que **les points d'entrée UI sont éclatés par surface et non par capacité**, et que **le parsing keywords existe en 4 exemplaires dont 1 mort**. Concrètement :

1. **Le SERP n'est importable que depuis Planning/Content/Dispatch** — jamais depuis Réservoir ni Recherche — et l'uploader est dupliqué 3 fois (dont 1 Server Action orpheline).
2. **La `SourcesModal` (dropzone auto-détectante, la bonne UX) n'existe qu'en Planning** ; Réservoir et Recherche passent par un menu 3 items sans détection.
3. **La détection de source CSV keywords existe en 4 implémentations** : Rust WASM (code mort), DuckDB/TS (la prod), et 2 copies client « miroir 1:1 » à synchroniser à la main.
4. **Le crawl on-page concurrent est visible dans la complétude Planning mais non actionnable depuis Planning** — il faut aller dans Content/Dispatch.
5. **Les écritures DB sont saines** (merges jsonb non destructifs, union sources/stages) mais les garde-fous sont incohérents : caps 1/25/50 Mo sans logique, quota billing sur backlinks mais pas keywords, revalidation partielle.

---

## 2. Matrice capacité × surface (l'éclatement en un tableau)

| Capacité d'import | Réservoir | Recherche | Planning | Content/Dispatch | Backlink Profile |
|---|---|---|---|---|---|
| CSV métriques (Mangools/Semrush/Ahrefs/SE-R) | ✅ `ImportKeywordsDialog` | ✅ idem | ✅ via `SourcesModal` | ❌ | ❌ |
| GSC preview+commit / sync | ✅ | ✅ | sync seul | ❌ | ❌ |
| Brief YTG par mot-clé | ✅ `KeywordSheet` | ✅ idem | ✅ Overview | ❌ | ❌ |
| Brief YTG global (match par terme) | ❌ | ❌ | ✅ **seul** | ❌ | ❌ |
| **SERP CSV** | ❌ | ❌ | ✅ Overview (fix 21/07, dupliqué) | ✅ `SerpPanel` complet | ❌ |
| Crawl on-page concurrents | ❌ | ❌ | ❌ (visible, non actionnable) | ✅ **seul** | ❌ |
| Enrich SE-Ranking (API) | ✅ | ✅ | ✅ (hook partagé — bon exemple) | ❌ | ❌ |
| Ajout manuel mot-clé | ✅ | ✅ | ❌ | ❌ | ❌ |
| Catalogue backlinks (`link_sources`) | Backlink Reservoir **seul** | — | — | — | ❌ |
| Backlinks domaine Semrush | ❌ | — | — | — | ✅ **seul** |

Constats : Réservoir et Recherche sont **strictement identiques** côté import (composants physiquement partagés — aucune feature exclusive de part et d'autre). Tout l'éclatement est entre le duo Réservoir/Recherche, Planning, et Content/Dispatch.

---

## 3. Duplications confirmées (vérifiées)

### 3.1 Parsing/détection CSV keywords — ×4, dont 1 mort
| # | Implémentation | Statut |
|---|---|---|
| 1 | Rust `crates/import-core/src/keyword.rs` (`detect_source`, `parse_keywords`) exposé WASM (`lib.rs:33,43`) | **CODE MORT** — zéro appelant TS (vérifié par grep). Ne gère pas Ahrefs. Seul à calculer le trend Semrush — fonctionnalité que personne n'exécute. |
| 2 | `services/analytics/src/processor.ts:153-311` (DuckDB) | **LA PROD** — gère les 4 sources, mais `growthPct: null` en dur (pas de trend). |
| 3 | `src/lib/keywords/import/detect-file.ts:66-85` (`detectKeywordSource`) | Copie « 1:1 » assumée en commentaire. |
| 4 | `src/components/keywords/import-keywords-dialog.tsx:40-52` (`detectSource`) | Copie client pour préremplir le sélecteur. |

### 3.2 Uploader SERP — ×3, dont 1 orphelin
| # | Implémentation | Statut |
|---|---|---|
| 1 | `src/components/content/serp-panel.tsx:32-58` (`importFile`) | Original — feedback inline, drag&drop, + crawl on-page. |
| 2 | `src/components/keywords/planning/keyword-overview-modal.tsx:107-136` (`importSerpFile`) | Copie du fix 21/07 (« Mirrors SerpPanel » en commentaire) — feedback toast sonner. Même garde 25 Mo, même fetch, deux UX. |
| 3 | `src/lib/keywords/serp-actions.ts` (`importSerpForKeywordAction`) | Server Action appelée **uniquement par ses tests** (vérifié) — aucune UI. |

### 3.3 Slugify — ×3
`wasm.slugify` (canon, utilisé par actions/GSC/migrations), `slugifyTerm` réimplémenté dans `services/analytics/src/processor.ts`, et une 3ᵉ copie dans `scripts/seed-backlinks.ts:52-59`. **Risque réel** : l'identité `keywords.id = ${siteId}-${slug}` est la clé de merge de TOUS les imports — toute divergence de slug crée des doublons au lieu de fusionner (ETL vs brief-par-terme vs GSC vs ajout manuel).

### 3.4 GSC — deux clients, deux mondes
- TS (`src/lib/gsc/client.ts` + discovery/sync) → `keywords.gsc*` (jsonb), déclenché utilisateur.
- Rust (`crates/gsc-worker`, `reqwest` + `sqlx` SQL brut **hors Drizzle**) → `gsc_query_snapshots`/`gsc_page_snapshots`, planifié.
Aucun code partagé ; un changement d'API GSC (pagination, scopes) se répercute à deux endroits, et une migration Drizzle ne protège pas le Rust (noms de colonnes en dur).

### 3.5 Scripts hors canal
`scripts/repop-eleva-seo.ts`, `seed-keywords-bench.ts`, `seed-backlinks.ts` font des inserts bruts en contournant `upsertCanonical`/`upsertLinkSources` (quota, namespacing org, unions sources/stages réimplémentés ad hoc ou absents). Acceptable en dev, dangereux si copié-collé vers la prod.

---

## 4. Incohérences transverses

| Sujet | État actuel | Problème |
|---|---|---|
| Caps upload | 1 Mo (briefs YTG, catalogue backlinks) / 25 Mo (SERP, backlinks domaine) / 50 Mo (CSV keywords) | 3 seuils sans logique fonctionnelle ; le 1 Mo vient du plafond Server Actions, contourné ailleurs par des route handlers. |
| Brief YTG global (`SourcesModal`) | `file.text()` sans cap client | Le cap 1 Mo n'existe que dans `ImportBriefDialog` — la dropzone Planning peut envoyer un payload > 1 Mo vers une Server Action qui le refusera opaquement. |
| Quota billing | Contrôlé sur les 2 imports backlinks (`lockOrgForQuota`) | **Aucun quota** sur CSV keywords lourd ni GSC. |
| Revalidation SERP | `serp-store.ts:60` revalide `/content/planning` uniquement | `/keywords/planning` importe désormais du SERP (fix 21/07) mais n'est pas revalidé — le client compense par `retry()`, le cache RSC non. |
| Complétude prompt generator | Dérivée à la volée (`compute-readiness.ts`) — aucun champ stocké | Sain en soi, mais l'item « on-page » est affiché non actionnable depuis Planning. |
| Cul-de-sac SERP global | `SourcesModal` détecte `serp-csv` mais affiche un message sans action (`sources-modal.tsx:312-316`) | Limitation connue documentée dans le bug du 21/07 — pas de picker de mot-clé. |
| Feedback import | Toast sonner (Planning) vs texte inline (SerpPanel) vs poll 2 s (CSV lourd) | Trois langages UX pour « ton import est passé/échoué ». |

## 5. Écarts doc ↔ code (issus de la synthèse documentaire)

- La doctrine **« Rust pur → WASM → Next persiste »** (spec 2026-06-04) et la doctrine **« gros volumes → DuckDB/Hono async »** (ADR 2026-06-09) coexistent sans règle de routage écrite ; résultat : le module keywords du crate Rust est mort, l'ETL DuckDB est la prod de fait.
- Le worker Rust à file d'attente promis pour les **backlinks** n'a jamais existé ; c'est le service analytics qui joue ce rôle… pour les **keywords**.
- L'id composite multi-org de `link_sources` (« à reprendre ») a été contourné par le namespacing `${orgId}__` — jamais réconcilié.
- La décision « SERP scopé keyword » (spec sources-modal 2026-06-18) était la bonne, mais le code a régressé (bug 21/07) faute d'un chemin d'import unique factorisé.

---

## 6. Cible de rationalisation

**Principe : une capacité d'import = un module unique (détection + upload + écriture + feedback), monté sur toutes les surfaces pertinentes. La surface ne décide plus de ce qu'on peut importer.**

1. **Point d'entrée unique** : généraliser `SourcesModal` (détection `detectImportFile`) comme bouton « Importer » de Réservoir, Recherche ET Planning. Un export SERP déposé globalement ouvre un **picker de mot-clé** (lève le cul-de-sac documenté) ; un CSV métriques route vers le flux lourd ; un brief YTG matche par terme avec fallback picker si aucun match.
2. **Un seul uploader SERP** : hook partagé `useSerpImport(keywordId, { onImported })` consommé par `SerpPanel` et `KeywordOverviewModal` (et demain par la SourcesModal généralisée). Supprimer `importSerpForKeywordAction` (ou en faire le canal unique — mais la route handler 25 Mo est le bon transport, la Server Action est plafonnée à 1 Mo : **supprimer**).
3. **Une seule détection CSV** : `detect-file.ts` devient l'unique implémentation client (supprimer la copie de `import-keywords-dialog.tsx`) ; documenter que `processor.ts` (serveur) est l'autorité de parsing.
4. **Trancher Rust vs DuckDB par ADR** : supprimer `parse_keywords_csv`/`detect_keyword_source` du crate (code mort) ; le crate reste l'autorité pour SERP, on-page, backlinks (où il est unique et sain). Si le trend Semrush du Rust est une feature voulue, la porter dans `processor.ts` avant suppression.
5. **Slug unique** : faire consommer `wasm.slugify` par le service analytics (il consomme déjà `parse_page_meta` du même crate — le précédent existe) et par les scripts de seed. Test de non-régression : mêmes entrées → mêmes ids sur les 3 chemins.
6. **Écritures et fraîcheur** : tout chemin d'import finit par `upsertCanonical`/`upsertLinkSources` (y compris scripts, quitte à leur ajouter un flag « seed ») ; revalidation systématique des DEUX plannings + recherche/réservoir après import ; harmoniser les caps (25 Mo partout où une route handler existe, et migrer le brief YTG vers une route handler pour tuer le dernier plafond 1 Mo).
7. **On-page actionnable partout** : exposer `crawlSerpForKeywordAction` depuis l'Overview Planning (l'item de complétude devient un bouton, comme les autres sources).
8. **Quota cohérent** : décider si les imports keywords sont quota-gated comme les backlinks (aujourd'hui asymétrique).

## 7. Plan d'action proposé (séquencé, chaque phase livrable seule)

- **Phase 1 — Dette sans risque** (petites PRs indépendantes) :
  a) supprimer bindings WASM morts + `importSerpForKeywordAction` + copie `detectSource` client ;
  b) extraire `useSerpImport` et l'utiliser dans les 2 UI existantes ;
  c) ajouter `revalidatePath("/keywords/planning")` dans `serp-store.ts` ;
  d) cap 1 Mo côté client sur la branche ytg-brief de `SourcesModal`.
- **Phase 2 — SourcesModal partout** : monter la modale unifiée sur Réservoir + Recherche ; ajouter le picker de mot-clé pour SERP (et brief sans match) — c'est la phase qui réalise « une seule source d'importation ».
- **Phase 3 — Complétude actionnable** : crawl on-page depuis l'Overview Planning ; feedback unifié (toasts partout).
- **Phase 4 — Fondations** : slug unique (analytics + seeds), ADR Rust/DuckDB, harmonisation caps/quotas, scripts via upserts partagés.

Chaque phase respecte le flow git : `feature/<nom>` depuis `dev`, merge ff-only, suppression de branche.
