# Connecteur Mangools BYOK — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Connecteur Mangools par organisation (token BYOK chiffré) : enrichissement auto volume/KD/CPC, SERP à la demande pour l'analyse sémantique, dialog « mots-clés associés », suivi de sync par mot-clé générique (SE-Ranking inclus).

**Architecture:** Rail générique `org_connectors` (token AES-256-GCM) + client Mangools v3 throttlé (2 req/s, séquentiel) + normalisation pure vers les formes internes existantes (`metrics jsonb`, `SerpSnapshot`) → en aval rien ne change. Statut d'enrichissement par mot-clé dans `keywords.enrichment` (jsonb par provider), rendu par un spinner par ligne + polling léger ; job `keyword-enrich` dans le service analytics existant.

**Tech Stack:** Next 16 / Bun / Drizzle-Postgres / bun:test (DI par `deps`, jamais `mock.module` pour le code maison) / service analytics Hono existant.

**Spec:** `docs/superpowers/specs/2026-07-25-mangools-connector-design.md` (+ fixtures réelles `src/lib/mangools/fixtures/`).

## Global Constraints

- **Throttle incontournable** : ~2 req/s max par org, appels **séquentiels** dans un même job, backoff sur 429 (`Retry-After` si présent, **une seule** reprise). Jamais de fan-out parallèle vers api.mangools.com.
- **Aucun retry automatique sur `quota_exceeded`** (402) — c'est le quota du client.
- **Rail de suivi provider-agnostique** : statut par kw = `Partial<Record<KeywordSource, EnrichState>>` ; l'UI ne connaît pas les providers. SE-Ranking écrit le même rail (Task 10).
- **BYOK org** : token par organisation, écrit par owner/admin uniquement, **chiffré au repos**, jamais renvoyé en clair au client.
- **Feature gratuite** : aucun gating billing seo-insight.
- **CSV/manuel = fallback permanent** : aucun chemin existant supprimé ou cassé.
- **`location_id` toujours explicite** (0 ⇒ retombe sur US 2840). France = 2250.
- Conventional Commits FR + trailers session ; branche `feature/mangools-connector`.
- Copies UI en français, i18n `messages/fr.json` + `en.json` (clés sous `Settings.integrations.mangools`, `Keywords.sync`, `Onboarding.tools`).
- Migrations : ajout dans `domain-schema.ts` → `bun run db:generate` → commit du SQL généré. Tests : `bun test <fichier>` (test DB seedée via pattern existant).

---

### Task 1: Crypto connecteurs (AES-256-GCM)

**Files:**
- Create: `src/lib/connectors/crypto.ts`
- Test: `src/lib/connectors/crypto.test.ts`

**Interfaces:**
- Produces: `encryptSecret(plain: string, secret?: string): string` (format `v1:<iv_b64>:<tag_b64>:<ct_b64>`), `decryptSecret(blob: string, secret?: string): string` (throw `Error("connector_secret_invalid")` si tag/format invalide). `secret` défaut = `process.env.BETTER_AUTH_SECRET` ; clé dérivée par `hkdfSync("sha256", secret, "seo-insight", "org-connectors", 32)` (node:crypto).

- [ ] Test rouge : round-trip (`decryptSecret(encryptSecret("tok"))==="tok"`), deux chiffrés du même clair diffèrent (IV aléatoire), blob altéré → throw, secret différent → throw.
- [ ] Implémenter (node:crypto `createCipheriv("aes-256-gcm")`, IV 12 octets `randomBytes`).
- [ ] `bun test src/lib/connectors/crypto.test.ts` → PASS. Commit `feat(connectors): chiffrement AES-256-GCM des secrets`.

### Task 2: Table `org_connectors` + store

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (fin de fichier)
- Create: `src/lib/connectors/store.ts` + `src/lib/connectors/types.ts`
- Test: `src/lib/connectors/store.test.ts` (test DB, pattern `DB_TESTS=1`)

