# GSC → Keywords Sync 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:** A manual owner-only "Synchroniser GSC" button on keywords → recherche that pulls the active site's top-200 GSC queries (by impressions, 28d) into `keywords`, merging into existing keywords and flagging them with an inline "GSC" badge.

**Architecture:** Reuse slice-1 GSC token/connection. Extend `SourceMetrics` with optional GSC fields (jsonb, no migration). A pure parser + a DB upsert/merge function, wired by an owner-gated server action; recherche shows a sync button and an inline GSC badge+tooltip.

**Tech Stack:** Next 16 server actions, Drizzle/Postgres, Better Auth (`getAccessToken`), shadcn (Badge, Tooltip), bun, biome.

Spec: `docs/superpowers/specs/2026-06-05-gsc-keywords-sync-design.md`

> **REVISED 2026-06-05** — model changed after Task 1: GSC is a **separate positioning overlay** (a nullable `keywords.gsc` jsonb column), **`SourceMetrics` is untouched** (vol/kd/cpc stay required/primary), and sync **enriches existing keywords only** (unknown queries ignored). Tasks 2 & 3 (parse + REST wrapper) are unchanged and DONE. Tasks 1/4/5/6 below are **superseded** by the revised spec — follow the spec's "GSC positioning overlay" model and the controller-provided task prompts.

---

## File structure

- Modify `src/lib/keywords/types.ts` — `SourceMetrics`: optional `volume/kd/cpc/growthPct` + new optional `clicks/impressions/ctr/position`.
- Fix readers surfaced by tsc (e.g. `research-columns.tsx`, `coverage-matrix.tsx`, import/fixtures) — guard now-optional fields.
- Modify `src/lib/gsc/parse.ts` — add `parseSearchAnalytics`, `topByImpressions`; add `GscQueryRow` to `src/lib/gsc/types.ts`.
- Modify `src/lib/gsc/parse.test.ts` (rename of client.test? no — create `src/lib/gsc/search-analytics.test.ts`) — tests for parse + top-N.
- Create `src/lib/gsc/search-analytics.ts` — `querySearchAnalytics(token, property, opts)`.
- Create `src/lib/gsc/sync.ts` — `buildGscMetric`, `upsertGscKeywordsForSite(siteId, rows)`.
- Create `src/lib/gsc/sync.test.ts` — DB-gated upsert/merge tests.
- Modify `src/lib/gsc/actions.ts` — add `syncGscKeywordsAction(siteId)`.
- Modify `src/app/(app)/keywords/recherche/page.tsx` — resolve `canSync` (owner) and pass down.
- Modify `src/components/keywords/research/research-container.tsx` — sync button.
- Modify `src/components/keywords/research/research-columns.tsx` — inline "GSC" badge + tooltip in the term cell.

---

## Task 1: Extend SourceMetrics (optional SEO fields + GSC fields) and fix readers

**Files:**
- Modify: `src/lib/keywords/types.ts` (the `SourceMetrics` interface, ~line 39)
- Modify: whatever files tsc flags (guard undefined)

- [ ] **Step 1: Change the interface**

Replace the `SourceMetrics` interface with:

```ts
/** Per-source metrics. SEO-tool fields (volume/kd/cpc) are the reference and are
 *  absent for GSC-only keywords; GSC fields are present only under metrics.gsc. */
export interface SourceMetrics {
  volume?: number; // monthly search volume (last known)
  kd?: number; // keyword difficulty 0-100
  cpc?: number; // CPC in USD
  growthPct?: number | null; // keyword interest growth %
  competitiveDensity?: number | null; // Semrush only
  numberOfResults?: number | null; // Semrush only
  clicks?: number; // GSC
  impressions?: number; // GSC
  ctr?: number; // GSC, 0..1
  position?: number; // GSC, average position
  updatedAt: string; // ISO date (always set)
}
```

- [ ] **Step 2: Run tsc to surface broken readers**

Run: `bunx tsc --noEmit`
Expected: errors where code reads `metrics.<source>.volume|kd|cpc|growthPct` assuming they're defined (likely `src/components/keywords/research/research-columns.tsx`, `coverage-matrix.tsx`, and possibly `src/lib/keywords/import-actions.ts`/`fixtures.ts`).

