# Data-to-Content Flow — 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:** Build the expert editorial pipeline — target a keyword×country in Recherche, enrich it in a rebuilt Planning "Chambre de Briefing", validate it in a new full-screen Dispatch view, and transmute it into a Content Kanban card.

**Architecture:** Postgres + Drizzle stays the transactional source of truth (DuckDB/Hono analytical layer is deferred). The pipeline is driven by a hybrid state model: `keyword.stages` contains `planning` (page gate) and `keyword_targets(keyword,country)` carries `candidate/planned/live`. Readiness reuses the existing `buildContentAnalysis`. Enrichment reuses the existing `serp-panel`/`brief-panel`. Each (keyword × targeted country) becomes one `ContentItem`.

**Tech Stack:** Next.js 16 (RSC + Server Actions), Drizzle/Postgres, `useOptimistic`, sonner toasts, nuqs URL-state, bun test, Playwright `/debug` harness.

**Spec:** `docs/superpowers/specs/2026-06-09-data-to-content-flow-design.md`

---

## Conventions for this plan

- **Pure-logic tests** run via `bun run test <file>` (react-server condition is set by the `test` script).
- **DB-action verification** uses a throwaway round-trip script run with `bun --env-file=.env.local --conditions=react-server run <file>` (the project's `db:migrate:test` is currently broken on an unrelated `.env.test` Zod error, so the DB **test suite** can't run; the round-trip script is the proven verification path — see `backlink-nav-deadlock` memory). Delete the script after.
- **Nav verification** uses a temporary `/debug` route tree + Playwright (`mcp__next-devtools__browser_eval`), then removal — the pattern used to validate the RSC migration.
- ORG used for manual harness checks: `8307eb7a-dbac-4cf7-97ef-bf05c86433d1`, its active site `ac5b0f4a-c760-459d-80e2-43786951381f`.
- Every code mutation on the client gets a toast on failure; no read server action in a mount `useEffect` (guard with a `loadedSite`/key ref — a read action on mount deadlocks navigation).

## File Structure

**Modify**
- `src/lib/keywords/actions.ts` — add the `planned ⇒ planning stage` bridge to `setKeywordTargetAction` (exported also as `updateKeywordTargetAction`).
- `src/lib/keywords/queries.ts` — add `getPlanningKeywords(siteId)`.
- `src/lib/content/actions.ts` — add `createContentItemAction`.
- `src/components/app-sidebar.tsx` — re-add the `Planning` keywords nav entry.

**Create**
- `src/app/(app)/keywords/planning/page.tsx` — RSC page (Chambre de Briefing).
- `src/components/keywords/planning/briefing-container.tsx` — table + drawer client container (Layout A).
- `src/app/(app)/keywords/dispatch/[id]/page.tsx` — RSC page (Dispatch).
- `src/components/keywords/dispatch/dispatch-view.tsx` — full-screen view (Layout A).
- Test/verify scripts under `/tmp` or project root (deleted after use).

---

## Task 1: `planned ⇒ planning stage` bridge

**Files:**
- Modify: `src/lib/keywords/actions.ts` (around `setKeywordTargetAction`, lines 47–70)

Goal: when a target cell is set to `planned`, the keyword also gains the `planning` stage so it surfaces in the Chambre de Briefing. Self-contained (by keyword id, no siteId needed). Keep the existing name `setKeywordTargetAction` (the matrix calls it) and add an `updateKeywordTargetAction` alias matching the spec.

- [ ] **Step 1: Write the failing round-trip test**

Create `./_t1.ts`:

```ts
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import { setKeywordTargetAction, updateKeywordTargetAction } from "@/lib/keywords/actions";

const SITE = "ac5b0f4a-c760-459d-80e2-43786951381f";
const [k] = await db.select({ id: keywords.id }).from(keywords).where(eq(keywords.siteId, SITE)).limit(1);
// reset
await db.update(keywords).set({ stages: [] }).where(eq(keywords.id, k.id));
await setKeywordTargetAction(k.id, "US", "candidate");
let row = (await db.select({ stages: keywords.stages }).from(keywords).where(eq(keywords.id, k.id)))[0];
console.log("RESULT candidate adds no stage:", JSON.stringify(row.stages) === "[]");
await updateKeywordTargetAction(k.id, "US", "planned");
row = (await db.select({ stages: keywords.stages }).from(keywords).where(eq(keywords.id, k.id)))[0];
console.log("RESULT planned adds planning stage:", row.stages.includes("planning"));
// cleanup
await setKeywordTargetAction(k.id, "US", null);
await db.update(keywords).set({ stages: [] }).where(eq(keywords.id, k.id));
process.exit(0);
```

