# Keyword Promotion Flows 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:** One consistent promotion UX on keyword selection — Réservoir → Recherche/Planning/Contenu, Recherche → Planning/Contenu, Chambre de Briefing → Contenu — with a group-vs-N-pages dialog and an editable keyword↔page link (`content_keywords`).

**Architecture:** A `content_keywords` join table (role primary/secondary, read-side fallback on `primaryKeywordId` so old items need no backfill) + a `promoteKeywordsToContentAction(siteId, ids, mode, primaryId?)` with two modes + a shared `PromoteToContentDialog` wired into the three pages + a "Mots-clés" manage section in the content card sheet. `content_items.primaryKeywordId` stays maintained in sync (ContentDot/kanban compat).

**Tech Stack:** Next.js 16 RSC + server actions, Drizzle + Postgres, bun:test, shadcn (Dialog/Select position="popper"/RadioGroup), sonner.

**Context the implementer must know:**
- DB tests: `bun run test src/lib/content/...` (test DB + `.env.local`); harness pattern = `src/lib/content/content.test.ts` (assertTestDb, mock next/cache, seed user/org/site, cleanup). Never `db:push`.
- Action files use `"use server"` — all exports must be async server actions.
- `SelectContent` MUST use `position="popper"` (item-aligned pushes options off-screen).
- Existing behavior to preserve: `createContentFromKeywordsAction` (per-keyword dedup) is called by `reservoir.tsx` and tested in `content.test.ts`.

---

### Task 1: `content_keywords` table + migration

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (after `contentItems`)
- Generate: `drizzle/0016_*.sql`

- [ ] **Step 1: Add the table**

```ts
/** Keyword↔page link (primary = the page's main keyword; mirrors content_items.primary_keyword_id). */
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] })],
);
```
(`pgTable`, `text`, `primaryKey` already imported.)

- [ ] **Step 2: Generate + inspect**

Run: `bun run db:generate` — the new migration must ONLY create `content_keywords` (composite PK + 2 cascade FKs).

- [ ] **Step 3: Apply dev + test**

Run: `bun run db:migrate && bun run db:migrate:test` — both succeed.

- [ ] **Step 4: Commit**

```bash
git add src/lib/db/domain-schema.ts drizzle/
git commit -m "feat(db): content_keywords link table (primary/secondary roles)"
```

---

### Task 2: Promotion action (two modes) + covered-check

**Files:**
- Modify: `src/lib/content/actions.ts`
- Test: extend `src/lib/content/content.test.ts`

- [ ] **Step 1: Write the failing tests** (append to `content.test.ts`; reuse its seed helpers — it already seeds SITE/ORG/USR and has a keyword-insert pattern; add a local `seedKw(id, term)` helper if none exists):

```ts
test("promote one-page: 1 item, roles primary/secondary, primaryKeywordId sync", async () => {
  await seedKw("k1", "alpha");
  await seedKw("k2", "bravo");
  await seedKw("k3", "charlie");
  const { promoteKeywordsToContentAction } = await import("./actions");
  const res = await promoteKeywordsToContentAction(SITE, ["k1", "k2", "k3"], "one-page", "k2");
  expect(res.created).toBe(1);
  const items = await db.select().from(contentItems).where(eq(contentItems.siteId, SITE));
  expect(items.length).toBe(1);
  expect(items[0].primaryKeywordId).toBe("k2");
  expect(items[0].title).toBe("bravo");
  const links = await db.select().from(contentKeywords)
    .where(eq(contentKeywords.contentItemId, items[0].id));
  expect(links.length).toBe(3);
  expect(links.find((l) => l.keywordId === "k2")?.role).toBe("primary");
  expect(links.filter((l) => l.role === "secondary").length).toBe(2);
});

test("promote per-keyword: N items + primary links + dedup skip", async () => {
  await seedKw("k1", "alpha");
  await seedKw("k2", "bravo");
  const { promoteKeywordsToContentAction } = await import("./actions");
  const r1 = await promoteKeywordsToContentAction(SITE, ["k1"], "per-keyword");
  expect(r1.created).toBe(1);
  const r2 = await promoteKeywordsToContentAction(SITE, ["k1", "k2"], "per-keyword");
  expect(r2.created).toBe(1); // k1 skipped (already primary of an item)
  expect(r2.skipped).toEqual(["alpha"]);
  const links = await db.select().from(contentKeywords);
  expect(links.filter((l) => l.role === "primary").length).toBe(2);
});

test("fetchCoveredKeywordIdsAction returns ids already primary of a site item", async () => {
  await seedKw("k1", "alpha");
  await seedKw("k2", "bravo");
  const { promoteKeywordsToContentAction, fetchCoveredKeywordIdsAction } = await import("./actions");
  await promoteKeywordsToContentAction(SITE, ["k1"], "per-keyword");
  expect(await fetchCoveredKeywordIdsAction(SITE, ["k1", "k2"])).toEqual(["k1"]);
});
```
Import `contentKeywords` from `@/lib/db/domain-schema` at the top of the test file.

