# GSC Discovery 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:** Add an owner-only "Importer depuis GSC" action that previews a site's GSC queries and creates the selected unknown ones as keywords (with their GSC positioning), routed to Réservoir or Recherche.

**Architecture:** Reuse slice-2 `querySearchAnalytics`. Expose the rust `slugify` via wasm-bindgen so created keyword ids match the CSV import (later CSV imports merge). A pure-ish DB inserter (`createGscKeywordsForSite`, idempotent via `onConflictDoNothing`), two owner-gated server actions (preview + commit), and a preview dialog launched from the reservoir toolbar. The existing overlay-only Sync is untouched.

**Tech Stack:** Rust + wasm-pack (`import-core`), Next 16 server actions, Drizzle/Postgres, Better Auth (`getActiveMember`, `getAccessToken`), shadcn (Dialog, Checkbox, Input, RadioGroup/Button), bun + biome.

Spec: `docs/superpowers/specs/2026-06-06-gsc-discovery-design.md`

---

## File structure

- Modify `crates/import-core/src/lib.rs` — expose `slugify` (wasm-bindgen); rebuild artifacts (gitignored).
- Create `src/lib/gsc/import-types.ts` — `GscImportRow`, `GscImportPreview`, `GscImportCommitResult`.
- Create `src/lib/gsc/discovery.ts` — `createGscKeywordsForSite`.
- Create `src/lib/gsc/discovery.test.ts` — DB-gated tests.
- Create `src/lib/gsc/owner.ts` — extracted `isOwner` / `requestHeaders` (shared by actions + import-actions).
- Modify `src/lib/gsc/actions.ts` — import owner helpers from `./owner` (remove the private copies).
- Create `src/lib/gsc/import-actions.ts` — `previewGscImportAction`, `commitGscImportAction`.
- Create `src/components/keywords/import-gsc-dialog.tsx` — the preview dialog.
- Modify `src/components/keywords/keywords-toolbar.tsx` — owner-only "Importer depuis GSC" button + dialog.
- Modify `src/components/keywords/reservoir.tsx` — accept + pass `canImportGsc`.
- Modify `src/app/(app)/keywords/reservoir/page.tsx` — resolve owner, pass `canImportGsc`.

Tooling: typecheck `bunx tsc --noEmit`; lint `bunx biome check --write <files>`; pure tests `bun test <file>`; DB tests `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test <file>`; wasm rebuild `bun run build:wasm`. A dev server is already running under d3k — do NOT start/restart it.

---

## Task 1: Expose rust `slugify` via wasm-bindgen + rebuild

**Files:**
- Modify: `crates/import-core/src/lib.rs`
- Rebuilt (gitignored, local only): `src/lib/backlink/import-core/*`

- [ ] **Step 1: Add the wasm export**

In `crates/import-core/src/lib.rs`, after the `detect_keyword_source` export, add:
```rust
#[wasm_bindgen]
pub fn slugify(s: &str) -> String {
    normalize::slugify_public(s)
}
```
(`normalize` is already declared via `mod normalize;` at the top of the file.)

- [ ] **Step 2: Rebuild the wasm package**

Run: `bun run build:wasm`
Expected: wasm-pack builds successfully; `src/lib/backlink/import-core/import_core.js` and `import_core.d.ts` now contain a `slugify` export.

- [ ] **Step 3: Verify the export exists and is correct**