- [ ] **Step 3: Fix each surfaced reader with the guard rule**

Rule: a missing SEO metric is rendered/treated as absent — never assumed present.
- In display code, fall back to an em dash: `metrics?.volume ?? "—"` (for numbers shown as text), or `metrics?.volume ?? 0` only where a number is required for sorting/among. Prefer `"—"` for display, `?? 0` for numeric sort keys.
- In logic, use optional chaining + nullish coalescing.

Apply minimally to each error line. Do NOT change behavior for sources that always set these (Mangools/Semrush) — only make the access safe.

- [ ] **Step 4: Verify tsc + existing tests still pass**

Run: `bunx tsc --noEmit` (exit 0) and `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun test src/lib/keywords` (existing keyword tests still green).
Expected: exit 0; keyword tests pass.

- [ ] **Step 5: Lint + commit**

```bash
bunx biome check --write src/lib/keywords/types.ts
git add -A
git commit -m "refactor(keywords): make SEO metrics optional + add GSC fields to SourceMetrics"
```

---

## Task 2: Pure GSC search-analytics parser + top-N

**Files:**
- Modify: `src/lib/gsc/types.ts` (add `GscQueryRow`)
- Modify: `src/lib/gsc/parse.ts` (add `parseSearchAnalytics`, `topByImpressions`)
- Create: `src/lib/gsc/search-analytics.test.ts`

- [ ] **Step 1: Add the row type**

Append to `src/lib/gsc/types.ts`:
```ts
/** One row of a GSC searchAnalytics query (dimensions: ["query"]). */
export type GscQueryRow = {
  query: string;
  clicks: number;
  impressions: number;
  ctr: number; // 0..1
  position: number;
};
```

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

`src/lib/gsc/search-analytics.test.ts`:
```ts
import { expect, test } from "bun:test";
import { parseSearchAnalytics, topByImpressions } from "./parse";

test("parseSearchAnalytics maps rows and drops malformed", () => {
  const body = {
    rows: [
      { keys: ["lustre lab"], clicks: 10, impressions: 100, ctr: 0.1, position: 4.2 },
      { keys: ["best lustre"], clicks: 5, impressions: 200, ctr: 0.025, position: 7.1 },
      { clicks: 1, impressions: 1, ctr: 1, position: 1 }, // no keys → dropped
    ],
  };
  expect(parseSearchAnalytics(body)).toEqual([
    { query: "lustre lab", clicks: 10, impressions: 100, ctr: 0.1, position: 4.2 },
    { query: "best lustre", clicks: 5, impressions: 200, ctr: 0.025, position: 7.1 },
  ]);
  expect(parseSearchAnalytics({})).toEqual([]);
});

test("topByImpressions sorts desc and caps", () => {
  const rows = [
    { query: "a", clicks: 0, impressions: 50, ctr: 0, position: 9 },
    { query: "b", clicks: 0, impressions: 300, ctr: 0, position: 3 },
    { query: "c", clicks: 0, impressions: 120, ctr: 0, position: 5 },
  ];
  expect(topByImpressions(rows, 2).map((r) => r.query)).toEqual(["b", "c"]);
});
```

- [ ] **Step 2b: Run — verify it fails**

Run: `bun test src/lib/gsc/search-analytics.test.ts`
Expected: FAIL (functions not exported).

- [ ] **Step 3: Implement in parse.ts**

Append to `src/lib/gsc/parse.ts` (pure module, no server-only):
```ts
import type { GscQueryRow } from "./types";

/** Map a searchAnalytics response (dimensions ["query"]) to typed rows. */
export function parseSearchAnalytics(body: unknown): GscQueryRow[] {
  const rows = (body as { rows?: unknown })?.rows;
  if (!Array.isArray(rows)) return [];
  const out: GscQueryRow[] = [];
  for (const r of rows) {
    const keys = (r as { keys?: unknown })?.keys;
    const query = Array.isArray(keys) ? keys[0] : undefined;
    if (typeof query !== "string") continue;
    const row = r as Record<string, unknown>;
    out.push({
      query,
      clicks: Number(row.clicks ?? 0),
      impressions: Number(row.impressions ?? 0),
      ctr: Number(row.ctr ?? 0),
      position: Number(row.position ?? 0),
    });
  }
  return out;
}

/** Top N rows by impressions, descending. */
export function topByImpressions(rows: GscQueryRow[], n: number): GscQueryRow[] {
  return [...rows].sort((a, b) => b.impressions - a.impressions).slice(0, n);
}
```