- [ ] **Step 2: Run it — expect failure**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t1.ts`
Expected: `updateKeywordTargetAction` import fails (not exported) OR "planned adds planning stage: false".

- [ ] **Step 3: Implement the bridge**

In `src/lib/keywords/actions.ts`, replace the body of `setKeywordTargetAction` so that a `planned` status also unions the `planning` stage, and add the alias. (Reuse the same `sql` array-union expression as `addKeywordsToStageAction`.)

```ts
export async function setKeywordTargetAction(
  keywordIdValue: string,
  country: string,
  status: TargetStatus | null,
): Promise<void> {
  if (status === null) {
    await db
      .delete(keywordTargets)
      .where(
        and(
          eq(keywordTargets.keywordId, keywordIdValue),
          eq(keywordTargets.country, country),
        ),
      );
    return;
  }
  await db
    .insert(keywordTargets)
    .values({ keywordId: keywordIdValue, country, status })
    .onConflictDoUpdate({
      target: [keywordTargets.keywordId, keywordTargets.country],
      set: { status },
    });
  // Bridge: committing a country to production surfaces the keyword in the
  // Chambre de Briefing (stage gate). Idempotent array union by keyword id.
  if (status === "planned") {
    await db
      .update(keywords)
      .set({
        stages: sql`(select array_agg(distinct s) from unnest(${keywords.stages} || array['planning']::text[]) as s)`,
      })
      .where(eq(keywords.id, keywordIdValue));
  }
}

/** Spec alias for setKeywordTargetAction (one action, two effects). */
export const updateKeywordTargetAction = setKeywordTargetAction;
```

- [ ] **Step 4: Run it — expect pass**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t1.ts`
Expected: both `RESULT ...: true`.

- [ ] **Step 5: Clean up + commit**

```bash
rm -f ./_t1.ts
npx tsc --noEmit && npx @biomejs/biome check src/lib/keywords/actions.ts
git add src/lib/keywords/actions.ts
git commit -m "feat(keywords): planned target adds the planning stage (briefing gate)"
```

---

## Task 2: `getPlanningKeywords(siteId)` query

**Files:**
- Modify: `src/lib/keywords/queries.ts` (add after `getTargetsForSite`)

