# Système de promotion keywords — Réservoir → Recherche/Planning → Contenu

**Date:** 2026-06-10
**CdC:** `~/Documents/seo insight/docs/specs/keywords/` — recherche « pousse en planning » ; planning F10 « promotion vers contenu/planning » (crée l'item éditorial, lie `content_ref`).
**Décisions (brainstorm):** approche **A** — UX de promotion unifiée sur sélection + table de liaison `content_keywords` ; dialog au moment de promouvoir (« 1 page (groupe) » vs « N pages ») ; contrainte clé : **gérable après** (changer le principal, ajouter/retirer des mots-clés d'une page).

---

## État des lieux (ce qui existe déjà)

| Flux | Mécanisme existant |
|---|---|
| Réservoir → Recherche | `addKeywordsToStageAction(siteId, ids, "research")` — bouton « Vers Recherche » (`BulkActionsBar`) |
| Réservoir → Planning | idem avec `"planning"` — bouton « Vers Planning » |
| Réservoir → Contenu | `createContentFromKeywordsAction` (1 kw = 1 item, dédup par `primaryKeywordId`) — bouton « Vers Contenu » (appel direct, pas de choix groupe) |
| Recherche → Planning | implicite : cibler un pays en `planned` ajoute le stage `planning` (pont Chambre de Briefing) |
| Planning (Briefing) → Contenu | ❌ rien : table maison sans sélection bulk |
| Gérer les mots-clés d'une page après | ❌ `content_items.primaryKeywordId` = un seul FK, pas de secondaires |

Le chantier : compléter les manques + rendre l'ensemble cohérent, **sans casser l'existant**.

## A. Table de liaison `content_keywords`

```ts
export const contentKeywords = pgTable(
  "content_keywords",
  {
    contentItemId: text("content_item_id").notNull()
      .references(() => contentItems.id, { onDelete: "cascade" }),
    keywordId: text("keyword_id").notNull()
      .references(() => keywords.id, { onDelete: "cascade" }),
    role: text("role").$type<"primary" | "secondary">().notNull().default("secondary"),
  },
  (t) => [primaryKey({ columns: [t.contentItemId, t.keywordId] })],
);
```

- Migration Drizzle (`db:generate` + `db:migrate` + `db:migrate:test`, jamais `db:push`).
- **Compat sans backfill obligatoire** : la lecture (`getContentKeywords`) **fallback** sur `primaryKeywordId` quand un item n'a aucune ligne de liaison (les vieux items restent corrects). Les écritures matérialisent les lignes. Backfill one-off optionnel (dev + prod runbook) : `INSERT INTO content_keywords (content_item_id, keyword_id, role) SELECT id, primary_keyword_id, 'primary' FROM content_items WHERE primary_keyword_id IS NOT NULL ON CONFLICT DO NOTHING;`
- `content_items.primaryKeywordId` est **conservé et maintenu en sync** (= la ligne `role='primary'`) — `ContentDot`, le kanban et les requêtes existantes continuent de marcher.

## B. Action de promotion vers Contenu

`promoteKeywordsToContentAction(siteId, keywordIds, mode)` dans `src/lib/content/actions.ts` :

- **`mode: "per-keyword"`** (N pages) : comportement actuel (skip les keywords déjà `primary` d'un item du site, crée 1 item par keyword, title = term, status `idea`, lang/country du keyword) **+ écrit la ligne `content_keywords` role `primary`**.
- **`mode: "one-page"`** (groupe) : crée **1** item — `primaryKeywordId` = le keyword **principal** (choisi dans le dialog, défaut = premier sélectionné), title = term du principal, lang/country du principal ; lignes `content_keywords` : principal en `primary`, les autres en `secondary`. Pas de dédup bloquante (créer une 2ᵉ page sur un mot-clé déjà couvert est un choix utilisateur) — mais le dialog AFFICHE un avertissement listant les keywords déjà couverts.
- Retour `{ created: number; skipped: string[] }` (skipped = termes déjà couverts en per-keyword).
- `createContentFromKeywordsAction` devient un alias délégant à `promoteKeywordsToContentAction(..., "per-keyword")` (compat tests/appels existants).
- `revalidatePath("/content/planning")`.

## C. Dialog partagé `PromoteToContentDialog`

`src/components/keywords/promote-content-dialog.tsx` (client) — props : `siteId`, `keywords: {id, term}[]` (la sélection), `open/onOpenChange`, `onDone()`.

- Radio : **« 1 page (groupe) »** — avec un `Select` (position `popper` !) du mot-clé **principal** parmi la sélection (défaut : premier) — vs **« N pages (1 par mot-clé) »**. Si 1 seul keyword sélectionné, pas de radio (mode unique, direct).
- Avertissement inline si certains termes sont déjà couverts par une page (info fournie par une action de check légère ou retournée par le mode per-keyword ; simple : `fetchCoveredKeywordIdsAction(siteId, ids)` → ids déjà `primary` d'un item).
- Confirme → action → toast `« N page(s) créée(s) »` → `onDone()` (clear sélection + refresh).

## D. Câblage des trois pages

1. **Réservoir** (`reservoir.tsx`) : « Vers Contenu » (BulkActionsBar) ouvre **le dialog** au lieu de l'appel direct. « Vers Recherche »/« Vers Planning » inchangés (déjà bons).
2. **Recherche** (`targeting-bar.tsx` + `research-container.tsx`) : la barre de sélection gagne **« → Planning »** (= `addKeywordsToStageAction(siteId, ids, "planning")`, toast, clear) et **« → Contenu… »** (le dialog). Le pont implicite « cibler planned → stage planning » reste.
3. **Chambre de Briefing** (`briefing-container.tsx`) : ajouter une **colonne checkbox** (+ select-all en tête) sur la table maison + une barre de sélection légère (count, « → Contenu… », Annuler). Le dialog reçoit les keywords sélectionnés. (Pas de DataTable refactor — la table maison reste.)

## E. Gérer après — section « Mots-clés » de la carte Contenu

Dans `content-card-sheet.tsx`, nouvelle section listant les mots-clés liés :

- **Lecture** : `getContentKeywordsAction(contentItemId)` → `{ keywordId, term, role }[]` — depuis `content_keywords`, **fallback** `[{primaryKeywordId, role:'primary'}]` (avec le term joint) si aucune ligne.
- **Changer le principal** : `setContentPrimaryKeywordAction(itemId, keywordId)` — swap des roles (l'ancien primary devient secondary) + sync `content_items.primaryKeywordId`. Matérialise les lignes si l'item était en fallback.
- **Retirer** : `removeContentKeywordAction(itemId, keywordId)` — interdit sur le `primary` (il faut d'abord désigner un autre principal) → retour `{ ok:false, error }`.
- **Ajouter** : `addContentKeywordsAction(itemId, keywordIds)` — role `secondary`, `onConflictDoNothing`. UI : input de recherche filtrant les keywords du site (réutilise `fetchKeywordsForSiteAction`, filtre client), exclut ceux déjà liés.
- UI : liste compacte — badge « Principal » sur le primary, menu par ligne (Définir principal / Retirer), input d'ajout en bas.

## F. Tests

- **DB (pattern content.test.ts)** : promote one-page (1 item, roles corrects, primaryKeywordId sync) ; promote per-keyword (N items + dédup skip + lignes primary) ; `getContentKeywords` fallback sans lignes ; set-primary swap + sync ; remove bloqué sur primary / ok sur secondary ; add dédup.
- **Existant à préserver** : `content.test.ts` (createContentFromKeywordsAction alias), tests réservoir/recherche.
- **Live (d3k, lecture statique)** : réservoir → sélectionner 2 → « Vers Contenu » → dialog → « 1 page » → 1 item dans /content/planning avec 2 mots-clés dans la carte ; briefing → sélection → « → Contenu… » ; recherche → « → Planning » fait apparaître le keyword dans la Chambre de Briefing ; carte : changer le principal, retirer un secondaire.

## Hors scope

- Entité `page_plans` complète (priorisation/programmation/cadence — CdC F10 complet) : la liaison est le sous-ensemble migrable.
- Retrait d'un stage (« dé-promouvoir ») ; sélection bulk multi-pages dans Contenu.
- Drag-and-drop de promotion (convention R2 — plus tard).
- Réordonnancement des secondaires.
