# GSC Discovery — Create keywords from GSC queries (Slice 4) — Design

- **Date**: 2026-06-06
- **Status**: approved (design), pending spec review
- **Section**: Google Search Console → keywords / reservoir
- **Depends on**: slice 2 (GSC overlay — `keywords.gsc`, `querySearchAnalytics`) and slice 3 (table UX) on `dev`.

## Problem

GSC sync (slice 2) only *enriches existing* keywords — a blank site stays empty
even though GSC returns hundreds of ranking queries (e.g. 422 for
`sc-domain:aciebenergie.fr`). Users expect GSC to **populate** a site with the
queries it ranks for. This slice adds **discovery**: create keywords from GSC
queries, with a curated preview so the noisy GSC tail doesn't pollute the
reservoir.

## Goal

A manual owner-only **"Importer depuis GSC"** action on the reservoir that opens
a preview dialog of the site's GSC queries (position/clicks/impressions),
lets the user filter and select, and creates the selected unknown queries as
keywords (with their GSC positioning), routed to the Réservoir or directly to
Recherche.

## Core principles

- **Sync stays overlay-only**, unchanged. The new Import handles *creation* of
  unknown queries. Clean split: **Sync = enrich existing; Import = create new.**
- **Curated, not a dump.** GSC query data is noisy (brand, long-tail, typos), so
  creation goes through a **preview + filter + select** step (mirrors the
  Mangools/Semrush CSV import UX).
- **GSC is still a positioning overlay.** Created keywords carry no vol/kd/cpc
  (those come from SEO tools); their `gsc` overlay holds the positioning.

## Decisions (from brainstorming)

- **Mechanism**: a separate **"Importer depuis GSC"** action with a preview
  dialog (NOT folded into the one-click Sync).
- **Destination**: a **toggle in the dialog** — Réservoir (default, `stages:[]`)
  or Recherche (`stages:["research"]`).
- **Dedup / id**: created keywords use the **same deterministic id as the CSV
  import** (`${siteId}-${slug}`) so a later Mangools/Semrush import of the same
  term **merges** (adds vol/kd/cpc) instead of duplicating. To guarantee an
  identical slug, **expose the rust `slugify` from `import-core` via
  wasm-bindgen** and reuse it (consistent with the "rust maximal" principle).
- **Noise control**: preview filters — a **min-impressions** input and a
  free-text **"exclure"** box (e.g. the brand term). Queries already matching an
  existing keyword are shown **greyed/non-selectable** ("déjà présent", handled
  by Sync).

## Data model — a GSC-discovered keyword

No schema change. Each created keyword:

```ts
{
  id: `${siteId}-${slug(query)}`,
  siteId,
  term: query,                 // original query text
  lang: site.primaryLanguage,
  country: site.targetCountries[0] ?? "US",
  theme: null,
  status: "idea",
  intent: [],
  stages: destination === "recherche" ? ["research"] : [],
  primarySource: "gsc",        // valid KeywordSource
  sources: ["gsc"],
  metrics: {},                 // no vol/kd/cpc → tables show N/A (readers use ?.)
  gsc: { clicks, impressions, ctr, position, updatedAt: now },
  trend: [],
}
```

Verified safe: `metrics` is `Partial<Record<KeywordSource, SourceMetrics>>`; all
table readers use `metrics[primarySource]?.…  ?? 0/null`; `"gsc"` is in
`KeywordSource`.

## Components / files