- [ ] **Step 2: Run to verify FAIL** — `bun run test src/lib/content/content.test.ts` (missing exports).

- [ ] **Step 3: Implement.** In `src/lib/content/actions.ts` (import `contentKeywords`):

```ts
/** Promote keywords to contenu. "per-keyword": N items (skip ids already primary of an
 *  item); "one-page": 1 item, `primaryId` (default first) primary, rest secondary. */
export async function promoteKeywordsToContentAction(
  siteId: string,
  keywordIds: string[],
  mode: "per-keyword" | "one-page",
  primaryId?: string,
): Promise<{ created: number; skipped: string[] }> {
  if (keywordIds.length === 0) return { created: 0, skipped: [] };
  const kws = await db
    .select({ id: keywords.id, term: keywords.term, lang: keywords.lang, country: keywords.country })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, keywordIds)));

  if (mode === "one-page") {
    const primary = kws.find((k) => k.id === primaryId) ?? kws[0];
    if (!primary) return { created: 0, skipped: [] };
    const itemId = randomUUID();
    await db.insert(contentItems).values({
      id: itemId, siteId, title: primary.term, status: "idea" as ContentStatus,
      primaryKeywordId: primary.id, lang: primary.lang, country: primary.country,
    });
    await db.insert(contentKeywords).values(
      kws.map((k) => ({ contentItemId: itemId, keywordId: k.id,
        role: (k.id === primary.id ? "primary" : "secondary") as "primary" | "secondary" })),
    );
    revalidatePath("/content/planning");
    return { created: 1, skipped: [] };
  }

  const covered = new Set(
    (await db.select({ kid: contentItems.primaryKeywordId }).from(contentItems)
      .where(eq(contentItems.siteId, siteId))).map((r) => r.kid),
  );
  const toCreate = kws.filter((k) => !covered.has(k.id));
  const skipped = kws.filter((k) => covered.has(k.id)).map((k) => k.term);
  if (toCreate.length === 0) return { created: 0, skipped };
  const rows = toCreate.map((k) => ({ itemId: randomUUID(), k }));
  await db.insert(contentItems).values(
    rows.map(({ itemId, k }) => ({ id: itemId, siteId, title: k.term,
      status: "idea" as ContentStatus, primaryKeywordId: k.id, lang: k.lang, country: k.country })),
  );
  await db.insert(contentKeywords).values(
    rows.map(({ itemId, k }) => ({ contentItemId: itemId, keywordId: k.id, role: "primary" as const })),
  );
  revalidatePath("/content/planning");
  return { created: toCreate.length, skipped };
}

/** Keyword ids (among `ids`) already covered as primary of a site content item. */
export async function fetchCoveredKeywordIdsAction(
  siteId: string,
  ids: string[],
): Promise<string[]> {
  if (ids.length === 0) return [];
  const rows = await db
    .select({ kid: contentItems.primaryKeywordId })
    .from(contentItems)
    .where(and(eq(contentItems.siteId, siteId), inArray(contentItems.primaryKeywordId, ids)));
  return [...new Set(rows.map((r) => r.kid).filter((k): k is string => k !== null))];
}
```