Run:
```bash
grep -n "slugify" src/lib/backlink/import-core/import_core.d.ts
bun -e 'import * as w from "import-core"; console.log(w.slugify("Panneau Solaire!"))'
```
Expected: the `.d.ts` declares `export function slugify(s: string): string;`, and the eval prints `panneau-solaire` (a clean kebab slug, matching the CSV import's slug for the same term).

- [ ] **Step 4: Typecheck + commit (rust source only — artifacts are gitignored)**

Run: `bunx tsc --noEmit` (exit 0).
```bash
git add crates/import-core/src/lib.rs
git commit -m "feat(import-core): expose slugify via wasm-bindgen"
```

---

## Task 2: GSC import types

**Files:**
- Create: `src/lib/gsc/import-types.ts`

> No test (types only).

- [ ] **Step 1: Create the file**

`src/lib/gsc/import-types.ts`:
```ts
// src/lib/gsc/import-types.ts — shared types for GSC discovery import (no runtime code).

export type GscImportStatus = "new" | "existing";

/** One previewed GSC query row, annotated against the site's existing keywords. */
export interface GscImportRow {
  query: string;
  clicks: number;
  impressions: number;
  ctr: number;
  position: number;
  status: GscImportStatus;
}

export type GscImportPreview =
  | { ok: true; rows: GscImportRow[]; total: number }
  | { ok: false; error: string };

export type GscImportCommitResult =
  | { ok: true; created: number }
  | { ok: false; error: string };
```

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

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/gsc/import-types.ts
git add src/lib/gsc/import-types.ts
git commit -m "feat(gsc): GSC import types (preview/commit)"
```

---

## Task 3: `createGscKeywordsForSite` (idempotent inserter) + tests

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

- [ ] **Step 1: Implement `discovery.ts`**

`src/lib/gsc/discovery.ts`:
```ts
// src/lib/gsc/discovery.ts — create keywords from GSC queries (idempotent).
import "server-only";
import * as wasm from "import-core";
import { db } from "@/lib/db/client";
import { keywords as keywordsTable } from "@/lib/db/domain-schema";
import type { GscTracking } from "@/lib/keywords/types";
import type { GscQueryRow } from "./types";

export type CreateGscResult = { created: number };

/** Create keywords for the given GSC queries. primarySource "gsc", no SEO
 *  metrics, gsc overlay set. Id = `${siteId}-${slug}` (matches CSV import so a
 *  later Mangools/Semrush import merges). onConflictDoNothing → idempotent. */
export async function createGscKeywordsForSite(
  siteId: string,
  rows: GscQueryRow[],
  opts: { lang: string; country: string; stages: ("research" | "planning")[] },
): Promise<CreateGscResult> {
  if (rows.length === 0) return { created: 0 };
  const now = new Date().toISOString();
  const values = rows.map((r) => {
    const gsc: GscTracking = {
      clicks: r.clicks,
      impressions: r.impressions,
      ctr: r.ctr,
      position: r.position,
      updatedAt: now,
    };
    return {
      id: `${siteId}-${wasm.slugify(r.query)}`,
      siteId,
      term: r.query,
      lang: opts.lang,
      country: opts.country,
      theme: null,
      status: "idea" as const,
      intent: [],
      stages: opts.stages,
      primarySource: "gsc" as const,
      sources: ["gsc" as const],
      metrics: {},
      gsc,
      trend: [],
    };
  });
  const inserted = await db
    .insert(keywordsTable)
    .values(values)
    .onConflictDoNothing({ target: keywordsTable.id })
    .returning({ id: keywordsTable.id });
  return { created: inserted.length };
}
```

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

`src/lib/gsc/discovery.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, user } from "@/lib/db/auth-schema";
import { client, db } from "@/lib/db/client";
import { keywords as kw, sites } from "@/lib/db/domain-schema";
import { createGscKeywordsForSite } from "./discovery";