**Interfaces:**
- Consumes: Task 1 (`encryptSecret`/`decryptSecret`).
- Produces:
  - Schéma : `orgConnectors` — `id text pk` (`${orgId}-${provider}`), `orgId text notNull` (FK organization.id, cascade), `provider text $type<ConnectorProvider> notNull`, `encryptedToken text notNull`, `status text $type<"active"|"invalid"> notNull default "active"`, `connectedBy text notNull`, `createdAt/updatedAt timestamptz defaultNow`. `uniqueIndex("org_connectors_org_provider_uidx").on(orgId, provider)`.
  - `types.ts` : `type ConnectorProvider = "mangools"` (union extensible — `"anthropic"` viendra du chantier IA), `type ConnectorStatus = { connected: boolean; status: "active"|"invalid"|null }`.
  - `store.ts` (`server-only`) : `getConnectorToken(orgId, provider): Promise<string|null>` (null si absent/`invalid`), `getConnectorStatus(orgId, provider): Promise<ConnectorStatus>`, `setConnector(orgId, provider, token, userId): Promise<void>` (upsert, re-chiffre, repasse `active`), `markConnectorInvalid(orgId, provider)`, `removeConnector(orgId, provider)`.

- [ ] Schéma + `bun run db:generate` (vérifier le SQL généré : 1 table + 1 unique index) + `bun run db:migrate:test`.
- [ ] Tests rouges store (upsert/replace/invalid/remove, token déchiffré correct, `getConnectorToken` null quand `invalid`).
- [ ] Implémenter, tests verts. Commit `feat(connectors): table org_connectors + store chiffre`.

### Task 3: Client Mangools + throttle

**Files:**
- Create: `src/lib/mangools/throttle.ts`, `src/lib/mangools/client.ts`, `src/lib/mangools/types.ts`
- Test: `src/lib/mangools/throttle.test.ts`, `src/lib/mangools/client.test.ts` (fixtures `src/lib/mangools/fixtures/`, fetch injecté — zéro réseau)

**Interfaces:**
- Produces:
  - `throttle.ts` : `createThrottle(opts?: { minIntervalMs?: number; now?: () => number; sleep?: (ms:number)=>Promise<void> })` → `{ run<T>(fn: () => Promise<T>): Promise<T> }` — sérialise (file promise-chain) + espace les exécutions de `minIntervalMs` (défaut **500 ms** ⇒ 2 req/s). Registre module-level `getOrgThrottle(orgId: string)` (Map).
  - `types.ts` : `MangoolsKeywordItem { _id:string; kw:string; lid:number; sv:number|null; seo:number|null; cpc:number|null; ppc:number|null; msv: [number,number,number][] | null }`, `MangoolsRelatedResponse { keywords: MangoolsKeywordItem[]; countKeywordsBeforeLimit:number }`, `MangoolsSerpItem { type:string; url?:string; title?:string; desc?:string; serpRank?:number; domain?:string; m?: { moz?: Record<string,unknown>; majestic?: { v?: { CitationFlow?:number; TrustFlow?:number; RefDomains?:number; ExtBackLinks?:number } }; fb?: unknown } }`, `MangoolsSerpResponse { kw:string; location_id:number; items: MangoolsSerpItem[] }`, `MangoolsLocation { _id:number; label:string; country_code:string|null; target_type:string }`, `MangoolsError = "unauthorized"|"quota_exceeded"|"rate_limited"|"upstream_error"`.
  - `client.ts` (`server-only`) : `createMangoolsClient(token: string, deps?: { fetch?: typeof fetch; throttle?: ReturnType<typeof createThrottle> })` → `{ relatedKeywords({kw, locationId}): Promise<MangoolsRelatedResponse>; serps({kw, locationId, platformId?}): Promise<MangoolsSerpResponse>; keywordImports(kws: string[], locationId: number): Promise<MangoolsKeywordItem[]>; searchLocations(query: string): Promise<MangoolsLocation[]> }`. Base `https://api.mangools.com/v3`, header `x-access-token`, timeout 20 s (`AbortSignal.timeout`), mapping erreurs : 401/403→`unauthorized`, 402→`quota_exceeded`, 429→`rate_limited` (backoff `Retry-After`||2000 ms, UNE reprise), autres ≥400→`upstream_error` (throw `MangoolsApiError extends Error { code: MangoolsError; status: number }`).