Then REPLACE the body of `createContentFromKeywordsAction` with a delegation (keep export + signature):

```ts
export async function createContentFromKeywordsAction(
  siteId: string,
  keywordIds: string[],
): Promise<{ created: number }> {
  const { created } = await promoteKeywordsToContentAction(siteId, keywordIds, "per-keyword");
  return { created };
}
```

- [ ] **Step 4: Run tests** — `bun run test src/lib/content/content.test.ts` → all pass (new + existing). `npx tsc --noEmit` clean.

- [ ] **Step 5: Commit**

```bash
git add src/lib/content/actions.ts src/lib/content/content.test.ts
git commit -m "feat(content): promoteKeywordsToContentAction (one-page/per-keyword) + covered check"
```

---

### Task 3: Manage-after actions (get/set-primary/add/remove)

**Files:**
- Modify: `src/lib/content/actions.ts`
- Test: extend `src/lib/content/content.test.ts`

- [ ] **Step 1: Failing tests:**

```ts
test("getContentKeywordsAction falls back to primaryKeywordId when no link rows", async () => {
  await seedKw("k1", "alpha");
  // legacy item: primaryKeywordId set, no content_keywords rows
  await db.insert(contentItems).values({ id: "ci1", siteId: SITE, title: "alpha",
    status: "idea", primaryKeywordId: "k1", lang: "fr", country: "FR" });
  const { getContentKeywordsAction } = await import("./actions");
  expect(await getContentKeywordsAction("ci1")).toEqual([
    { keywordId: "k1", term: "alpha", role: "primary" },
  ]);
});

test("setContentPrimaryKeywordAction swaps roles and syncs primaryKeywordId", async () => {
  await seedKw("k1", "alpha"); await seedKw("k2", "bravo");
  const { promoteKeywordsToContentAction, setContentPrimaryKeywordAction, getContentKeywordsAction } =
    await import("./actions");
  await promoteKeywordsToContentAction(SITE, ["k1", "k2"], "one-page", "k1");
  const item = (await db.select().from(contentItems).where(eq(contentItems.siteId, SITE)))[0];
  await setContentPrimaryKeywordAction(item.id, "k2");
  const links = await getContentKeywordsAction(item.id);
  expect(links.find((l) => l.keywordId === "k2")?.role).toBe("primary");
  expect(links.find((l) => l.keywordId === "k1")?.role).toBe("secondary");
  const after = (await db.select().from(contentItems).where(eq(contentItems.id, item.id)))[0];
  expect(after.primaryKeywordId).toBe("k2");
});

test("removeContentKeywordAction blocks primary, removes secondary; add dedups", async () => {
  await seedKw("k1", "alpha"); await seedKw("k2", "bravo"); await seedKw("k3", "charlie");
  const { promoteKeywordsToContentAction, removeContentKeywordAction,
    addContentKeywordsAction, getContentKeywordsAction } = await import("./actions");
  await promoteKeywordsToContentAction(SITE, ["k1", "k2"], "one-page", "k1");
  const item = (await db.select().from(contentItems).where(eq(contentItems.siteId, SITE)))[0];
  expect((await removeContentKeywordAction(item.id, "k1")).ok).toBe(false); // primary blocked
  expect((await removeContentKeywordAction(item.id, "k2")).ok).toBe(true);
  await addContentKeywordsAction(item.id, ["k3", "k3"]);
  await addContentKeywordsAction(item.id, ["k3"]); // idempotent
  const links = await getContentKeywordsAction(item.id);
  expect(links.length).toBe(2); // k1 primary + k3 secondary
});
```