- [ ] **Step 4: Run — verify pass**

Run: `bun test src/lib/gsc/search-analytics.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Lint + commit**

```bash
bunx biome check --write src/lib/gsc/parse.ts src/lib/gsc/types.ts src/lib/gsc/search-analytics.test.ts
bunx tsc --noEmit
git add src/lib/gsc/
git commit -m "feat(gsc): parse + top-N for searchAnalytics query rows"
```

---

## Task 3: searchAnalytics REST wrapper

**Files:**
- Create: `src/lib/gsc/search-analytics.ts`

> Thin fetch wrapper (server-only); no unit test (covered by parse test + live).

- [ ] **Step 1: Implement**

`src/lib/gsc/search-analytics.ts`:
```ts
// src/lib/gsc/search-analytics.ts — GSC searchAnalytics REST (no googleapis dep).
import "server-only";
import { parseSearchAnalytics } from "./parse";
import type { GscQueryRow } from "./types";

function isoDaysAgo(days: number): string {
  const d = new Date();
  d.setUTCDate(d.getUTCDate() - days);
  return d.toISOString().slice(0, 10);
}

/** Query GSC searchAnalytics for a property, dimension=query. */
export async function querySearchAnalytics(
  accessToken: string,
  property: string,
  opts: { days?: number; rowLimit?: number } = {},
): Promise<GscQueryRow[]> {
  const days = opts.days ?? 28;
  const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(
    property,
  )}/searchAnalytics/query`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      startDate: isoDaysAgo(days),
      endDate: isoDaysAgo(0),
      dimensions: ["query"],
      rowLimit: opts.rowLimit ?? 1000,
    }),
  });
  if (!res.ok) {
    throw new Error(`GSC searchAnalytics failed: HTTP ${res.status}`);
  }
  return parseSearchAnalytics(await res.json());
}
```

- [ ] **Step 2: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/gsc/search-analytics.ts
git add src/lib/gsc/search-analytics.ts
git commit -m "feat(gsc): searchAnalytics REST wrapper"
```

---

## Task 4: Upsert/merge GSC rows into keywords

**Files:**
- Create: `src/lib/gsc/sync.ts`
- Test: `src/lib/gsc/sync.test.ts`

- [ ] **Step 1: Implement sync.ts**

`src/lib/gsc/sync.ts`:
```ts
// src/lib/gsc/sync.ts — upsert/merge GSC query rows into the keywords table.
import "server-only";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { keywords as keywordsTable } from "@/lib/db/domain-schema";
import type { SourceMetrics } from "@/lib/keywords/types";
import type { GscQueryRow } from "./types";

export type UpsertResult = { created: number; merged: number };

function gscMetric(row: GscQueryRow, now: string): SourceMetrics {
  return {
    clicks: row.clicks,
    impressions: row.impressions,
    ctr: row.ctr,
    position: row.position,
    updatedAt: now,
  };
}

/** Upsert GSC rows for a site: merge into existing keywords (by lower(term)),
 *  create new ones for unseen queries. */