- Note : la forme de réponse de `keywordImports` (POST `/kwfinder/keyword-imports`) n'est **pas spikée** — l'implémenteur la valide avec `scripts/` + `MANGOOLS_API_KEY` de `.env.local` sur 2-3 kws, fige la fixture `fixture-keyword-imports.json`, et adapte le type si besoin (signaler en DONE_WITH_CONCERNS si la forme diffère des items `related`).

- [ ] Tests rouges throttle (horloge/sleep injectés : 3 runs espacés ≥500 ms, sérialisation, pas d'attente si intervalle déjà écoulé).
- [ ] Tests rouges client sur fixtures (parse related/serps/locations ; 401→unauthorized ; 429 puis 200 → une reprise ; 429 deux fois → throw rate_limited ; 402 → quota_exceeded sans reprise).
- [ ] Implémenter, tests verts. Commit `feat(mangools): client API v3 throttle + taxonomie erreurs`.

### Task 4: Normalisation (Mangools → formes internes)

**Files:**
- Create: `src/lib/mangools/normalize.ts`
- Modify: `src/lib/keywords/serp-types.ts:49` (`SerpSnapshot.source: "se-ranking"` → `"se-ranking" | "mangools"`)
- Test: `src/lib/mangools/normalize.test.ts` (golden sur fixtures)

**Interfaces:**
- Consumes: types Task 3 ; `SourceMetrics`/`MonthlyVolume` (`src/lib/keywords/types.ts:47-55`), `SerpSnapshot`/`SerpCompetitor` (`src/lib/keywords/serp-types.ts`).
- Produces (pur) :
  - `metricsFromMangools(item: MangoolsKeywordItem, now: string): SourceMetrics` — `{ volume: sv??0, kd: seo??0, cpc: cpc??0, growthPct: <calc 12 derniers msv ou null>, competitiveDensity: null, numberOfResults: null, updatedAt: now }`.
  - `trendFromMangools(item): MonthlyVolume[]` — depuis `msv` `[année, moisIdx0, volume]` → `{ month: "YYYY-MM", volume }`, 24 derniers mois max, ordre chronologique.
  - `serpSnapshotFromMangools(resp: MangoolsSerpResponse, importedAt: string): SerpSnapshot` — items `type==="ORGANIC"` → `SerpCompetitor { position: serpRank, domain, url, backlinks: majestic.v.ExtBackLinks??0, refDomains: majestic.v.RefDomains??0, pageAuthority: <moz si dispo sinon 0>, searchTraffic: 0, urlKeywords: 0 }` ; `features` = types non-ORGANIC uniques ; `source: "mangools"`. Réutiliser `isNonCompetitorDomain` (`src/lib/keywords/serp-import.ts`) pour filtrer.

- [ ] Tests rouges golden (fixtures related + serps : valeurs exactes attendues — kw "bague de fiançailles" sv 84700 seo 31 cpc 1.78 ; 1er organique histoiredor.com CF 35 TF 32).
- [ ] Implémenter + élargir `SerpSnapshot.source` (vérifier `bunx tsc --noEmit` : les usages littéraux `"se-ranking"` restent valides).
- [ ] Tests verts + tsc. Commit `feat(mangools): normalisation metrics/trend/serp vers formes internes`.

### Task 5: Locations (résolution pays → location_id)

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (table `mangoolsLocations`)
- Create: `src/lib/mangools/locations.ts`
- Test: `src/lib/mangools/locations.test.ts`

**Interfaces:**
- Consumes: client Task 3 (`searchLocations`).
- Produces: table `mangoolsLocations` — `countryCode text pk` (ISO2 upper), `locationId integer notNull`, `label text notNull`, `updatedAt`. `resolveLocationId(countryCode: string, deps: { search: (q:string)=>Promise<MangoolsLocation[]> }): Promise<number|null>` — lookup DB ; sinon `search(<nom pays>)` (nom via `countryLabel` de `src/lib/sites/options.ts`), garde `target_type==="Country" && country_code===code`, persiste, retourne ; introuvable → null (jamais de fallback silencieux). Seed FR=2250 dans la migration (INSERT).

- [ ] Schéma + `db:generate` (+ INSERT seed FR dans le SQL généré) + migrate:test.
- [ ] Tests rouges (hit DB seedé sans appel réseau ; miss → search mocké → persisté ; introuvable → null).
- [ ] Implémenter, verts. Commit `feat(mangools): resolution pays vers location_id avec cache DB`.

### Task 6: Actions connecteur + carte /settings/integrations

**Files:**
- Create: `src/lib/connectors/core.ts` (server-only, DI), `src/lib/connectors/actions.ts` (`"use server"`), `src/components/settings/mangools-integration.tsx`
- Modify: `src/app/(app)/settings/integrations/page.tsx` (monter la carte + charger le statut)
- Test: `src/lib/connectors/core.test.ts`

**Interfaces:**
- Consumes: store Task 2, client Task 3.
- Produces: `core.ts` : `connectMangools(token: string, deps?: { orgId?, userId?, canManage?, ping?: (token:string)=>Promise<void> })` — **check AVANT écrasement** : `ping` défaut = `createMangoolsClient(token).searchLocations("France")` (doit renvoyer ≥1) ; échec → `{ ok:false, error:"invalid_token" }` et l'ancien token est **conservé** ; succès → `setConnector`. `disconnectMangools()`, `getMangoolsStatus(): Promise<ConnectorStatus>`. Wrappers `"use server"` sans `deps`.
- UI : carte calquée sur `src/components/settings/seranking-integration.tsx` (Card, `Input type="password"`, états absent/actif/invalide, owner/admin write via le même mécanisme `canManage`, lien « Obtenir un token » → https://mangools.com/api-token, **remplacement = même formulaire** avec le même check). i18n `Settings.integrations.mangools.*` (fr+en).

- [ ] Tests rouges core (ping ok → stocké ; ping fail → ancien conservé + invalid_token ; non-admin → refus ; disconnect).
- [ ] Implémenter core+actions, verts ; puis carte + montage page (RSC : `getMangoolsStatus()` en parallèle des autres via Promise.all).
- [ ] `bunx tsc --noEmit` + `bunx biome check` sur les fichiers touchés. Commit `feat(connectors): connexion Mangools BYOK dans les reglages avec check de token`.

### Task 7: Rail de statut d'enrichissement par mot-clé (générique)

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (colonne sur `keywords`), `src/lib/keywords/types.ts`
- Create: `src/lib/keywords/enrich-status.ts` + action `fetchEnrichStatusesAction` dans `src/lib/keywords/enrich-status-actions.ts` (`"use server"`)
- Test: `src/lib/keywords/enrich-status.test.ts`

**Interfaces:**
- Produces:
  - `types.ts` : `type EnrichState = { status: "queued"|"syncing"|"done"|"error"; at: string; error?: string }` ; `type KeywordEnrichment = Partial<Record<KeywordSource, EnrichState>>`.
  - Colonne : `enrichment: jsonb("enrichment").$type<KeywordEnrichment>().notNull().default({})` sur `keywords`. Migration `db:generate`.
  - `enrich-status.ts` (`server-only`) : `setEnrichStatus(keywordIds: string[], provider: KeywordSource, state: EnrichState): Promise<void>` (UPDATE merge jsonb `enrichment || {...}` chunké), `clearEnrichStatus(keywordIds, provider)`.
  - Action : `fetchEnrichStatusesAction(siteId: string, keywordIds: string[]): Promise<Record<string, KeywordEnrichment>>` — org-scopée (même garde que les actions keywords existantes), SELECT id+enrichment, ≤500 ids.

- [ ] Schéma + migration + tests rouges (set/merge/clear, multi-provider sans écrasement croisé, action scoping).
- [ ] Implémenter, verts. Commit `feat(keywords): rail de statut d'enrichissement par provider`.

### Task 8: Job `keyword-enrich` (service analytics)

**Files:**
- Modify: `src/lib/analytics/enqueue.ts` (union + input), `services/analytics/src/index.ts` (schéma Zod + garde anti-doublon), `services/analytics/src/worker.ts` (registre HANDLERS)
- Create: `services/analytics/src/keyword-enrich.ts`
- Test: `services/analytics/test/keyword-enrich.test.ts` (DI, fixtures, test DB)

**Interfaces:**
- Consumes: client (Task 3), normalize (Task 4), locations (Task 5), enrich-status (Task 7), token via store (Task 2 — le job résout `getConnectorToken(orgId,"mangools")` ; l'orgId du site via la table sites comme les jobs existants), patterns `updateJobTotals`/`completeJob`/`failJob` (`services/analytics/src/queue.ts`).
- Produces: `EnqueueInput` + `KeywordEnrichInput = { type:"keyword-enrich"; siteId: string; payload: { provider: "mangools"; keywordIds: string[] } }` ; handler `processKeywordEnrich(job, deps?)` :
  1. `setEnrichStatus(all, "mangools", {status:"queued", at})` dès le claim.
  2. Batches de **50 kws** via `keywordImports` (throttle du client s'applique), par batch : `syncing` → merge metrics/trend par kw (MÊME écriture que `src/lib/seranking/sync.ts:172-202` : `metrics || {...}::jsonb`, `trendBySource`, `trend` fallback, bascule `primarySource` si la primaire n'a pas de volume) → `done` par kw ; kw absent de la réponse → `error:"not_found"`.
  3. `updateJobTotals(job.id, { processed, total, errors })` après chaque batch.
  4. `MangoolsApiError quota_exceeded` → tous les kws restants `error:"quota"`, job `completeJob` avec totals (PAS failed — le quota client n'est pas une panne). `unauthorized` → `markConnectorInvalid` + restants `error:"token"`, complete. Autres erreurs → `failJob`.
- Garde anti-doublon index.ts : 409 si un `keyword-enrich` `queued|running` existe pour le même `siteId`+`provider`.

- [ ] D'abord : spike `keyword-imports` (script scratchpad, 2-3 kws, `MANGOOLS_API_KEY`) → fixture `src/lib/mangools/fixtures/fixture-keyword-imports.json` commitée + type ajusté si besoin.
- [ ] Tests rouges handler (fixtures, deps injectées : happy path 2 batches, quota au 2ᵉ batch → restants error:"quota" + complete, unauthorized → connecteur invalid).
- [ ] Implémenter (enqueue + zod + handler + registre), verts. Commit `feat(analytics): job keyword-enrich Mangools avec statut par mot-cle`.

### Task 9: Trigger auto à l'ajout + SERP auto à l'analyse

**Files:**
- Modify: `src/lib/keywords/actions.ts` (`addKeywordAction` — après insert réussi), `src/lib/semantic/actions.ts:35-71` (`startSerpSemanticsAction`)
- Create: `src/lib/mangools/serp-fetch.ts`
- Test: `src/lib/mangools/serp-fetch.test.ts`, extension de `src/lib/semantic/actions.test.ts` si existant (sinon test DI de la nouvelle branche via `serp-fetch` seul + revue manuelle du câblage)

**Interfaces:**
- Consumes: Tasks 2-5, 7-8 ; `importSerpSnapshot`-pattern (`src/lib/keywords/serp-store.ts:54-62`) ; `buildSerpSemanticsPayload` (`src/lib/semantic/serp-payload.ts:24`).
- Produces:
  - `serp-fetch.ts` (`server-only`, DI) : `fetchSerpViaMangools(keywordId: string, deps?): Promise<{ ok:true } | { ok:false; error:"no_connector"|"no_location"|"quota"|"upstream" }>` — charge kw (term/country/orgId), token, locationId ; `client.serps` ; `serpSnapshotFromMangools` ; merge jsonb sur la locale (même SQL que serp-store) ; stampe `keywords.enrichment` non — hors rail (c'est une SERP, pas un enrichissement kw).
  - `addKeywordAction` : après insert d'un kw SANS métriques, si `getConnectorToken(orgId,"mangools")` → `enqueueAnalyticsJob({type:"keyword-enrich", siteId, payload:{provider:"mangools", keywordIds:[id]}})` en best-effort (try/catch silencieux, 409 toléré) — l'ajout n'échoue JAMAIS à cause de l'enrichissement.
  - `startSerpSemanticsAction` : branche `no_serp` → si connecteur actif, `await fetchSerpViaMangools(keywordId)` puis re-SELECT + re-`buildSerpSemanticsPayload` ; succès → enqueue normalement ; échec → `{ ok:false, error:"no_serp" }` inchangé (fallback manuel intact).

- [ ] Tests rouges serp-fetch (fixture serps → snapshot mergé sur la bonne locale ; no_connector ; quota).
- [ ] Implémenter + câblages, verts + `bunx tsc --noEmit`. Commit `feat(mangools): enrichissement auto a l'ajout + SERP auto a l'analyse`.

### Task 10: UI — spinner par ligne, bouton sync par kw, menu Sync, rail SE-Ranking

**Files:**
- Create: `src/components/keywords/enrich-status-cell.tsx`, `src/hooks/use-enrich-polling.ts`, `src/hooks/use-mangools-sync.ts`
- Modify: `src/components/keywords/columns.tsx` + `research/research-columns.tsx` (cellule statut à côté du terme), `src/components/keywords/row-actions.tsx` (bouton sync), `src/components/keywords/sync-menu.tsx` (entrée Mangools + footer), `src/components/keywords/reservoir.tsx` + `research/research-container.tsx` (câblage props/hooks), `src/lib/seranking/sync.ts` (écrit le rail : `queued`→`syncing`→`done|error` par kw)
- Test: `src/lib/seranking/sync.test.ts` (extension rail), `src/hooks/use-enrich-polling.test.ts` si pattern de test hooks existant, sinon logique extraite pure + testée

**Interfaces:**
- Consumes: `fetchEnrichStatusesAction` (Task 7), enqueue keyword-enrich (Task 8), `SyncMenu` props existantes (`sync-menu.tsx:44-52`), `KeywordRowActions` (`row-actions.tsx:21`).
- Produces:
  - `use-enrich-polling.ts` : `useEnrichPolling(siteId, visibleIds: string[])` → `{ statuses: Record<string, KeywordEnrichment>, refresh() }` — poll `fetchEnrichStatusesAction` toutes les 2,5 s **uniquement** tant qu'au moins un statut est `queued|syncing` (sinon aucun timer), arrêt auto, `refresh()` manuel après enqueue.
  - `enrich-status-cell.tsx` : spinner (`Loader2` animate-spin) si `queued|syncing`, tooltip erreur (`error` : quota/token/not_found) — **provider-agnostique** : affiche l'état le plus « actif » toutes sources confondues, tooltip liste par source.
  - `use-mangools-sync.ts` : `useMangoolsSync(siteId, { onQueued })` → `{ syncSelection(ids), syncOne(id), syncing }` — server action wrapper qui enqueue `keyword-enrich` (sélection, ou ids sans métriques si vide) puis `onQueued` → `refresh()`.
  - `sync-menu.tsx` : props ajoutées `mangoolsConnected: boolean`, `syncingMangools: boolean`, `onSyncMangools: () => void`, `lastMangoolsSyncAt: string|null` ; entrée « Enrichir via Mangools » — **miroir exact du comportement SE-Ranking** (exigence Bastien, screenshot 2026-07-25) : description dynamique avec compteur (`selectedCount > 0` → « Volume, KD, CPC, tendance — N sélectionné(s) », sinon « … — tout ce qui n'a pas de métriques »), la sync porte sur LA SÉLECTION quand elle existe (même mécanique que `serankingDesc`, `sync-menu.tsx:65-68`) ; footer étendu « · Mangools : {relativeFr(...) ?? "jamais"} ». `mangoolsConnected` arrive des pages RSC via `getMangoolsStatus()`. `lastMangoolsSyncAt` = max `metrics->mangools->>updatedAt` exposé par `getKeywordsSyncStatus` (étendre `src/lib/keywords/...` où il vit — repérer et suivre le pattern GSC/SE-Ranking existant).
  - `row-actions.tsx` : prop optionnelle `onSync?: () => void` → icône `RefreshCw` avant Supprimer (masquée si non connecté).
  - `seranking/sync.ts` : dans la boucle d'écriture existante (L172-202), poser `syncing` au début du chunk et `done|error` par kw via `setEnrichStatus` — **même rail, zéro UI spécifique**.

- [ ] Tests rouges rail SE-Ranking (sync écrit les statuts) + logique polling (start/stop).
- [ ] Implémenter cellule+hooks+menu+row (Réservoir PUIS Recherche, mêmes composants partagés).
- [ ] Vérif manuelle d3k (`--no-portless`) : connecter Mangools (token .env.local), ajouter un kw → spinner visible puis métriques ; sync sélection via menu ; sync 1 kw via ligne.
- [ ] tsc + biome + tests verts. Commit `feat(keywords): suivi de sync par kw + enrichir via Mangools (menu, ligne, spinner)`.

### Task 11: Dialog « Mots-clés associés » (Réservoir + Recherche)

**Files:**
- Create: `src/lib/mangools/related-actions.ts` (`"use server"` + core DI `related-core.ts`), `src/components/keywords/related-keywords-dialog.tsx`
- Modify: `src/components/keywords/reservoir-toolbar.tsx` + `research/research-toolbar.tsx` (bouton « Mots-clés associés », actif si 1 kw sélectionné OU depuis la ligne), `src/components/keywords/row-actions.tsx` (entrée optionnelle `onRelated?`)
- Test: `src/lib/mangools/related-core.test.ts`

**Interfaces:**
- Consumes: client/normalize/locations (Tasks 3-5), `addKeywordAction`-pattern & stages/destination (`src/lib/keywords/actions.ts`), upsert merge metrics (pattern `upsertCanonical`).
- Produces:
  - `related-core.ts` : `getRelatedKeywords(siteId, seedTerm, deps?): Promise<{ ok:true; rows: RelatedRow[] } | { ok:false; error:"no_connector"|"no_location"|"quota"|"upstream" }>` où `RelatedRow = { term:string; volume:number|null; kd:number|null; cpc:number|null }` (mappé de `metricsFromMangools`) — pays = `sites.targetCountries[0]` (règle des dialogs existants).
  - `addRelatedKeywordsAction(siteId, rows: RelatedRow[], destination: "reservoir"|"recherche")` : insert/merge chunké avec `metrics: { mangools: {...} }`, `primarySource:"mangools"`, `sources:["mangools"]`, stage selon destination — kws arrivent **déjà enrichis**, PAS de job derrière.
  - Dialog (client, partagé) : seed = kw sélectionné ; tableau (terme, volume, KD, CPC) trié pertinence (ordre API), filtre texte local, checkboxes + « tout », footer « Ajouter au Réservoir » / « Envoyer en Recherche » (+ compteur), état vide/erreur (quota, no_connector → CTA « Connecter Mangools » lien `/settings/integrations`). `<DialogContent className="sm:max-w-2xl">`, liste `max-h-[50dvh] overflow-y-auto`.

- [ ] Tests rouges core (fixture related → rows ; no_connector ; add : merge metrics + stage destination, idempotent).
- [ ] Implémenter actions + dialog + montages (2 toolbars + row-actions), verts.
- [ ] Vérif d3k : depuis un kw du Réservoir → dialog → ajouter 3 kws → lignes présentes avec volume/KD immédiats.
- [ ] Commit `feat(keywords): dialog mots-cles associes Mangools (reservoir + recherche)`.

### Task 12: Onboarding — étape « Connecte tes outils » (skippable)

**Files:**
- Modify: `src/components/onboarding/onboarding-wizard.tsx` (STEPS + rendu), 
- Create: `src/components/onboarding/steps/tools-step.tsx`
- Test: typecheck + smoke (pattern de tests wizard existant s'il y en a, sinon vérif d3k)

**Interfaces:**
- Consumes: `connectMangools`/`getMangoolsStatus` (Task 6), pattern étapes (`Step`/`STEPS`/`STEP_LABEL`, props `onNext`/`onBack` — `onboarding-wizard.tsx:13-18`).
- Produces: `Step = "language"|"plan"|"team"|"tools"`, STEPS `["language","plan","team","tools"]` (dernier = après team) ; `ToolsStep({ onNext, onBack })` : carte Mangools (pitch « fini les imports manuels : volumes, difficulté et SERP en automatique », input token masqué, bouton Connecter avec check, état succès) + bouton **« Passer »** toujours visible → `onNext` (= complétion). i18n `Onboarding.tools.*`.

- [ ] Implémenter + i18n fr/en.
- [ ] Vérif d3k : wizard complet avec skip, et avec connexion réelle.
- [ ] tsc + biome. Commit `feat(onboarding): etape optionnelle connexion Mangools`.

### Task 13: Finitions — i18n audit, CTA contextuels, docs

**Files:**
- Modify: CTA « Connecter Mangools » là où le connecteur absent bloque l'auto : `related-keywords-dialog.tsx` (déjà Task 11), message `no_serp` du flux d'analyse sémantique (composant qui affiche l'erreur — le repérer via `startSerpSemanticsAction` usages), `docs/project/ops/serveur-prod.md` (rien à déployer de spécial — noter le connecteur), `docs/project/imports-follow-ups.md` (Mangools = source API ; CSV fallback ; migration future de serankingApiKey vers org_connectors)
- Test: `bun run scripts/i18n-audit.ts` propre, suite complète

**Interfaces:** —

- [ ] CTA no_serp (+ i18n), docs, audit i18n.
- [ ] `bunx tsc --noEmit` + `bunx biome check src services` + `bun test` (baseline : comparer au même compte de fails préexistants sur dev, zéro nouveau).
- [ ] Commit `chore(mangools): CTA contextuels, i18n, docs follow-ups`.

---

## Self-review (spec ↔ plan)

- Token org chiffré + check avant écrasement → Tasks 1-2-6. Throttle 2 req/s séquentiel + 429 une reprise + 402 sans retry → Tasks 3, 8. Rail statut générique + spinner + bouton ligne + menu global + SE-Ranking sur le rail → Tasks 7, 10. Triggers auto (ajout + analyse) → Task 9. Mots-clés associés déjà-enrichis Réservoir/Recherche → Task 11. Onboarding skippable → Task 12. CSV fallback intact → aucun chemin supprimé (Tasks 9/13 ne retirent rien). locations explicites → Task 5. Fixtures réelles → présentes + keyword-imports fixée en Task 8. Gratuit → aucun code billing nulle part.
- Types cohérents : `EnrichState`/`KeywordEnrichment` (T7) consommés par T8/T10 ; `MangoolsApiError.code` (T3) consommé par T8/T9/T11 ; `RelatedRow` (T11) autonome ; `SerpSnapshot.source` élargi (T4) consommé par T9.
- Reste assumé hors plan : migration de `organization.serankingApiKey` vers `org_connectors` (follow-up documenté T13).