- [ ] **Step 2: Run to verify FAIL.**

- [ ] **Step 3: Implement** (in `actions.ts`):

```ts
export type ContentKeywordLink = {
  keywordId: string;
  term: string;
  role: "primary" | "secondary";
};

/** Linked keywords of an item; falls back to primaryKeywordId for pre-link items. */
export async function getContentKeywordsAction(
  contentItemId: string,
): Promise<ContentKeywordLink[]> {
  const rows = await db
    .select({ keywordId: contentKeywords.keywordId, term: keywords.term, role: contentKeywords.role })
    .from(contentKeywords)
    .innerJoin(keywords, eq(keywords.id, contentKeywords.keywordId))
    .where(eq(contentKeywords.contentItemId, contentItemId));
  if (rows.length > 0) {
    return rows.sort((a, b) => (a.role === "primary" ? -1 : b.role === "primary" ? 1 : 0));
  }
  const item = (await db.select().from(contentItems).where(eq(contentItems.id, contentItemId)))[0];
  if (!item?.primaryKeywordId) return [];
  const kw = (await db.select({ term: keywords.term }).from(keywords)
    .where(eq(keywords.id, item.primaryKeywordId)))[0];
  if (!kw) return [];
  return [{ keywordId: item.primaryKeywordId, term: kw.term, role: "primary" }];
}

/** Make `keywordId` the item's primary (old primary → secondary); syncs primaryKeywordId. */
export async function setContentPrimaryKeywordAction(
  contentItemId: string,
  keywordId: string,
): Promise<void> {
  const links = await getContentKeywordsAction(contentItemId); // materialized or fallback view
  await db.transaction(async (tx) => {
    // materialize: ensure every current link exists as a row, with the new roles
    await tx.delete(contentKeywords).where(eq(contentKeywords.contentItemId, contentItemId));
    const all = links.some((l) => l.keywordId === keywordId)
      ? links
      : [...links, { keywordId, term: "", role: "secondary" as const }];
    await tx.insert(contentKeywords).values(
      all.map((l) => ({ contentItemId, keywordId: l.keywordId,
        role: (l.keywordId === keywordId ? "primary" : "secondary") as "primary" | "secondary" })),
    );
    await tx.update(contentItems).set({ primaryKeywordId: keywordId })
      .where(eq(contentItems.id, contentItemId));
  });
  revalidatePath("/content/planning");
}

/** Remove a linked keyword; the primary is protected (set another primary first). */
export async function removeContentKeywordAction(
  contentItemId: string,
  keywordId: string,
): Promise<{ ok: boolean; error?: string }> {
  const item = (await db.select().from(contentItems).where(eq(contentItems.id, contentItemId)))[0];
  if (item?.primaryKeywordId === keywordId) {
    return { ok: false, error: "Définis d'abord un autre mot-clé principal." };
  }
  await db.delete(contentKeywords).where(and(
    eq(contentKeywords.contentItemId, contentItemId),
    eq(contentKeywords.keywordId, keywordId),
  ));
  revalidatePath("/content/planning");
  return { ok: true };
}

/** Link keywords as secondaries (idempotent). */
export async function addContentKeywordsAction(
  contentItemId: string,
  keywordIds: string[],
): Promise<void> {
  const unique = [...new Set(keywordIds)];
  if (unique.length === 0) return;
  await db.insert(contentKeywords)
    .values(unique.map((keywordId) => ({ contentItemId, keywordId, role: "secondary" as const })))
    .onConflictDoNothing();
  revalidatePath("/content/planning");
}
```

- [ ] **Step 4: Run tests + tsc** — all content tests pass; tsc clean.

- [ ] **Step 5: Commit**

```bash
git add src/lib/content/actions.ts src/lib/content/content.test.ts
git commit -m "feat(content): manage content_keywords (get w/ fallback, set-primary, add, remove)"
```

---