export async function upsertGscKeywordsForSite(
  siteId: string,
  rows: GscQueryRow[],
  site: { lang: string; country: string },
): Promise<UpsertResult> {
  const now = new Date().toISOString();
  const existing = await db
    .select()
    .from(keywordsTable)
    .where(eq(keywordsTable.siteId, siteId));
  const byTerm = new Map(existing.map((k) => [k.term.toLowerCase(), k]));

  let created = 0;
  let merged = 0;
  await db.transaction(async (tx) => {
    for (const row of rows) {
      const key = row.query.toLowerCase();
      const found = byTerm.get(key);
      const metric = gscMetric(row, now);
      if (found) {
        const sources = found.sources.includes("gsc")
          ? found.sources
          : [...found.sources, "gsc" as const];
        await tx
          .update(keywordsTable)
          .set({
            sources,
            metrics: { ...found.metrics, gsc: metric },
          })
          .where(eq(keywordsTable.id, found.id));
        merged++;
      } else {
        await tx.insert(keywordsTable).values({
          id: randomUUID(),
          siteId,
          term: row.query,
          lang: site.lang,
          country: site.country,
          theme: null,
          status: "idea",
          intent: [],
          stages: ["research"],
          primarySource: "gsc",
          sources: ["gsc"],
          metrics: { gsc: metric },
          trend: [],
        });
        created++;
      }
    }
  });
  return { created, merged };
}
```

> Note: `serpFeatures` is nullable in the schema (no `.notNull()`), so it can be omitted from the insert; `theme` is nullable. Confirm against `src/lib/db/domain-schema.ts` (keywords table) — match the seed.ts insert shape (id, siteId, term, lang, country, theme, status, intent, stages, primarySource, sources, metrics, trend). `briefs`/`serpFeatures` have defaults/nullable.

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

`src/lib/gsc/sync.test.ts`:
```ts
import { afterAll, beforeEach, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization } from "@/lib/db/auth-schema";
import { client, db } from "@/lib/db/client";
import { keywords as kw, sites } from "@/lib/db/domain-schema";
import { user } from "@/lib/db/auth-schema";
import { upsertGscKeywordsForSite } from "./sync";

const SITE = "gsc-sync-site";
const ORG = "gsc-sync-org";
const USR = "gsc-sync-user";

beforeEach(async () => {
  await assertTestDb();
  await db.delete(kw).where(eq(kw.siteId, SITE));
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(user).where(eq(user.id, USR));
  await db.insert(user).values({ id: USR, name: "U", email: `gscsync-${Date.now()}@example.com`, emailVerified: true });
  await db.insert(organization).values({ id: ORG, name: "O", slug: `gscsync-${Date.now()}`, createdAt: new Date() });
  await db.insert(sites).values({ id: SITE, orgId: ORG, ownerUserId: USR, name: "S", url: "https://example.com", primaryLanguage: "fr", targetCountries: ["FR"] });
});

afterAll(async () => {
  await client.end();
});

const SITE_LOC = { lang: "fr", country: "FR" };

test("creates new keywords for unseen queries", async () => {
  const res = await upsertGscKeywordsForSite(SITE, [
    { query: "lustre lab", clicks: 1, impressions: 10, ctr: 0.1, position: 4 },
  ], SITE_LOC);
  expect(res).toEqual({ created: 1, merged: 0 });
  const [row] = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(row.primarySource).toBe("gsc");
  expect(row.sources).toContain("gsc");
  expect(row.metrics.gsc?.impressions).toBe(10);
});

test("merges into an existing keyword, keeps primarySource", async () => {
  await db.insert(kw).values({
    id: "k-existing", siteId: SITE, term: "Lustre Lab", lang: "fr", country: "FR",
    status: "active", primarySource: "semrush", sources: ["semrush"],
    metrics: { semrush: { volume: 500, kd: 20, cpc: 1, growthPct: null, updatedAt: "2026-01-01" } },
  });
  const res = await upsertGscKeywordsForSite(SITE, [
    { query: "lustre lab", clicks: 2, impressions: 99, ctr: 0.02, position: 6 },
  ], SITE_LOC);
  expect(res).toEqual({ created: 0, merged: 1 });
  const [row] = await db.select().from(kw).where(eq(kw.id, "k-existing"));
  expect(row.primarySource).toBe("semrush");
  expect(row.sources.sort()).toEqual(["gsc", "semrush"]);
  expect(row.metrics.semrush?.volume).toBe(500);
  expect(row.metrics.gsc?.impressions).toBe(99);
});
```

- [ ] **Step 2b: Run — verify fail**

Run: `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/gsc/sync.test.ts`
Expected: FAIL if function missing — it exists from Step 1, so this should PASS. If it fails, fix `sync.ts` (not the test) and report.

- [ ] **Step 3: Run — verify pass**

Run the same command. Expected: PASS (2 tests). `--conditions=react-server` required (`sync.ts` imports `server-only`).

- [ ] **Step 4: Lint + typecheck + commit**

```bash
bunx biome check --write src/lib/gsc/sync.ts src/lib/gsc/sync.test.ts
bunx tsc --noEmit
git add src/lib/gsc/
git commit -m "feat(gsc): upsert/merge GSC query rows into keywords"
```

---

## Task 5: `syncGscKeywordsAction`

**Files:**
- Modify: `src/lib/gsc/actions.ts` (add the action; reuse existing `isOwner`, `getActiveOrgId`, `getGscAccessToken`)

- [ ] **Step 1: Add imports + action**

In `src/lib/gsc/actions.ts`, add imports:
```ts
import { sites as sitesTable } from "@/lib/db/domain-schema";
import { querySearchAnalytics } from "./search-analytics";
import { upsertGscKeywordsForSite } from "./sync";
import { topByImpressions } from "./parse";
```
(Note: `sitesTable` may already be imported — check; don't duplicate. `db`, `and`, `eq`, `getActiveOrgId`, `isOwner`, `getGscAccessToken` are already in the file.)

Add the constant + action:
```ts
const GSC_SYNC_TOP_N = 200;