Returns the Chambre de Briefing rows: keywords carrying the `planning` stage, each with its readiness `ContentAnalysis` (computed for the keyword's native country) and its per-country targets. Reuses `buildContentAnalysis` (which already reads `serp`/`briefs`).

- [ ] **Step 1: Write the failing test**

Create `./_t2.ts`:

```ts
import { getPlanningKeywords } from "@/lib/keywords/queries";
const SITE = "ac5b0f4a-c760-459d-80e2-43786951381f";
const rows = await getPlanningKeywords(SITE);
console.log("RESULT returns array:", Array.isArray(rows));
console.log("RESULT each row has keyword+analysis+targets:",
  rows.every((r) => r.keyword && r.analysis && r.targets));
console.log("RESULT all rows are stage=planning:",
  rows.every((r) => r.keyword.stages.includes("planning")));
process.exit(0);
```

- [ ] **Step 2: Run it — expect failure**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t2.ts`
Expected: import fails (`getPlanningKeywords` not exported).

- [ ] **Step 3: Implement the query**

In `src/lib/keywords/queries.ts` add the import and function. (`getKeywordsForSite`'s `toKeyword` mapper is in this file; reuse the same row→Keyword shape. `buildContentAnalysis` lives in `@/lib/content/analysis`.)

```ts
import { buildContentAnalysis } from "@/lib/content/analysis";
import type { ContentAnalysis } from "@/lib/content/types";
import type { TargetStatus } from "./targets";

export interface PlanningRow {
  keyword: Keyword;
  analysis: ContentAnalysis;
  targets: Partial<Record<string, TargetStatus>>;
}

/** Keywords at stage `planning`, with readiness + per-country targets. */
export async function getPlanningKeywords(siteId: string): Promise<PlanningRow[]> {
  const rows = await db
    .select()
    .from(keywords)
    .where(
      and(
        eq(keywords.siteId, siteId),
        sql`'planning' = any(${keywords.stages})`,
      ),
    )
    .orderBy(asc(keywords.term));
  const targetsBySite = await getTargetsForSite(siteId);
  return rows.map((r) => {
    const kw = toKeyword(r);
    return {
      keyword: kw,
      analysis: buildContentAnalysis(r.serp, r.briefs, kw.locale.country),
      targets: targetsBySite[kw.id] ?? {},
    };
  });
}
```

Add `and` and `sql` to the `drizzle-orm` import at the top of the file if not present.

- [ ] **Step 4: Run it — expect pass**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t2.ts`
Expected: three `RESULT ...: true` (rows may be `[]` if no planning keywords yet — still passes the shape checks).

- [ ] **Step 5: Clean up + commit**

```bash
rm -f ./_t2.ts
npx tsc --noEmit && npx @biomejs/biome check src/lib/keywords/queries.ts
git add src/lib/keywords/queries.ts
git commit -m "feat(keywords): getPlanningKeywords query (briefing backlog + readiness)"
```

---

## Task 3: `createContentItemAction` (transmutation)

**Files:**
- Modify: `src/lib/content/actions.ts` (add next to `createContentFromKeywordsAction`, lines 59–96)

Creates ONE `ContentItem` (`status: "brief"`) for a (keyword, targeted country), then promotes that target `planned → live`. The keyword's `lang` is read from the keyword row; `country` is the dispatched country (NOT `keyword.country`).

- [ ] **Step 1: Write the failing round-trip test**

Create `./_t3.ts`:

```ts
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { contentItems, keywords, keywordTargets } from "@/lib/db/domain-schema";
import { createContentItemAction } from "@/lib/content/actions";

const SITE = "ac5b0f4a-c760-459d-80e2-43786951381f";
const [k] = await db.select({ id: keywords.id }).from(keywords).where(eq(keywords.siteId, SITE)).limit(1);
await db.insert(keywordTargets).values({ keywordId: k.id, country: "GB", status: "planned" })
  .onConflictDoUpdate({ target: [keywordTargets.keywordId, keywordTargets.country], set: { status: "planned" } });
const res = await createContentItemAction(k.id, "GB", { title: "Test article", dueDate: "2026-07-01", assignee: "alice" });
const ci = (await db.select().from(contentItems).where(eq(contentItems.id, res.id)))[0];
console.log("RESULT content item brief+GB+title:", ci.status === "brief" && ci.country === "GB" && ci.title === "Test article" && ci.primaryKeywordId === k.id);
const t = (await db.select({ s: keywordTargets.status }).from(keywordTargets)
  .where(and(eq(keywordTargets.keywordId, k.id), eq(keywordTargets.country, "GB"))))[0];
console.log("RESULT target promoted to live:", t.s === "live");
// cleanup
await db.delete(contentItems).where(eq(contentItems.id, res.id));
await db.delete(keywordTargets).where(and(eq(keywordTargets.keywordId, k.id), eq(keywordTargets.country, "GB")));
process.exit(0);
```

- [ ] **Step 2: Run it — expect failure**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t3.ts`
Expected: import fails (`createContentItemAction` not exported).

- [ ] **Step 3: Implement the action**

In `src/lib/content/actions.ts` (it already imports `randomUUID` from `node:crypto` and `db`, `contentItems`, `keywords`; add `keywordTargets` to the schema import and `and` to drizzle import):

```ts
export async function createContentItemAction(
  keywordId: string,
  country: string,
  payload: { title: string; dueDate: string | null; assignee: string | null },
): Promise<{ id: string }> {
  const [kw] = await db
    .select({ siteId: keywords.siteId, lang: keywords.lang, term: keywords.term })
    .from(keywords)
    .where(eq(keywords.id, keywordId))
    .limit(1);
  if (!kw) throw new Error("Keyword not found");
  const id = randomUUID();
  await db.insert(contentItems).values({
    id,
    siteId: kw.siteId,
    title: payload.title.trim() || kw.term,
    status: "brief",
    primaryKeywordId: keywordId,
    lang: kw.lang,
    country,
    assignee: payload.assignee,
    dueDate: payload.dueDate,
  });
  // Promote the targeted country to "live" (a deliverable now exists for it).
  await db
    .update(keywordTargets)
    .set({ status: "live" })
    .where(
      and(
        eq(keywordTargets.keywordId, keywordId),
        eq(keywordTargets.country, country),
      ),
    );
  return { id };
}
```

> Verify the exact `contentItems` insertable columns against the schema (`src/lib/db/domain-schema.ts`): `createdAt` likely has `defaultNow()`; omit it. If `contentItems` lacks `lang`/`country`/`assignee`/`dueDate` columns, match the names used by `createContentFromKeywordsAction` (lines 59–96) verbatim.

- [ ] **Step 4: Run it — expect pass**

Run: `bun --env-file=.env.local --conditions=react-server run ./_t3.ts`
Expected: both `RESULT ...: true`.

- [ ] **Step 5: Clean up + commit**

```bash
rm -f ./_t3.ts
npx tsc --noEmit && npx @biomejs/biome check src/lib/content/actions.ts
git add src/lib/content/actions.ts
git commit -m "feat(content): createContentItemAction (dispatch → content transmutation)"
```

---

## Task 4: Chambre de Briefing page (`/keywords/planning`)

**Files:**
- Create: `src/app/(app)/keywords/planning/page.tsx`
- Create: `src/components/keywords/planning/briefing-container.tsx`
- Modify: `src/components/app-sidebar.tsx` (re-add the nav entry)

Layout A: table of planning keywords (term · country flags · 3 readiness dots) + a right drawer that reuses `SerpPanel` and `BriefPanel` on the selected keyword, plus a "Préparer l'article" link to Dispatch.

- [ ] **Step 1: RSC page**

Create `src/app/(app)/keywords/planning/page.tsx` (mirror the existing `keywords/recherche/page.tsx` RSC pattern):

```tsx
import { DashboardHeader } from "@/components/dashboard-header";
import { BriefingContainer } from "@/components/keywords/planning/briefing-container";
import {
  Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { getActiveOrgId } from "@/lib/active-org";
import { getPlanningKeywords } from "@/lib/keywords/queries";
import { getActiveSiteId } from "@/lib/sites/active-site";
import { getSitesForOrg } from "@/lib/sites/queries";

export default async function Page() {
  const sites = await getSitesForOrg(await getActiveOrgId());
  const siteId = await getActiveSiteId(sites.map((s) => s.id));
  const rows = siteId ? await getPlanningKeywords(siteId) : [];
  return (
    <>
      <DashboardHeader>
        <Breadcrumb><BreadcrumbList>
          <BreadcrumbItem>Keywords</BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem><BreadcrumbPage>Planning</BreadcrumbPage></BreadcrumbItem>
        </BreadcrumbList></Breadcrumb>
      </DashboardHeader>
      <BriefingContainer initialRows={rows} initialSiteId={siteId} />
    </>
  );
}
```

- [ ] **Step 2: Container (table + drawer)**

Create `src/components/keywords/planning/briefing-container.tsx`. It seeds state from props (no mount refetch — guard with a `loadedSite` ref like the migrated containers), renders a table, and a drawer (`Sheet`) that reuses the enrichment panels. Reuse:
- `ContentDot` (`@/components/content/content-dot`) — props `{ state, tooltip }`.
- `SerpPanel` (`@/components/content/serp-panel`) — props `{ keywordId, snapshot, onImported }`; `snapshot = keyword.serp[keyword.locale.country] ?? null`.
- `BriefPanel` (`@/components/keywords/brief-panel`) — props `{ briefs }` = `keyword.briefs`.
- `ImportBriefDialog` if present in the Recherche sheet (grep `importKeywordBriefAction` usage) for brief upload.

```tsx
"use client";
import Link from "next/link";
import { useRef, useState } from "react";
import { ContentDot } from "@/components/content/content-dot";
import { SerpPanel } from "@/components/content/serp-panel";
import { BriefPanel } from "@/components/keywords/brief-panel";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import type { PlanningRow } from "@/lib/keywords/queries";
import { useActiveSite } from "@/lib/keywords/site-context";

const FLAG: Record<string, string> = { US: "🇺🇸", GB: "🇬🇧", CA: "🇨🇦", AU: "🇦🇺" };

export function BriefingContainer({
  initialRows, initialSiteId,
}: { initialRows: PlanningRow[]; initialSiteId: string }) {
  const { activeSiteId } = useActiveSite();
  const loadedSite = useRef(initialSiteId);
  const [rows] = useState(initialRows); // refetch-on-site-change can be added later; mount never refetches
  const [open, setOpen] = useState<PlanningRow | null>(null);
  // NOTE: keep `activeSiteId`/`loadedSite` for a future site-switch refetch; do NOT fetch on mount.
  void activeSiteId; void loadedSite;

  return (
    <div className="space-y-3 p-4">
      <h1 className="font-semibold text-lg">Chambre de Briefing</h1>
      <div className="overflow-hidden rounded-md border">
        <table className="w-full text-sm">
          <thead><tr className="border-b text-left text-muted-foreground text-xs">
            <th className="p-2">Terme</th><th className="p-2">Pays</th>
            <th className="p-2 text-center">SERP</th><th className="p-2 text-center">On-page</th>
            <th className="p-2 text-center">Brief</th><th className="p-2" />
          </tr></thead>
          <tbody>
            {rows.length === 0 ? (
              <tr><td colSpan={6} className="p-6 text-center text-muted-foreground">
                Aucun mot-clé planifié. Cible un pays en « Planifié » dans Recherche.
              </td></tr>
            ) : rows.map((r) => {
              const ready = r.analysis.serp.state === "ok" && r.analysis.onpage.state === "ok" && r.analysis.brief.state === "ok";
              return (
                <tr key={r.keyword.id} className="cursor-pointer border-b hover:bg-muted/40" onClick={() => setOpen(r)}>
                  <td className="p-2 font-medium">{r.keyword.term}</td>
                  <td className="p-2">{Object.keys(r.targets).map((c) => FLAG[c] ?? c).join(" ")}</td>
                  <td className="p-2 text-center"><ContentDot state={r.analysis.serp.state === "ok" ? "ok" : "none"} tooltip="SERP" /></td>
                  <td className="p-2 text-center"><ContentDot state={r.analysis.onpage.state} tooltip="On-page" /></td>
                  <td className="p-2 text-center"><ContentDot state={r.analysis.brief.state === "ok" ? "ok" : r.analysis.brief.state === "none" ? "none" : "partial"} tooltip="Brief" /></td>
                  <td className="p-2 text-right">
                    {ready ? (
                      <Button asChild size="sm" onClick={(e) => e.stopPropagation()}>
                        <Link href={`/keywords/dispatch/${r.keyword.id}`}>Préparer l'article →</Link>
                      </Button>
                    ) : null}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <Sheet open={!!open} onOpenChange={(o) => !o && setOpen(null)}>
        <SheetContent side="right" className="w-[min(680px,90vw)] overflow-y-auto p-4">
          {open ? (
            <div className="space-y-4">
              <h2 className="font-semibold text-base">{open.keyword.term}</h2>
              <SerpPanel
                keywordId={open.keyword.id}
                snapshot={open.keyword.serp?.[open.keyword.locale.country] ?? null}
                onImported={() => location.reload()}
              />
              <BriefPanel briefs={open.keyword.briefs} />
            </div>
          ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}
```

> `keyword.serp` is `Record<locale, SerpSnapshot>`. Confirm the key used elsewhere (grep `\.serp\[` in `src/lib/content/queries.ts`) and match it for `snapshot`. If the Recherche sheet already wraps SERP+Brief import in a single component, prefer reusing that whole component in the drawer instead of `SerpPanel`+`BriefPanel` separately.

- [ ] **Step 3: Re-add the nav entry**

In `src/components/app-sidebar.tsx`, under the Keywords `items`, re-add after `Recherche`:

```tsx
        { title: "Recherche", url: "/keywords/recherche" },
        { title: "Planning", url: "/keywords/planning" },
      ],
```

- [ ] **Step 4: Verify it compiles + renders (harness)**

```bash
npx tsc --noEmit && npx @biomejs/biome check "src/app/(app)/keywords/planning/page.tsx" src/components/keywords/planning/briefing-container.tsx src/components/app-sidebar.tsx
```
Then (optional but recommended) create a temporary `src/app/debug/plan/page.tsx` that renders `<BriefingContainer initialRows={await getPlanningKeywords(SITE)} initialSiteId={SITE} />` with the hardcoded ORG/SITE, exclude `/debug` from `src/proxy.ts` matcher, and drive it:
```bash
~/.bun/bin/d3k # or use mcp__next-devtools__browser_eval: start chrome, navigate /debug/plan, screenshot
```
Confirm the table renders and a row opens the drawer. Remove the debug route + revert proxy afterward.

- [ ] **Step 5: Commit**

```bash
git add "src/app/(app)/keywords/planning/page.tsx" src/components/keywords/planning/briefing-container.tsx src/components/app-sidebar.tsx
git commit -m "feat(keywords): rebuild Planning as the Chambre de Briefing (table + enrichment drawer)"
```

---

## Task 5: Dispatch view (`/keywords/dispatch/[id]`) + transmutation + link

**Files:**
- Create: `src/app/(app)/keywords/dispatch/[id]/page.tsx`
- Create: `src/components/keywords/dispatch/dispatch-view.tsx`

Layout A: country tabs, scrolling left zone (SERP dashboard + Brief), persistent right rail (Titre/Échéance/Assigné + "Envoyer en production" → `createContentItemAction`).

- [ ] **Step 1: RSC page (fetch the keyword + targets)**

Create `src/app/(app)/keywords/dispatch/[id]/page.tsx`. Fetch the single keyword (reuse `getKeywordsForSite` then `.find`, or add a `getKeywordById` query if cleaner), its targets, and its readiness:

```tsx
import { notFound } from "next/navigation";
import { DispatchView } from "@/components/keywords/dispatch/dispatch-view";
import { getActiveOrgId } from "@/lib/active-org";
import { buildContentAnalysis } from "@/lib/content/analysis";
import { getKeywordsForSite, getTargetsForSite } from "@/lib/keywords/queries";
import { getActiveSiteId } from "@/lib/sites/active-site";
import { getSitesForOrg } from "@/lib/sites/queries";

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const sites = await getSitesForOrg(await getActiveOrgId());
  const siteId = await getActiveSiteId(sites.map((s) => s.id));
  const keyword = (await getKeywordsForSite(siteId)).find((k) => k.id === id);
  if (!keyword) notFound();
  const targets = (await getTargetsForSite(siteId))[id] ?? {};
  const plannedCountries = Object.entries(targets)
    .filter(([, s]) => s === "planned")
    .map(([c]) => c);
  return (
    <DispatchView
      keyword={keyword}
      plannedCountries={plannedCountries}
      analysisByCountry={Object.fromEntries(
        plannedCountries.map((c) => [c, buildContentAnalysis(
          keyword.serp as never, keyword.briefs, c)]),
      )}
    />
  );
}
```

> `getKeywordsForSite` returns the UI `Keyword` (no raw `serp` field guaranteed — check `toKeyword` in `queries.ts`; if `serp` isn't on the UI `Keyword`, add it to `toKeyword` or fetch the raw row by id). Adjust the `analysisByCountry` source accordingly.

- [ ] **Step 2: Dispatch view component**

Create `src/components/keywords/dispatch/dispatch-view.tsx` (client). Country tabs drive the active country; the rail submits `createContentItemAction`.

```tsx
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { SerpPanel } from "@/components/content/serp-panel";
import { BriefPanel } from "@/components/keywords/brief-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createContentItemAction } from "@/lib/content/actions";
import type { ContentAnalysis } from "@/lib/content/types";
import type { Keyword } from "@/lib/keywords/types";

const FLAG: Record<string, string> = { US: "🇺🇸", GB: "🇬🇧", CA: "🇨🇦", AU: "🇦🇺" };

export function DispatchView({
  keyword, plannedCountries, analysisByCountry,
}: {
  keyword: Keyword;
  plannedCountries: string[];
  analysisByCountry: Record<string, ContentAnalysis>;
}) {
  const router = useRouter();
  const [country, setCountry] = useState(plannedCountries[0] ?? "");
  const [title, setTitle] = useState(keyword.term);
  const [dueDate, setDueDate] = useState("");
  const [assignee, setAssignee] = useState("");
  const [pending, start] = useTransition();
  const a = analysisByCountry[country];
  const ready = a && a.serp.state === "ok" && a.onpage.state === "ok" && a.brief.state === "ok";

  function send() {
    start(async () => {
      try {
        await createContentItemAction(keyword.id, country, {
          title, dueDate: dueDate || null, assignee: assignee || null,
        });
        toast.success("Envoyé en production");
        router.push("/content/planning");
      } catch {
        toast.error("Échec de l'envoi en production");
      }
    });
  }

  return (
    <div className="flex h-[calc(100vh-3.5rem)] flex-col">
      <div className="flex items-center gap-3 border-b px-4 py-2">
        <button type="button" onClick={() => router.back()} className="text-muted-foreground">←</button>
        <span className="font-medium">{keyword.term}</span>
        <div className="ml-2 flex gap-1">
          {plannedCountries.map((c) => (
            <button key={c} type="button" onClick={() => setCountry(c)}
              aria-pressed={c === country}
              className={c === country ? "rounded bg-muted px-2 py-0.5" : "px-2 py-0.5"}>
              {FLAG[c] ?? c}
            </button>
          ))}
        </div>
      </div>
      <div className="flex min-h-0 flex-1">
        <div className="flex-1 space-y-4 overflow-y-auto p-4">
          <SerpPanel keywordId={keyword.id}
            snapshot={(keyword as never as { serp?: Record<string, never> }).serp?.[country] ?? null}
            onImported={() => router.refresh()} />
          <BriefPanel briefs={keyword.briefs} />
        </div>
        <aside className="w-80 shrink-0 space-y-3 border-l p-4">
          <h2 className="font-semibold">Préparation</h2>
          <label className="block text-sm">Titre
            <Input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
          <label className="block text-sm">Échéance
            <Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} /></label>
          <label className="block text-sm">Assigné
            <Input value={assignee} onChange={(e) => setAssignee(e.target.value)} /></label>
          <Button className="w-full" disabled={pending || !country || !ready} onClick={send}>
            Envoyer en production
          </Button>
          {!ready ? <p className="text-muted-foreground text-xs">Readiness incomplète pour ce pays.</p> : null}
        </aside>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Compile + lint**

```bash
npx tsc --noEmit && npx @biomejs/biome check "src/app/(app)/keywords/dispatch/[id]/page.tsx" src/components/keywords/dispatch/dispatch-view.tsx
```

- [ ] **Step 4: End-to-end harness check**

Build a temporary `/debug` route (hardcoded ORG/SITE) that links Briefing → Dispatch, drive it with Playwright (`mcp__next-devtools__browser_eval`): open briefing, click "Préparer", set a title, click "Envoyer", confirm redirect to `/content/planning` and (round-trip script) that a `ContentItem` with `status=brief` exists and the target is `live`. Remove the debug route + revert `src/proxy.ts` after.

- [ ] **Step 5: Commit**

```bash
git add "src/app/(app)/keywords/dispatch/[id]/page.tsx" src/components/keywords/dispatch/dispatch-view.tsx
git commit -m "feat(keywords): Dispatch view + transmutation to ContentItem"
```

---

## Task 6: Full-flow verification + spec parity

- [ ] **Step 1:** `npx tsc --noEmit` and `npx @biomejs/biome check` on all created/modified files — clean.
- [ ] **Step 2:** Pure tests still green: `bun run test src/lib/content/analysis.test.ts src/lib/keywords/targets.test.ts`.
- [ ] **Step 3:** Manual flow in the authed app (or harness): Recherche → set a cell `planned` (keyword appears in Planning) → enrich (SERP import + crawl + brief import → 3 dots green) → "Préparer" → Dispatch → "Envoyer" → card lands in `/content/planning` `brief` column. Confirm via toasts + a round-trip query.
- [ ] **Step 4:** Remove any leftover `/debug` routes; `git status` clean except intended files.
- [ ] **Step 5:** Update `docs/project/data-par-page.md` (Keywords section) to document the rebuilt Planning + new Dispatch, and commit.

---

## Notes / risks
- **Test DB blocked:** the DB **test suite** can't run until the `.env.test` Zod error is fixed; DB-action tasks use round-trip scripts instead. Fixing `db:migrate:test` is a worthwhile prerequisite but out of this plan's scope.
- **`keyword.serp` on the UI type:** verify whether `toKeyword` exposes `serp`; if not, either extend it or fetch raw rows in the Dispatch/Briefing queries. This affects Tasks 2, 4, 5 — resolve it in Task 2 and reuse.
- **Drawer reuse:** if the Recherche sheet already bundles SERP+Brief import into one component, reuse that whole component in the Briefing drawer and Dispatch left zone rather than re-wiring `SerpPanel`/`BriefPanel` individually (less drift).