### Task 4: `PromoteToContentDialog` + wire Réservoir

**Files:**
- Create: `src/components/keywords/promote-content-dialog.tsx`
- Modify: `src/components/keywords/reservoir.tsx`

- [ ] **Step 1: Create the dialog** (client component):

```tsx
"use client";
// Dialog de promotion vers Contenu : « 1 page (groupe) » vs « N pages ».
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
  fetchCoveredKeywordIdsAction, promoteKeywordsToContentAction,
} from "@/lib/content/actions";

export function PromoteToContentDialog({
  siteId, keywords, open, onOpenChange, onDone,
}: {
  siteId: string;
  keywords: { id: string; term: string }[];
  open: boolean;
  onOpenChange: (v: boolean) => void;
  onDone: () => void;
}) {
  const single = keywords.length === 1;
  const [mode, setMode] = useState<"one-page" | "per-keyword">("one-page");
  const [primaryId, setPrimaryId] = useState<string>(keywords[0]?.id ?? "");
  const [covered, setCovered] = useState<string[]>([]);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    if (!open || keywords.length === 0) return;
    setPrimaryId(keywords[0].id);
    setMode(single ? "per-keyword" : "one-page");
    void fetchCoveredKeywordIdsAction(siteId, keywords.map((k) => k.id)).then(setCovered);
  }, [open, siteId, keywords, single]);

  const coveredTerms = keywords.filter((k) => covered.includes(k.id)).map((k) => k.term);

  async function confirm() {
    setBusy(true);
    try {
      const res = await promoteKeywordsToContentAction(
        siteId, keywords.map((k) => k.id), single ? "per-keyword" : mode, primaryId,
      );
      toast.success(`${res.created} page(s) créée(s)${res.skipped.length ? ` — ${res.skipped.length} déjà couvert(s)` : ""}`);
      onOpenChange(false);
      onDone();
    } catch {
      toast.error("Échec de la promotion vers Contenu");
    } finally {
      setBusy(false);
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Vers Contenu</DialogTitle>
          <DialogDescription>
            {keywords.length} mot(s)-clé(s) sélectionné(s).
          </DialogDescription>
        </DialogHeader>
        {!single ? (
          <RadioGroup value={mode} onValueChange={(v) => setMode(v as typeof mode)} className="gap-3">
            <div className="flex items-start gap-2">
              <RadioGroupItem value="one-page" id="pm-one" />
              <div className="grid gap-1">
                <Label htmlFor="pm-one">1 page (groupe)</Label>
                <p className="text-muted-foreground text-xs">
                  Une seule page : 1 principal + {keywords.length - 1} secondaire(s).
                </p>
                {mode === "one-page" ? (
                  <Select value={primaryId} onValueChange={setPrimaryId}>
                    <SelectTrigger size="sm" aria-label="Mot-clé principal">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent position="popper">
                      {keywords.map((k) => (
                        <SelectItem key={k.id} value={k.id}>{k.term}</SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                ) : null}
              </div>
            </div>
            <div className="flex items-start gap-2">
              <RadioGroupItem value="per-keyword" id="pm-n" />
              <div className="grid gap-1">
                <Label htmlFor="pm-n">{keywords.length} pages (1 par mot-clé)</Label>
                <p className="text-muted-foreground text-xs">
                  Les mots-clés déjà couverts par une page sont ignorés.
                </p>
              </div>
            </div>
          </RadioGroup>
        ) : null}
        {coveredTerms.length > 0 ? (
          <p className="text-amber-600 text-xs dark:text-amber-500">
            Déjà couvert(s) par une page : {coveredTerms.join(", ")}
          </p>
        ) : null}
        <DialogFooter>
          <Button variant="ghost" onClick={() => onOpenChange(false)}>Annuler</Button>
          <Button onClick={confirm} disabled={busy || keywords.length === 0}>
            {busy ? "Création…" : "Créer"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```