const SITE = "gsc-disc-site";
const ORG = "gsc-disc-org";
const USR = "gsc-disc-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: `gscdisc-${Date.now()}@example.com`, emailVerified: true });
  await db.insert(organization).values({ id: ORG, name: "O", slug: `gscdisc-${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 row = (query: string, impressions = 10) => ({ query, clicks: 1, impressions, ctr: 0.1, position: 12 });
const OPTS = { lang: "fr", country: "FR", stages: [] as ("research" | "planning")[] };

test("creates GSC-only keywords with the right shape", async () => {
  const res = await createGscKeywordsForSite(SITE, [row("panneau solaire", 99)], OPTS);
  expect(res).toEqual({ created: 1 });
  const [k] = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(k.id).toBe(`${SITE}-panneau-solaire`);
  expect(k.primarySource).toBe("gsc");
  expect(k.sources).toEqual(["gsc"]);
  expect(k.metrics).toEqual({});
  expect(k.gsc?.impressions).toBe(99);
  expect(k.stages).toEqual([]);
});

test("is idempotent (second run creates 0) and skips existing ids", async () => {
  await createGscKeywordsForSite(SITE, [row("panneau solaire")], OPTS);
  const res = await createGscKeywordsForSite(SITE, [row("panneau solaire"), row("prix solaire")], OPTS);
  expect(res).toEqual({ created: 1 }); // only the new one
  const rows = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(rows.length).toBe(2);
});

test("routes stages per destination", async () => {
  await createGscKeywordsForSite(SITE, [row("solaire recherche")], { ...OPTS, stages: ["research"] });
  const [k] = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(k.stages).toEqual(["research"]);
});
```

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

Run: `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/gsc/discovery.test.ts`
Expected: PASS (3 tests). `--conditions=react-server` is required (`discovery.ts` imports `server-only`). If `wasm.slugify` is undefined, Task 1's rebuild didn't run — rerun `bun run build:wasm`.

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

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/gsc/discovery.ts src/lib/gsc/discovery.test.ts
git add src/lib/gsc/discovery.ts src/lib/gsc/discovery.test.ts
git commit -m "feat(gsc): createGscKeywordsForSite — idempotent keyword creation from queries"
```

---

## Task 4: Extract `isOwner` + write preview/commit actions

**Files:**
- Create: `src/lib/gsc/owner.ts`
- Modify: `src/lib/gsc/actions.ts`
- Create: `src/lib/gsc/import-actions.ts`

- [ ] **Step 1: Extract the owner helpers**

`src/lib/gsc/owner.ts`:
```ts
// src/lib/gsc/owner.ts — shared owner-gating helpers (not a "use server" module).
import "server-only";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

/** Request headers, falling back gracefully outside a request scope (tests). */
export async function requestHeaders(): Promise<Headers> {
  try {
    return await headers();
  } catch {
    return new Headers();
  }
}

/** Is the current session user the active org owner? */
export async function isOwner(): Promise<boolean> {
  const member = await auth.api.getActiveMember({ headers: await requestHeaders() });
  return member?.role === "owner";
}
```

- [ ] **Step 2: Update `actions.ts` to use the shared helpers**

In `src/lib/gsc/actions.ts`:
1. Remove the local `requestHeaders` and `isOwner` function definitions.
2. Remove the now-unused `headers` import (from `next/headers`) and `auth` import IF they are no longer referenced elsewhere in the file (check: `auth` may still be used — only remove what becomes unused; tsc will flag unused imports via biome).
3. Add: `import { isOwner } from "./owner";`

Run `bunx tsc --noEmit` (exit 0) and `bunx biome check --write src/lib/gsc/actions.ts` to catch any now-unused imports and fix them.

- [ ] **Step 3: Write the import actions**

`src/lib/gsc/import-actions.ts`:
```ts
// src/lib/gsc/import-actions.ts — owner-gated GSC discovery preview + commit.
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getActiveOrgId } from "@/lib/active-org";
import { db } from "@/lib/db/client";
import { keywords as keywordsTable, sites as sitesTable } from "@/lib/db/domain-schema";
import { createGscKeywordsForSite } from "./discovery";
import { isOwner } from "./owner";
import { querySearchAnalytics } from "./search-analytics";
import { getGscAccessToken } from "./token";
import type { GscImportCommitResult, GscImportPreview, GscImportRow } from "./import-types";
import type { GscQueryRow } from "./types";

/** Fetch the active site's GSC queries, annotated new/existing vs the reservoir. */
export async function previewGscImportAction(
  siteId: string,
): Promise<GscImportPreview> {
  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 })
    .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 queryRows: GscQueryRow[];
  try {
    queryRows = await querySearchAnalytics(token, site.gscProperty, {
      days: 28,
      rowLimit: 25000,
    });
  } catch {
    return { ok: false, error: "Échec de la récupération GSC." };
  }
  const existing = new Set(
    (
      await db
        .select({ term: keywordsTable.term })
        .from(keywordsTable)
        .where(eq(keywordsTable.siteId, siteId))
    ).map((r) => r.term.toLowerCase()),
  );
  const rows: GscImportRow[] = queryRows
    .map((r) => ({
      ...r,
      status: existing.has(r.query.toLowerCase())
        ? ("existing" as const)
        : ("new" as const),
    }))
    .sort((a, b) => b.impressions - a.impressions);
  return { ok: true, rows, total: queryRows.length };
}