export type SyncGscResult =
  | { ok: true; synced: number; created: number; merged: number }
  | { ok: false; error: string };

/** Pull the active site's top GSC queries into keywords. Owner-only. */
export async function syncGscKeywordsAction(
  siteId: string,
): Promise<SyncGscResult> {
  if (!(await isOwner())) {
    return { ok: false, error: "Action réservée au propriétaire." };
  }
  const orgId = await getActiveOrgId();
  const [site] = await db
    .select({
      gscProperty: sitesTable.gscProperty,
      lang: sitesTable.primaryLanguage,
      countries: sitesTable.targetCountries,
    })
    .from(sitesTable)
    .where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)));
  if (!site) return { ok: false, error: "Site introuvable." };
  if (!site.gscProperty) {
    return { ok: false, error: "Mappe d'abord une propriété GSC pour ce site." };
  }
  const token = await getGscAccessToken();
  if (!token) return { ok: false, error: "Connecte Google Search Console." };

  let rows: Awaited<ReturnType<typeof querySearchAnalytics>>;
  try {
    rows = await querySearchAnalytics(token, site.gscProperty);
  } catch {
    return { ok: false, error: "Échec de la synchronisation GSC." };
  }
  const top = topByImpressions(rows, GSC_SYNC_TOP_N);
  const { created, merged } = await upsertGscKeywordsForSite(siteId, top, {
    lang: site.lang,
    country: site.countries[0] ?? "US",
  });
  revalidatePath("/keywords/recherche");
  return { ok: true, synced: top.length, created, merged };
}
```

> `revalidatePath` is already imported in actions.ts. Confirm `and` is imported (it is). If `sitesTable` isn't yet imported, add it.

- [ ] **Step 2: Typecheck + lint**

Run: `bunx tsc --noEmit` (exit 0); `bunx biome check --write src/lib/gsc/actions.ts`.

- [ ] **Step 3: Commit**

```bash
git add src/lib/gsc/actions.ts
git commit -m "feat(gsc): syncGscKeywordsAction — top-N queries into keywords"
```

---

## Task 6: recherche UI — sync button + inline GSC badge

**Files:**
- Modify: `src/app/(app)/keywords/recherche/page.tsx` (resolve `canSync`, pass to container)
- Modify: `src/components/keywords/research/research-container.tsx` (sync button)
- Modify: `src/components/keywords/research/research-columns.tsx` (inline GSC badge + tooltip in the term cell)

- [ ] **Step 1: Resolve owner in the page and pass down**

Read `src/app/(app)/keywords/recherche/page.tsx`. Make it `async`, resolve owner via `auth.api.getActiveMember({ headers: await headers() })` (`import { headers } from "next/headers"`, `import { auth } from "@/lib/auth"`), and pass `canSync={member?.role === "owner"}` to `<ResearchContainer canSync={...} />`.

- [ ] **Step 2: Sync button in research-container**

Read `src/components/keywords/research/research-container.tsx`. Add a `canSync?: boolean` prop. Near the top toolbar (where search/view controls live — NOT inside `TargetingBar`, which only shows on selection), add an owner-only button using the existing active site id (`activeSiteId` from `useActiveSite()`):

```tsx
// imports
import { syncGscKeywordsAction } from "@/lib/gsc/actions";
import { useTransition } from "react"; // merge with existing react import
// ...
const [syncing, startSync] = useTransition();
const [syncMsg, setSyncMsg] = useState<string | null>(null);