If `radio-group` isn't in `src/components/ui/`, add it via the shadcn CLI (`bunx shadcn@latest add radio-group` — check `components.json` first; the project registry is radix-based).

- [ ] **Step 2: Wire Réservoir.** In `reservoir.tsx`: add state `const [promoteOpen, setPromoteOpen] = useState(false);` and a memo of selected keyword objects `{id, term}`; change `onSendToContent={sendSelectedToContent}` → `onSendToContent={() => setPromoteOpen(true)}`; render `<PromoteToContentDialog siteId={activeSiteId} keywords={selectedKeywordObjs} open={promoteOpen} onOpenChange={setPromoteOpen} onDone={() => { setRowSelection({}); refresh(); }} />`. Delete the now-unused `sendSelectedToContent` (and its `createContentFromKeywordsAction` import if unused elsewhere).

- [ ] **Step 3: Verify** — `npx tsc --noEmit` clean; `bun run test src/lib/content/` pass.

- [ ] **Step 4: Commit**

```bash
git add src/components/keywords/promote-content-dialog.tsx src/components/keywords/reservoir.tsx src/components/ui/
git commit -m "feat(keywords): PromoteToContentDialog (1 page groupe / N pages) wired into réservoir"
```

---

### Task 5: Recherche — « → Planning » + « → Contenu… »

**Files:**
- Modify: `src/components/keywords/research/targeting-bar.tsx`
- Modify: `src/components/keywords/research/research-container.tsx`

- [ ] **Step 1: TargetingBar props.** Add `onSendToPlanning: () => void;` and `onSendToContent: () => void;` to the props interface; render two buttons next to « + Shortlist » (same `size="sm" variant="outline"` style):

```tsx
<Button size="sm" variant="outline" onClick={onSendToPlanning}>→ Planning</Button>
<Button size="sm" variant="outline" onClick={onSendToContent}>→ Contenu…</Button>
```

- [ ] **Step 2: Container wiring.** In `research-container.tsx`:
  - import `addKeywordsToStageAction` (from `@/lib/keywords/actions` — already imported there? check) and `PromoteToContentDialog`;
  - state `const [promoteOpen, setPromoteOpen] = useState(false);`
  - `const selectedKeywordObjs = useMemo(() => rows.filter((k) => rowSelection[k.id]).map((k) => ({ id: k.id, term: k.term })), [rows, rowSelection]);`
  - handler:
```tsx
async function sendSelectedToPlanning() {
  const ids = Object.entries(rowSelection).filter(([, v]) => v).map(([id]) => id);
  if (!ids.length) return;
  try {
    await addKeywordsToStageAction(activeSiteId, ids, "planning");
    toast.success(`${ids.length} mot(s)-clé(s) envoyé(s) en Planning`);
    setRowSelection({});
  } catch {
    toast.error("Échec de l'envoi en Planning");
  }
}
```
  - pass `onSendToPlanning={sendSelectedToPlanning}` and `onSendToContent={() => setPromoteOpen(true)}` to `<TargetingBar />`; render the dialog with `onDone={() => setRowSelection({})}`.
  - The staged page (`recherche-staged`) renders the same container — props flow automatically; no extra change.

- [ ] **Step 3: Verify** — tsc clean; `bun run test src/lib/keywords/` pass.

- [ ] **Step 4: Commit**

```bash
git add src/components/keywords/research/
git commit -m "feat(keywords): recherche selection bar gains → Planning and → Contenu"
```

---

### Task 6: Chambre de Briefing — sélection + « → Contenu… »

**Files:**
- Modify: `src/components/keywords/planning/briefing-container.tsx`

- [ ] **Step 1: Add selection state + checkbox column.** The table is hand-rolled (`<table>`); add:
  - `const [sel, setSel] = useState<Record<string, boolean>>({});`
  - header cell: a `Checkbox` (shadcn, already used in the app) checked when all rows selected, toggling all (`rows.every((r) => sel[r.keyword.id])`);
  - per-row first cell: `<Checkbox checked={!!sel[row.keyword.id]} onCheckedChange={(v) => setSel((s) => ({ ...s, [row.keyword.id]: v === true }))} onClick={(e) => e.stopPropagation()} />` (rows open the drawer on click — stop propagation);
  - adjust the empty-state `colSpan` (6 → 7).