```
crates/import-core/src/lib.rs            — expose `#[wasm_bindgen] pub fn slugify(s) -> String`
src/lib/backlink/import-core/*           — rebuilt wasm artifacts (bun run build:wasm)
src/lib/gsc/discovery.ts                 — createGscKeywordsForSite(siteId, rows, opts)
src/lib/gsc/discovery.test.ts            — DB-gated: create shape, idempotent, skip existing, stages
src/lib/gsc/import-actions.ts            — previewGscImportAction + commitGscImportAction (owner-gated)
src/lib/gsc/import-types.ts              — GscImportRow, GscImportPreview types
src/components/keywords/import-gsc-dialog.tsx — preview dialog (filters, select, destination)
src/components/keywords/keywords-toolbar.tsx  — add "Importer depuis GSC" button (owner-only)
```

`querySearchAnalytics` (slice 2) is reused as-is. The new code lives under
`src/lib/gsc/` and a dedicated dialog component, keeping each unit focused.

## Flow

### `previewGscImportAction(siteId): GscImportPreview`
1. Owner-gate; verify site in active org; read `gscProperty` (error if null) +
   token (error if not connected).
2. `querySearchAnalytics(token, property, { days:28, rowLimit:25000 })`.
3. Load existing keyword terms for the site (`lower(term)` set).
4. Return rows sorted by impressions desc:
   `{ query, clicks, impressions, ctr, position, status: "new" | "existing" }`
   plus `{ total }`.

### Dialog (`ImportGscDialog`)
- On open: calls `previewGscImportAction`; shows a loading state.
- **Filters**: min-impressions number input; free-text "exclure" box (case-
  insensitive substring, excludes matching queries from the visible/selectable
  list). Default min-impressions = 1.
- **Rows**: checkbox per `new` row (default: all visible `new` checked);
  `existing` rows greyed + disabled with an "déjà présent" badge.
- **Destination** toggle: Réservoir (default) / Recherche.
- Footer: "Importer (N)" where N = selected count.

### `commitGscImportAction(siteId, queries, destination): { created: number }`
1. Owner-gate; resolve site lang/country.
2. `createGscKeywordsForSite(siteId, queries, { lang, country, stages })`.
3. `revalidatePath("/keywords/reservoir")` (and `/keywords/recherche` when
   destination is recherche).

### `createGscKeywordsForSite(siteId, rows, opts)`
- For each row: build the keyword shape above; `db.insert(...).onConflictDoNothing({ target: keywords.id })`
  so re-import is idempotent and never clobbers an existing keyword.
- Return `{ created }` (rows actually inserted).

## Error handling

- Unmapped property / not connected → actionable French error in the dialog.
- GSC API failure → `{ ok:false, error:"Échec de la récupération GSC." }`; the
  dialog shows it, never crashes the page.
- 0 queries returned → info state ("Aucune requête GSC sur 28 jours.").

## Testing

- **wasm**: after exposing `slugify`, a quick assertion that
  `wasm.slugify("Panneau Solaire!") === "panneau-solaire"` (matches the CSV
  import slug for the same term).
- **Unit** (pure): preview row annotation (new vs existing by lower term);
  filter predicate (min impressions + exclude substring).
- **DB-gated** (`discovery.test.ts`, `_test`, `--conditions=react-server`):
  creates a keyword with `primarySource:"gsc"`, empty metrics, `gsc` set;
  idempotent (second run creates 0); skips a term that already exists; routes
  stages per destination.
- **Manual (d3k)**: blank acieb site → reservoir → "Importer depuis GSC" →
  preview shows the 422 queries → filter (min impr, exclude brand) → select →
  import to Réservoir (and a second run to Recherche) → keywords appear with the
  GSC badge + position, vol/kd/cpc N/A; re-import creates 0; running Sync after
  overlays fresh positions.

## Non-goals (YAGNI)

- Scheduled/background GSC import (rust worker = slice 6).
- Per-page / per-country dimensions (slice 5).
- Editing or back-filling vol/kd/cpc for GSC-only keywords.
- Brand auto-detection (the free-text "exclure" box covers it).
- Changing the existing Sync behavior.

## Open items for the plan

- Confirm `bun run build:wasm` regenerates `import_core.js` with the new
  `slugify` export and that `import * as wasm from "import-core"` picks it up
  (artifacts under `src/lib/backlink/import-core/`).
- Confirm the reservoir toolbar (`keywords-toolbar.tsx`) is the right host for
  the button and how owner-gating is resolved there (reuse the recherche
  `canSync` pattern or `getActiveMember`).
- Confirm `querySearchAnalytics` returns `ctr` and `position` already (it does,
  from slice 2 `GscQueryRow`).