function handleSync() {
  setSyncMsg(null);
  startSync(async () => {
    const res = await syncGscKeywordsAction(activeSiteId);
    if (!res.ok) { setSyncMsg(res.error); return; }
    setSyncMsg(`${res.synced} synchronisées (${res.created} créées, ${res.merged} fusionnées)`);
    // reload the keyword rows using the existing loader in this component.
    // (call whatever effect/loader populates `rows`; if it's an effect keyed on
    //  activeSiteId, trigger a re-fetch — match the existing pattern.)
  });
}
```
JSX (render only when `canSync`):
```tsx
{canSync ? (
  <div className="flex items-center gap-2">
    <Button variant="outline" size="sm" onClick={handleSync} disabled={syncing}>
      {syncing ? "Synchronisation…" : "Synchroniser GSC"}
    </Button>
    {syncMsg ? <span className="text-muted-foreground text-xs">{syncMsg}</span> : null}
  </div>
) : null}
```
Reuse the component's existing keyword loader to refresh after a successful sync (read the file to see how `rows` is populated — likely a `useEffect` on `activeSiteId` calling an action/query; extract it into a callable `load()` and call it after sync). Import `Button` if not already.

- [ ] **Step 3: Inline GSC badge + tooltip in the term column**

Read `src/components/keywords/research/research-columns.tsx`. In the column whose `accessorKey`/cell renders the keyword `term`, append — to the RIGHT of the term — a badge when the row's keyword `sources` includes `"gsc"`, with a tooltip of the GSC metrics:

```tsx
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// inside the term cell, after the term text (kw = the row's Keyword):
{kw.sources.includes("gsc") ? (
  <Tooltip>
    <TooltipTrigger asChild>
      <Badge variant="secondary" className="ml-2 align-middle text-[10px]">GSC</Badge>
    </TooltipTrigger>
    <TooltipContent>
      Position {kw.metrics.gsc?.position?.toFixed(1) ?? "—"} · {kw.metrics.gsc?.clicks ?? 0} clics · {kw.metrics.gsc?.impressions ?? 0} impr.
    </TooltipContent>
  </Tooltip>
) : null}
```
Confirm `tooltip.tsx` and `badge.tsx` exist in `src/components/ui/` (they do). Ensure a `TooltipProvider` wraps the table or app (check the shadcn tooltip setup; if needed, wrap the table in `<TooltipProvider>`).

- [ ] **Step 4: Typecheck + lint**

Run: `bunx tsc --noEmit` (exit 0); `bunx biome check --write` the three files.

- [ ] **Step 5: Commit**

```bash
git add "src/app/(app)/keywords/recherche/page.tsx" src/components/keywords/research/research-container.tsx src/components/keywords/research/research-columns.tsx
git commit -m "feat(gsc): recherche sync button + inline GSC badge/tooltip"
```

---

## Task 7: Live verification

- [ ] **Step 1: Full checks**

Run:
```bash
bunx tsc --noEmit
DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/gsc
bunx biome check src/lib/gsc src/lib/keywords/types.ts src/components/keywords/research
```
Expected: tsc 0; all gsc tests pass; biome clean.

- [ ] **Step 2: Live (d3k)**

`d3k agent-browser open http://localhost:3000/keywords/recherche` → as the owner with a mapped GSC property, click "Synchroniser GSC" → result message; GSC-tracked terms show the inline "GSC" badge with a tooltip; re-sync updates without duplicates. `d3k errors` clean. (If the active site has no mapped property, expect the "Mappe d'abord une propriété GSC" message — map it in Settings → Intégrations first.)

- [ ] **Step 3: Final commit (fixups)**

```bash
git add -A && git commit -m "chore(gsc): keywords sync verification fixups"
```

---

## Notes / risks

- Making `volume/kd/cpc` optional (Task 1) ripples to readers — fix exactly what tsc reports, default to "—"/`?? 0`. Don't broaden scope.
- Sync is owner-only (current-session token); cross-user remains deferred.
- The recherche keyword loader must be re-run after sync so new rows appear without a manual refresh — wire it to the component's existing loader.
- Keep `--conditions=react-server` for any bun test touching `src/lib/gsc` (server-only imports).