- [ ] **Step 2: Selection bar + dialog.** Above the table, when `count > 0`:

```tsx
const selectedIds = Object.entries(sel).filter(([, v]) => v).map(([id]) => id);
const selectedKeywordObjs = rows
  .filter((r) => sel[r.keyword.id])
  .map((r) => ({ id: r.keyword.id, term: r.keyword.term }));
```

```tsx
{selectedIds.length > 0 ? (
  <div className="flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-sm">
    <span className="font-medium">{selectedIds.length} sélectionné(s)</span>
    <Button size="sm" variant="outline" onClick={() => setPromoteOpen(true)}>
      → Contenu…
    </Button>
    <Button size="sm" variant="ghost" onClick={() => setSel({})}>Annuler</Button>
  </div>
) : null}
<PromoteToContentDialog
  siteId={siteId}
  keywords={selectedKeywordObjs}
  open={promoteOpen}
  onOpenChange={setPromoteOpen}
  onDone={() => { setSel({}); router.refresh(); }}
/>
```
The container needs `siteId` — check its props; if absent, thread it from the page RSC (`src/app/(app)/keywords/planning/page.tsx` already resolves the active site for its data fetch — pass it down).

- [ ] **Step 3: Verify** — tsc clean.

- [ ] **Step 4: Commit**

```bash
git add src/components/keywords/planning/ "src/app/(app)/keywords/planning/page.tsx"
git commit -m "feat(keywords): briefing gains bulk selection + → Contenu promotion"
```

---

### Task 7: Carte Contenu — section « Mots-clés »

**Files:**
- Create: `src/components/content/content-keywords-panel.tsx`
- Modify: `src/components/content/content-card-sheet.tsx`

- [ ] **Step 1: Create the panel** (client):