/** Create the selected GSC queries as keywords, routed by destination. */
export async function commitGscImportAction(
  siteId: string,
  queries: GscQueryRow[],
  destination: "reservoir" | "recherche",
): Promise<GscImportCommitResult> {
  if (!(await isOwner())) {
    return { ok: false, error: "Action réservée au propriétaire." };
  }
  const orgId = await getActiveOrgId();
  const [site] = await db
    .select({
      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." };
  const stages: ("research" | "planning")[] =
    destination === "recherche" ? ["research"] : [];
  const { created } = await createGscKeywordsForSite(siteId, queries, {
    lang: site.lang,
    country: site.countries[0] ?? "US",
    stages,
  });
  revalidatePath("/keywords/reservoir");
  if (destination === "recherche") revalidatePath("/keywords/recherche");
  return { ok: true, created };
}
```

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

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/gsc/owner.ts src/lib/gsc/actions.ts src/lib/gsc/import-actions.ts
git add src/lib/gsc/owner.ts src/lib/gsc/actions.ts src/lib/gsc/import-actions.ts
git commit -m "feat(gsc): preview + commit actions for GSC discovery (shared isOwner)"
```

---

## Task 5: `ImportGscDialog` component

**Files:**
- Create: `src/components/keywords/import-gsc-dialog.tsx`

> No unit test (presentational; verified live in Task 7).

- [ ] **Step 1: Implement the dialog**

`src/components/keywords/import-gsc-dialog.tsx`:
```tsx
// src/components/keywords/import-gsc-dialog.tsx — preview + select GSC queries to import.
"use client";

import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  commitGscImportAction,
  previewGscImportAction,
} from "@/lib/gsc/import-actions";
import type { GscImportRow } from "@/lib/gsc/import-types";

type Destination = "reservoir" | "recherche";

export function ImportGscDialog({
  open,
  onOpenChange,
  siteId,
  onImported,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  siteId: string;
  onImported: () => void;
}) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [rows, setRows] = useState<GscImportRow[]>([]);
  const [total, setTotal] = useState(0);
  const [minImpr, setMinImpr] = useState(1);
  const [exclude, setExclude] = useState("");
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [destination, setDestination] = useState<Destination>("reservoir");
  const [pending, setPending] = useState(false);
  const [doneMsg, setDoneMsg] = useState<string | null>(null);

  useEffect(() => {
    if (!open) return;
    setLoading(true);
    setError(null);
    setDoneMsg(null);
    previewGscImportAction(siteId)
      .then((res) => {
        if (!res.ok) {
          setError(res.error);
          setRows([]);
          return;
        }
        setRows(res.rows);
        setTotal(res.total);
        setSelected(
          new Set(res.rows.filter((r) => r.status === "new").map((r) => r.query)),
        );
      })
      .finally(() => setLoading(false));
  }, [open, siteId]);

  const ex = exclude.trim().toLowerCase();
  const visible = rows.filter(
    (r) =>
      r.impressions >= minImpr &&
      (ex === "" || !r.query.toLowerCase().includes(ex)),
  );
  const importable = visible.filter(
    (r) => r.status === "new" && selected.has(r.query),
  );

  function toggle(query: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(query)) next.delete(query);
      else next.add(query);
      return next;
    });
  }

  function commit() {
    setPending(true);
    setDoneMsg(null);
    const payload = importable.map((r) => ({
      query: r.query,
      clicks: r.clicks,
      impressions: r.impressions,
      ctr: r.ctr,
      position: r.position,
    }));
    commitGscImportAction(siteId, payload, destination)
      .then((res) => {
        if (!res.ok) {
          setError(res.error);
          return;
        }
        setDoneMsg(`${res.created} mot(s)-clé(s) créé(s).`);
        onImported();
      })
      .finally(() => setPending(false));
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-3xl">
        <DialogHeader>
          <DialogTitle>Importer depuis Google Search Console</DialogTitle>
          <DialogDescription>
            Les requêtes déjà présentes dans le réservoir sont grisées (gérées
            par « Synchroniser GSC »).
          </DialogDescription>
        </DialogHeader>

        {loading ? (
          <p className="text-muted-foreground text-sm">Récupération des requêtes…</p>
        ) : error ? (
          <p className="text-destructive text-sm" role="alert">
            {error}
          </p>
        ) : (
          <div className="grid gap-3">
            <div className="flex flex-wrap items-end gap-3">
              <div className="grid gap-1">
                <Label htmlFor="gsc-min-impr">Min. impressions</Label>
                <Input
                  id="gsc-min-impr"
                  type="number"
                  min={0}
                  value={minImpr}
                  onChange={(e) => setMinImpr(Number(e.target.value) || 0)}
                  className="h-9 w-32"
                />
              </div>
              <div className="grid gap-1">
                <Label htmlFor="gsc-exclude">Exclure (texte)</Label>
                <Input
                  id="gsc-exclude"
                  value={exclude}
                  onChange={(e) => setExclude(e.target.value)}
                  placeholder="ex : marque"
                  className="h-9 w-48"
                />
              </div>
              <div className="grid gap-1">
                <Label>Envoyer vers</Label>
                <div className="flex gap-1">
                  <Button
                    type="button"
                    size="sm"
                    variant={destination === "reservoir" ? "default" : "outline"}
                    onClick={() => setDestination("reservoir")}
                  >
                    Réservoir
                  </Button>
                  <Button
                    type="button"
                    size="sm"
                    variant={destination === "recherche" ? "default" : "outline"}
                    onClick={() => setDestination("recherche")}
                  >
                    Recherche
                  </Button>
                </div>
              </div>
            </div>

            <div className="max-h-80 overflow-auto rounded-lg border">
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead className="w-10" />
                    <TableHead>Requête</TableHead>
                    <TableHead>Position</TableHead>
                    <TableHead>Clics</TableHead>
                    <TableHead>Impr.</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {visible.map((r) => (
                    <TableRow
                      key={r.query}
                      className={r.status === "existing" ? "opacity-40" : ""}
                    >
                      <TableCell>
                        {r.status === "new" ? (
                          <Checkbox
                            checked={selected.has(r.query)}
                            onCheckedChange={() => toggle(r.query)}
                            aria-label={`Sélectionner ${r.query}`}
                          />
                        ) : (
                          <Badge variant="secondary" className="text-[10px]">
                            présent
                          </Badge>
                        )}
                      </TableCell>
                      <TableCell>{r.query}</TableCell>
                      <TableCell className="tabular-nums">
                        {r.position.toFixed(1)}
                      </TableCell>
                      <TableCell className="tabular-nums">{r.clicks}</TableCell>
                      <TableCell className="tabular-nums">
                        {r.impressions}
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </div>

            <div className="flex items-center justify-between gap-2">
              <span className="text-muted-foreground text-sm">
                {importable.length} sélectionnée(s) · {visible.length} affichée(s)
                · {total} requêtes GSC
                {doneMsg ? ` · ${doneMsg}` : ""}
              </span>
              <Button
                onClick={commit}
                disabled={pending || importable.length === 0}
              >
                {pending ? "Import…" : `Importer (${importable.length})`}
              </Button>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

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

Run: `bunx tsc --noEmit` (exit 0). If `@/components/ui/label` does not exist, check `src/components/ui/` and either add the shadcn Label via the shadcn skill or replace the `<Label>` usages with a plain `<span className="text-sm font-medium">`.
```bash
bunx biome check --write src/components/keywords/import-gsc-dialog.tsx
git add src/components/keywords/import-gsc-dialog.tsx
git commit -m "feat(gsc): ImportGscDialog — preview, filter, select, destination"
```

---

## Task 6: Wire the owner-only button (toolbar + reservoir + page)

**Files:**
- Modify: `src/components/keywords/keywords-toolbar.tsx`
- Modify: `src/components/keywords/reservoir.tsx`
- Modify: `src/app/(app)/keywords/reservoir/page.tsx`

- [ ] **Step 1: Add the button + dialog to the toolbar**

Replace the contents of `src/components/keywords/keywords-toolbar.tsx` with:
```tsx
// src/components/keywords/keywords-toolbar.tsx
"use client";
import { PlusIcon, SearchIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { AddKeywordDialog } from "@/components/keywords/add-keyword-dialog";
import { ImportGscDialog } from "@/components/keywords/import-gsc-dialog";
import { ImportKeywordsDialog } from "@/components/keywords/import-keywords-dialog";
import { Button } from "@/components/ui/button";

export function KeywordsToolbar({
  siteId,
  onImported,
  canImportGsc,
}: {
  siteId: string;
  onImported: () => void;
  canImportGsc?: boolean;
}) {
  const [importOpen, setImportOpen] = useState(false);
  const [addOpen, setAddOpen] = useState(false);
  const [gscOpen, setGscOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setAddOpen(true)}>
        <PlusIcon /> Ajouter
      </Button>
      <Button variant="outline" onClick={() => setImportOpen(true)}>
        <UploadIcon /> Importer
      </Button>
      {canImportGsc ? (
        <Button variant="outline" onClick={() => setGscOpen(true)}>
          <SearchIcon /> Importer depuis GSC
        </Button>
      ) : null}
      <AddKeywordDialog
        open={addOpen}
        onOpenChange={setAddOpen}
        siteId={siteId}
        onAdded={onImported}
      />
      <ImportKeywordsDialog
        open={importOpen}
        onOpenChange={setImportOpen}
        siteId={siteId}
        onImported={onImported}
      />
      {canImportGsc ? (
        <ImportGscDialog
          open={gscOpen}
          onOpenChange={setGscOpen}
          siteId={siteId}
          onImported={onImported}
        />
      ) : null}
    </>
  );
}
```

- [ ] **Step 2: Thread `canImportGsc` through Reservoir**

In `src/components/keywords/reservoir.tsx`:
1. Change the component signature `export function Reservoir() {` to `export function Reservoir({ canImportGsc }: { canImportGsc?: boolean }) {`.
2. On the `<KeywordsToolbar ... />` usage (inside the `actions={...}` prop), add `canImportGsc={canImportGsc}`:
```tsx
        actions={
          <KeywordsToolbar
            siteId={activeSiteId}
            onImported={refresh}
            canImportGsc={canImportGsc}
          />
        }
```

- [ ] **Step 3: Resolve owner in the reservoir page and pass it down**

Replace `src/app/(app)/keywords/reservoir/page.tsx` with:
```tsx
// src/app/(app)/keywords/reservoir/page.tsx
import { headers } from "next/headers";
import { DashboardHeader } from "@/components/dashboard-header";
import { Reservoir } from "@/components/keywords/reservoir";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { auth } from "@/lib/auth";

export default async function Page() {
  const member = await auth.api.getActiveMember({ headers: await headers() });
  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>Keywords</BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Reservoir</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <Reservoir canImportGsc={member?.role === "owner"} />
    </>
  );
}
```

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

```bash
bunx tsc --noEmit
bunx biome check --write src/components/keywords/keywords-toolbar.tsx src/components/keywords/reservoir.tsx "src/app/(app)/keywords/reservoir/page.tsx"
git add src/components/keywords/keywords-toolbar.tsx src/components/keywords/reservoir.tsx "src/app/(app)/keywords/reservoir/page.tsx"
git commit -m "feat(gsc): owner-only Importer depuis GSC button on reservoir"
```

---

## Task 7: Full checks + live verification

- [ ] **Step 1: Static 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/components/keywords "src/app/(app)/keywords/reservoir/page.tsx"
```
Expected: tsc 0; all gsc tests pass; biome clean.

- [ ] **Step 2: Live (d3k) on the blank `acieb` site**

`d3k agent-browser open http://localhost:3000/keywords/reservoir` (active site Acieb, mapped `sc-domain:aciebenergie.fr`, as owner):
- Click "Importer depuis GSC" → dialog loads the GSC queries (≈422), sorted by impressions, all "new" checked.
- Set "Min. impressions" and "Exclure" (e.g. the brand) → the list filters; "Importer (N)" updates.
- Keep destination "Réservoir" → Importer → "X créé(s)"; reservoir now shows the created keywords with a country flag, GSC badge + position, vol/kd/cpc as N/A.
- Re-open and import again → 0 created (idempotent), or rows now show as "présent".
- Switch destination to "Recherche" on a fresh selection → those keywords appear in `/keywords/recherche` with the Pos. GSC column populated.
- Run "Synchroniser GSC" on recherche afterwards → overlays positions without duplicating.
- `d3k errors` clean.

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

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

---

## Notes / risks

- The wasm artifacts under `src/lib/backlink/import-core/` are gitignored — Task 1 commits only `lib.rs`; every environment must run `bun run build:wasm`. The dev server picks up the rebuilt package automatically.
- `createGscKeywordsForSite` uses `onConflictDoNothing` on `keywords.id`, so it never clobbers an existing keyword and re-import is idempotent; existing terms are also filtered out client-side (greyed in the preview).
- GSC-only keywords carry no vol/kd/cpc (`metrics: {}`); all table readers already guard with `?.`, so they render as N/A. A later Mangools/Semrush import of the same term shares the id and merges metrics.
- Sync stays overlay-only and untouched; the two GSC actions are cleanly separated.
- Branch: `feature/gsc-discovery` (already created off `dev`).
