# Rattacher keywords→pages existantes — 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:** À la promotion « Vers Contenu », décider par-keyword **optimiser une page existante (fusion, zéro doublon) vs créer du neuf**, avec un axe `kind` extensible, la page cible visible en Recherche + Planning, suggestion live pour les gaps, et une trace de cannibalisation si on force la création.

**Architecture:** Nouvelle dimension `content_items.kind` (`new`|`optimize`) orthogonale au statut. `promoteKeywordsToContentAction` gagne un param `decisions` (optimize/create par kw) ; la fusion par URL compare `normalizeUrl` en JS (les URLs content_items sont brutes en base). Logique de résolution de page pure dans `src/lib/pages/attach.ts`. Dialog de promotion réécrit en décision par-kw avec défauts intelligents.

**Tech Stack:** Drizzle/Postgres, Next 16 RSC/Server Actions, tanstack table + table HTML brute (planning).

**Spec (normative)** : `docs/superpowers/specs/2026-07-15-keyword-page-attach-design.md`.

## Global Constraints

- Branche `feature/keyword-page-attach` (créer depuis dev). Conventional Commits. JAMAIS main/prod.
- `content_items.kind` : `"new" | "optimize"`, défaut `"new"`. Axe EXTENSIBLE (ne pas surcharger `status`). `warned_page_url text NULL` = trace cannibalisation.
- Fusion par URL : comparer `normalizeUrl(content_items.url) === normalizeUrl(pageUrl)` en JS (les URLs en base sont brutes — pas de normalisation à l'écriture aujourd'hui). Les items `optimize` créés stockent `url = normalizeUrl(pageUrl)`. `normalizeUrl` = `src/lib/content/stats-logic.ts:18` (seul point de vérité).
- « à retravailler » = si l'item managé fusionné était `published` → statut repasse `writing` ; sinon inchangé. `kind` bascule vers `optimize`.
- Idempotence : kw déjà lié à l'item cible → skip compté. Tenancy : page ET content_item du `siteId` (vérifié). Rétrocompat : `promoteKeywordsToContentAction` sans `decisions` = comportement actuel (create, kind new).
- Dialog par-kw : défaut intelligent (page résolue → Optimiser coché ; sinon Créer) + toggle + picker par ligne. Le mode « 1 page (groupe) » reste inchangé.
- i18n : 4 catalogues `messages/{en,fr,es,de}.json`. Tests DI (pas de mock.module). Gates par task : tests ciblés + `bunx tsc --noEmit` + biome. Gate finale : `bun run test:all`.
- Modèles subagents : Opus 4.8 par défaut ; Fable pour la review finale de branche si disponible (sinon Opus). `git add` scoped, jamais `-A` (courses d'index constatées).
- Vagues parallèles : T1‖T2, puis T3‖T4, puis T5‖T6‖T7, puis T8.

---

### Task 1: Schéma — content_items.kind + warned_page_url + ContentKind  [VAGUE 1]

**Files:** `src/lib/db/domain-schema.ts`, `src/lib/content/types.ts`, `drizzle/0065_*.sql` (db:generate), dev+test DB.

**Interfaces (produit):**
- `content_items.kind text NOT NULL DEFAULT 'new'` `$type<ContentKind>` ; `content_items.warned_page_url text NULL`.
- `ContentKind = "new" | "optimize"` (types.ts) ; `CONTENT_KINDS: ContentKind[]` ; `CONTENT_KIND_LABEL: Record<ContentKind,string>` (`new`: « Nouveau contenu », `optimize`: « Optimisation »).

- [ ] Ajouter `ContentKind` type + colonne `kind` (`text("kind").$type<ContentKind>().notNull().default("new")`) et `warnedPageUrl: text("warned_page_url")` à `contentItems` (domain-schema.ts:535). Exporter `CONTENT_KINDS`/`CONTENT_KIND_LABEL` dans `src/lib/content/types.ts` (à côté de CONTENT_STATUSES).
- [ ] `bun run db:generate` → inspecter (2 ALTER seulement) → appliquer dev (`bun run db:migrate`) + test (`bun run db:migrate:test`).
- [ ] Étendre `ContentItem` (types.ts:46) avec `kind: ContentKind; warnedPageUrl: string | null` ; peupler dans `getContentItemsForSite` (queries.ts — ajouter les 2 colonnes au select + mapping). Vérifier que rien ne casse (le board lit `ContentItem`).
- [ ] `bunx tsc --noEmit` propre ; `bun run test src/lib/content src/lib/db` sans régression.
- [ ] Commit `feat(content): dimension kind (new/optimize) + trace cannibalisation (migration 0065)`.

---

### Task 2: Résolution de page — src/lib/pages/attach.ts (pur)  [VAGUE 1]

**Files (create):** `src/lib/pages/attach.ts`, `src/lib/pages/attach.test.ts`.

**Interfaces (produit):**
```ts
import type { PageRef } from "./mapping";
export type ResolvedPage = {
  pageId: string; url: string;
  source: "gsc" | "content" | "text" | "manual";
  confidence: "mapped" | "suggested"; score: number | null;
};
export type KwForResolve = {
  id: string; term: string;
  targetPage?: { url: string; source: "gsc"|"content"|"text"|"manual"; score: number|null } | null;
  targetPageId?: string | null;
};
// mapped (targetPage déjà en base) > suggested (textualMatch live) > null
export function resolvePageForKeyword(kw: KwForResolve, pages: PageRef[]): ResolvedPage | null;
// défaut du dialog : "optimize" si une page est résolue, sinon "create"
export function defaultDecision(resolved: ResolvedPage | null): "optimize" | "create";
```

- [ ] Tests : kw avec `targetPage` + `targetPageId` → `{confidence:"mapped", source: celui du targetPage}` (pas de textualMatch) ; kw sans targetPage mais textualMatch trouve → `{confidence:"suggested", source:"text", score}` ; kw sans rien → null ; `defaultDecision(non-null)==="optimize"`, `defaultDecision(null)==="create"`. (Import `textualMatch`/`PageRef` de `./mapping` ; construire les PageRef de test à la main.)
- [ ] Impl : `resolvePageForKeyword` — si `kw.targetPageId && kw.targetPage` → mapped (retrouve l'url via kw.targetPage.url, pageId = kw.targetPageId) ; sinon `const m = textualMatch(kw.term, pages)` → si m, retrouver la page par `m.pageId` dans `pages` pour l'url → suggested ; sinon null. `defaultDecision` trivial.
- [ ] `bun test src/lib/pages/attach.test.ts` vert, tsc, biome.
- [ ] Commit `feat(content): résolution de page pour un keyword (mapped > suggested > gap)`.

---

### Task 3: promoteKeywordsToContentAction — mode optimize + decisions  [VAGUE 2, cœur]

**Files:** `src/lib/content/actions.ts` (modif promote + nouvelle resolvePagesForKeywordsAction), `src/lib/content/actions.test.ts` (ou content.test.ts — suis l'existant).

**Interfaces:**
- Consumes: `resolvePageForKeyword`/`defaultDecision` (T2), `normalizeUrl` (content/stats-logic), `ContentKind` (T1), `sitePages` (schéma), `searchSitePages` interne.
- Produces (pour T5) :
```ts
export type PromoteDecision =
  | { action: "optimize"; pageUrl: string; pageId: string }
  | { action: "create"; warnedPageUrl?: string };
// 5e param optionnel — sans lui : comportement actuel (create/new). En mode
// per-keyword uniquement (one-page inchangé).
export async function promoteKeywordsToContentAction(
  siteId: string, keywordIds: string[], mode: "per-keyword" | "one-page",
  primaryId?: string, decisions?: Record<string, PromoteDecision>,
): Promise<{ created: number; optimized: number; merged: number; skipped: string[] }>;
// pour le dialog : résout la page de chaque kw (targetPage + suggestion live)
export type ResolvedForDialog = {
  keywordId: string; term: string;
  page: { pageId: string; url: string; source: string; confidence: "mapped"|"suggested"; score: number|null } | null;
};
export async function resolvePagesForKeywordsAction(
  siteId: string, keywordIds: string[],
): Promise<ResolvedForDialog[]>;
```

- [ ] Tests DB (pattern content.test.ts, fixtures distinctes `attach-*`) :
  - `resolvePagesForKeywordsAction` : kw avec targetPage → page mapped ; kw sans → suggestion live si une site_page matche ; sinon null.
  - promote `decisions[kw]={action:"optimize",pageUrl,pageId}` sur une URL **managée** (content_item existant avec `url` dont normalizeUrl == normalizeUrl(pageUrl)) → PAS de nouvel item ; kw ajouté en `content_keywords` (role secondary, onConflictDoNothing) ; l'item passe `kind="optimize"` ; si il était `published` → `status="writing"`, sinon inchangé ; le kw reçoit `targetPage` manual sur pageId.
  - optimize sur URL **non managée** → 1 content_item créé `kind="optimize"`, `url=normalizeUrl(pageUrl)`, title = `site_pages.title` (fallback term), kw primary + content_keywords.
  - idempotence : 2 kw optimize vers la même URL managée → 1 seul ajout par kw, aucun doublon d'item ; re-promotion → skip compté.
  - `decisions[kw]={action:"create",warnedPageUrl:"https://x/y"}` → item `kind="new"`, `warned_page_url` posé.
  - rétrocompat : sans `decisions` → comportement actuel (create, kind new, warned null).
  - tenancy : pageId/pageUrl d'un autre site, ou content_item managé d'un autre site, jamais touchés.
- [ ] Impl `resolvePagesForKeywordsAction` : `assertSiteInActiveOrg` ; SELECT keywords (id, term, targetPageId + LEFT JOIN site_pages pour url/source) + SELECT `site_pages` du site (PageRef) ; `resolvePageForKeyword` par kw ; retourne la liste.
- [ ] Impl promote optimize : après le fetch kws, si `decisions` fourni, brancher par kw. Charger une fois les content_items managés candidats : `SELECT id, url, status, kind FROM content_items WHERE siteId AND url IS NOT NULL` → `Map<normalizeUrl(url), item>`. Pour un kw optimize : chercher `managed.get(normalizeUrl(pageUrl))`. Trouvé → `addContentKeywords` idempotent (role secondary) + `UPDATE content_items SET kind='optimize', status = CASE WHEN status='published' THEN 'writing' ELSE status END WHERE id` + `setKeywordTargetPage` manual. Non trouvé → INSERT content_item (kind optimize, url normalisée, title site_pages) + content_keywords primary + targetPage manual. create → INSERT kind new (+ warned_page_url si fourni). Compter created/optimized/merged/skipped. Transaction par lot.
  ⚠️ Ne PAS casser les 2 modes existants sans `decisions`. Le mode one-page reste littéralement inchangé.
- [ ] `bun run test src/lib/content` vert, tsc, biome.
- [ ] Commit `feat(content): promotion optimize vs create — fusion par URL, kind, trace cannibalisation`.

---

### Task 4: Planning — peupler targetPage sur les rows  [VAGUE 2, parallèle T3]

**Files:** `src/lib/keywords/queries.ts` (getPlanningKeywords), `src/lib/keywords/queries.test.ts`.

**Interfaces:** `getPlanningKeywords(siteId)` peuple désormais `keyword.targetPage` (LEFT JOIN site_pages sur targetPageId, comme le réservoir) au lieu de null.

- [ ] Test : un kw planning avec `targetPageId` renseigné → `row.keyword.targetPage.url` non null ; sans → null. (Regarde comment le réservoir a fait le LEFT JOIN dans `keywordSelect`/`toKeyword` — réutilise le même mécanisme ; getPlanningKeywords utilise probablement `toKeyword`, donc il suffit d'inclure `targetPageUrl`/source dans le select de getPlanningKeywords.)
- [ ] Impl : ajouter le LEFT JOIN site_pages + les colonnes `targetPageUrl`/`targetPageSource` au select de `getPlanningKeywords`, mapper via `toKeyword` (déjà supporté par `KeywordReadRow.targetPageUrl?`).
- [ ] `bun run test src/lib/keywords` vert, tsc, biome.
- [ ] Commit `feat(content): targetPage peuplé sur les rows du planning`.

---

### Task 5: Dialog « Vers Contenu » — décision par-kw  [VAGUE 3]

**Files:** `src/components/keywords/promote-content-dialog.tsx`, `messages/{en,fr,es,de}.json` (bloc keywords/content existant).

**Interfaces:** Consumes `resolvePagesForKeywordsAction` + `promoteKeywordsToContentAction(decisions)` (T3), `searchSitePagesAction` (Pages), `defaultDecision` (T2).

- [ ] À l'ouverture (mode par-keyword) : `resolvePagesForKeywordsAction(siteId, ids)` → état `decisions: Record<id, {action, pageId?, pageUrl?}>` initialisé par `defaultDecision` (page → optimize avec la page résolue ; sinon create).
- [ ] Rendu par-kw : une ligne par keyword — terme + (si page) toggle « 🔄 Optimiser [path court] (badge source/confiance) » / « ➕ Créer » + bouton picker (searchSitePagesAction, debounce) pour rattacher une autre page ; (si pas de page) « ➕ Créer » + picker optionnel. Forcer Créer alors qu'une page existait → embarquer `warnedPageUrl` dans la decision create.
- [ ] Submit : `promoteKeywordsToContentAction(siteId, ids, "per-keyword", undefined, decisions)` → toast récap « X optimisés · Y créés · Z fusionnés ». Le mode « 1 page (groupe) » (RadioGroup existant) reste pour !single et ne passe pas de decisions.
- [ ] i18n 4 catalogues. `bunx tsc --noEmit`, biome, `bun run test src/components` sans régression.
- [ ] Commit `feat(content): dialog Vers Contenu — décision optimiser/créer par keyword + picker`.

---

### Task 6: Colonnes « Page ciblée » — Recherche démasquée + Planning  [VAGUE 3, parallèle T5]

**Files:** `src/components/keywords/research/research-container.tsx` (démasquer + câbler meta), `src/components/keywords/planning/planning-table.tsx` (colonne), `messages/*` si libellés.

**Interfaces:** Consumes `TargetPageCell` (props `{keyword: Keyword, siteId, onChanged}`), `keyword.targetPage` peuplé (T4 pour planning ; déjà présent au réservoir/recherche).

- [ ] Recherche : retirer `targetPage: false` du columnVisibility (research-container.tsx:256-259) ET câbler la `meta` de la table recherche (`siteId: activeSiteId`, `onTargetPageChanged: refresh`) — sinon la cell affiche « — » (le garde `if(!siteId)`). Vérifier que la colonne devient fonctionnelle (picker actif).
- [ ] Planning : `<th>` « Page ciblée » après Sources (planning-table.tsx:294) + `<td>` `<TargetPageCell keyword={k} siteId={siteId} onChanged={()=>router.refresh()} />` (~:407) avec `stopPropagation` (le cell le gère). Incrémenter le `colSpan` de l'empty-state (:302).
- [ ] `bunx tsc --noEmit`, biome, `bun run test src/components` sans régression.
- [ ] Commit `feat(content): colonne Page ciblée fonctionnelle en Recherche + Planning`.

---

### Task 7: Kanban Contenu — badges kind + cannibalisation + facette  [VAGUE 3, parallèle T5/T6]

**Files:** `src/components/content/content-card.tsx`, `src/components/content/content-columns.tsx`, `src/components/content/content-toolbar.tsx`, `messages/*`.

**Interfaces:** Consumes `ContentItem.kind`/`warnedPageUrl` (T1), `CONTENT_KIND_LABEL` (T1).

- [ ] Carte kanban (content-card.tsx) : badge `kind` (« Optimisation » si `kind==="optimize"`, discret si `new`) + badge « ⚠️ Cannibalisation » (tooltip URL) si `warnedPageUrl`.
- [ ] Vue liste (content-columns.tsx) : colonne/badge kind + indicateur cannibalisation.
- [ ] Facette `kind` (content-toolbar.tsx) : `FacetDropdown` (Nouveau/Optimisation) + `case "kind"` dans les chips + clause dans `filterContentItems` (le filtre pur). Dimension extensible (mappe `CONTENT_KINDS`).
- [ ] i18n 4 catalogues. tsc, biome, `bun run test src/components` sans régression.
- [ ] Commit `feat(content): kanban — badge kind, alerte cannibalisation, facette de filtre`.

---

### Task 8: Gate + roadmap + review + merge + QA  [orchestrateur]

- [ ] `bun run test:all` + tsc + biome verts.
- [ ] Roadmap : entrée ✅ (optimize vs create, kind extensible, anti-cannibalisation).
- [ ] Review finale de branche (Fable si dispo, sinon Opus) → fixes → merge ff-only dev + push + delete branche.
- [ ] QA live : promouvoir un kw advmfr qui a une page mappée → dialog propose Optimiser → fusion (pas de doublon) ; un gap → suggestion live ou Créer ; « Créer quand même » → badge cannibalisation au kanban ; colonnes Recherche/Planning fonctionnelles ; facette kind.

## Vérification finale

1. Suites vertes ; migration 0065 chaîne intacte.
2. Optimiser sur URL managée = zéro nouvel item, kw ajouté, statut published→writing.
3. Re-promotion d'une URL optimize (créée au 1er passage) = fusion, pas de doublon.
4. Sans `decisions`, la promotion se comporte exactement comme avant.
5. `kind` n'a pas touché l'enum `status` (axe orthogonal).