```tsx
"use client";
// Mots-clés liés à une page : principal (badge), définir principal, retirer, ajouter.
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  addContentKeywordsAction, type ContentKeywordLink, getContentKeywordsAction,
  removeContentKeywordAction, setContentPrimaryKeywordAction,
} from "@/lib/content/actions";
import { fetchKeywordsForSiteAction } from "@/lib/keywords/actions";

export function ContentKeywordsPanel({
  contentItemId, siteId,
}: { contentItemId: string; siteId: string }) {
  const [links, setLinks] = useState<ContentKeywordLink[]>([]);
  const [query, setQuery] = useState("");
  const [candidates, setCandidates] = useState<{ id: string; term: string }[]>([]);

  async function reload() {
    setLinks(await getContentKeywordsAction(contentItemId));
  }
  useEffect(() => { void reload(); /* eslint-disable-line */ }, [contentItemId]);

  useEffect(() => {
    if (query.trim().length < 2) { setCandidates([]); return; }
    let on = true;
    void fetchKeywordsForSiteAction(siteId).then((all) => {
      if (!on) return;
      const linked = new Set(links.map((l) => l.keywordId));
      setCandidates(
        all.filter((k) => !linked.has(k.id) && k.term.toLowerCase().includes(query.toLowerCase()))
          .slice(0, 8).map((k) => ({ id: k.id, term: k.term })),
      );
    });
    return () => { on = false; };
  }, [query, siteId, links]);

  async function setPrimary(id: string) {
    await setContentPrimaryKeywordAction(contentItemId, id);
    await reload();
  }
  async function remove(id: string) {
    const res = await removeContentKeywordAction(contentItemId, id);
    if (!res.ok) toast.error(res.error ?? "Impossible");
    await reload();
  }
  async function add(id: string) {
    await addContentKeywordsAction(contentItemId, [id]);
    setQuery("");
    await reload();
  }

  return (
    <div className="rounded-lg border p-3">
      <div className="mb-2 font-medium text-sm">Mots-clés</div>
      <ul className="grid gap-1">
        {links.map((l) => (
          <li key={l.keywordId} className="flex items-center gap-2 text-sm">
            <span className="flex-1 truncate">{l.term}</span>
            {l.role === "primary" ? (
              <Badge variant="secondary">Principal</Badge>
            ) : (
              <>
                <Button size="sm" variant="ghost" onClick={() => setPrimary(l.keywordId)}>
                  Définir principal
                </Button>
                <Button size="sm" variant="ghost" className="text-destructive"
                  onClick={() => remove(l.keywordId)}>
                  Retirer
                </Button>
              </>
            )}
          </li>
        ))}
        {links.length === 0 ? (
          <li className="text-muted-foreground text-sm">Aucun mot-clé lié.</li>
        ) : null}
      </ul>
      <div className="mt-2 grid gap-1">
        <Input value={query} onChange={(e) => setQuery(e.target.value)}
          placeholder="Ajouter un mot-clé…" aria-label="Ajouter un mot-clé" />
        {candidates.map((c) => (
          <Button key={c.id} size="sm" variant="ghost" className="justify-start"
            onClick={() => add(c.id)}>
            + {c.term}
          </Button>
        ))}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Mount in the card sheet.** In `content-card-sheet.tsx`, inside the scrollable grid (next to the Brief block ~line 195), add `<ContentKeywordsPanel contentItemId={item.id} siteId={item.siteId} />` — read the file first: use the actual item prop name/shape; if `siteId` isn't on the item prop, thread it from the parent (board/table get it from the page).

- [ ] **Step 3: Verify** — tsc clean; `bun run test src/lib/content/ src/lib/keywords/` pass.

- [ ] **Step 4: Commit**

```bash
git add src/components/content/
git commit -m "feat(content): Mots-clés panel on the card (primary swap, add/remove)"
```

---

### Task 8: Live verify + roadmap

- [ ] **Step 1: Gates** — `npx tsc --noEmit` ; `bun run test src/lib/content/ src/lib/keywords/ src/lib/backlink/` (per-dir green).
- [ ] **Step 2: Live (d3k, static reads)** — réservoir: select 2 → « Vers Contenu » → dialog → « 1 page » avec principal choisi → vérifier en DB (`content_items` + `content_keywords` roles) et l'item dans /content/planning ; carte : section Mots-clés liste 2, « Définir principal » swap (vérif DB), retrait bloqué sur principal ; recherche : « → Planning » → le keyword apparaît dans la Chambre de Briefing ; briefing : sélection → « → Contenu… ». Nettoyer les items de test créés.
- [ ] **Step 3: Roadmap** — marquer ✅ « Système de promotion » dans la section Keywords (réservoir→recherche/planning existants unifiés ; recherche→planning explicite ; briefing→contenu ; dialog groupe/N pages ; `content_keywords` gérable après). Noter le backfill optionnel prod (`INSERT INTO content_keywords … SELECT … FROM content_items WHERE primary_keyword_id IS NOT NULL ON CONFLICT DO NOTHING`).
- [ ] **Step 4: Commit**

```bash
git add docs/project/roadmap.md
git commit -m "docs(roadmap): keyword promotion flows shipped (unified selection → contenu)"
```

---

## Notes for the executor
- Order: 1 → 2 → 3 → (4, 5, 6 touch different components but 5/6 depend on the dialog from 4 — run 4 before 5 and 6) → 7 → 8. Sequential dispatch.
- The dialog (Task 4) is the shared piece; Tasks 5/6 import it.
- `radio-group` may need `bunx shadcn@latest add radio-group` — use the shadcn skill conventions if so.
- Briefing page (Task 6): verify how `siteId` reaches the container before coding (read the page RSC).
