# Design — Feature #1 : keywords reservoir lit la DB

Date : 2026-06-05
Branche : `feature/keywords-reservoir-db`

> Première des **2 features** décidées : (#1, ici) brancher la lecture du keywords
> reservoir sur la DB ; (#2, plus tard) l'import (add manuel + CSV, Rust-WASM,
> colonnes complètes). Cette spec ne couvre que #1.

## Contexte

`/keywords/reservoir` lit aujourd'hui les **fixtures** (`getKeywordsForSite`,
`src/lib/keywords/sites.ts`, qui filtre `KEYWORDS` de `fixtures.ts`) — pas la DB.
La table DB `keywords` (`src/lib/db/domain-schema.ts`) **existe et est seedée** (31
lignes, mappées depuis `KEYWORDS` par `db:seed`). Les keywords sont **scopés au
site** (`keywords.siteId` FK → `sites.id`, `onDelete cascade`). Le site actif est
un **état client** (`useActiveSite()` du vrai `SiteContext`) qui change sans reload
via le team switcher.

Différence de mapping : le type UI `Keyword` (`src/lib/keywords/types.ts`) imbrique
`locale: { lang, country }`, alors que la table a des colonnes plates `lang` /
`country`. Le reste correspond 1:1 (`theme`, `status`, `intent[]`,
`primarySource`, `sources[]`, `metrics` jsonb, `trend` jsonb, `serpFeatures[]`).

Objectif : la page reservoir lit la **DB** (scopée au site actif), pour que les
keywords importés (feature #2) y apparaissent. C'est le prérequis qui existait déjà
pour le backlink mais pas pour les keywords.

## Périmètre

- **Seulement le reservoir** migre vers la DB.
- La fonction **fixture** `getKeywordsForSite` (`lib/keywords/sites.ts`) **reste**
  — elle est encore utilisée par la page **Research**
  (`src/components/keywords/research/research-container.tsx`) et le test
  `fixtures.test.ts`. On ne la supprime pas (hors scope). Le reservoir cesse
  simplement de l'importer.

## Modèle de lecture (client-driven via server action)

Le site actif étant client et changeant sans reload, la donnée doit refetch
côté client au changement de site → on passe par une **server action** appelée
depuis le composant client (plutôt qu'un Server Component avec org stub comme le
backlink).

- **`src/lib/keywords/queries.ts`** (nouveau, `server-only`) :
  `getKeywordsForSite(siteId: string): Promise<Keyword[]>` —
  `db.select().from(keywords).where(eq(keywords.siteId, siteId)).orderBy(asc(keywords.term))`
  → map chaque row → `Keyword` via un helper `toKeyword(row)` :
  `locale: { lang: r.lang, country: r.country }` ; `serpFeatures: r.serpFeatures ?? undefined` ;
  le reste copié directement (`theme`, `status`, `intent`, `primarySource`,
  `sources`, `metrics`, `trend`, `id`, `siteId`, `term`).
  > Note : ce nom (`getKeywordsForSite`) **coexiste** avec celui des fixtures
  > (modules différents) — c'est intentionnel ; le reservoir importera la version
  > DB depuis `@/lib/keywords/queries`, Research garde la version fixture depuis
  > `@/lib/keywords/sites`.

- **`src/lib/keywords/actions.ts`** (nouveau, `"use server"`) :
  `fetchKeywordsForSiteAction(siteId: string): Promise<Keyword[]>` — wrappe
  `getKeywordsForSite` (importée de `./queries`). Appelable depuis le client.
  (Type `Keyword` réimporté pour la signature ; un `"use server"` ne peut exporter
  que des fonctions async — pas de type/const exporté ici, donc OK.)

- **`src/components/keywords/reservoir.tsx`** (modifié) :
  - remplacer `import { getKeywordsForSite } from "@/lib/keywords/sites"` par
    `import { fetchKeywordsForSiteAction } from "@/lib/keywords/actions"`.
  - dans le `useEffect([activeSiteId])` existant (qui fait déjà `setLoading(true)`
    + un `setTimeout(200)`), remplacer l'appel synchrone
    `setRows(getKeywordsForSite(activeSiteId))` par un appel **async** :
    charger via `fetchKeywordsForSiteAction(activeSiteId)`, `setRows(...)`,
    `setLoading(false)`, avec garde anti-race (ignorer la réponse si `activeSiteId`
    a changé entre-temps — pattern `let cancelled = false` dans l'effet). Retirer le
    `setTimeout` artificiel (le vrai await fournit le délai de chargement). Garder
    `setRowSelection({})` au changement de site.

Le reste du composant (filtres, DataTable, sheet, bulk bar, skeleton) **inchangé** —
il consomme déjà `rows: Keyword[]`.

## Tests

- **`src/lib/keywords/queries.test.ts`** (DB-gated `DB_TESTS=1` + `assertTestDb`,
  lancé contre `seo_insight_test`) :
  - `getKeywordsForSite` ne renvoie que les keywords du site demandé (isolation) ;
  - le mapping `lang`/`country` → `locale: { lang, country }` est correct ;
  - `metrics` / `trend` (jsonb) reviennent intacts.
  Seed minimal dans le test : insérer un site + 2 keywords (1 du site, 1 d'un autre
  site) — le `seo_insight_test` a déjà l'org/sites seedés ; insérer des keywords
  ad hoc et nettoyer en `beforeEach` (delete keywords, garde anti-prod).
- **Live d3k** : sur `/keywords/reservoir`, la table affiche les keywords du site
  actif (identiques aux fixtures pour les 3 sites seedés) ; changer de site via le
  switcher change les lignes ; un site sans keyword affiche le reservoir vide.

## Hors scope (feature #2)

- Add manuel + import CSV + gabarit + crate Rust-WASM keyword (colonnes complètes :
  `term, lang, country, theme, status, intent, primary_source, volume, kd, cpc` →
  `metrics[primary_source]`, trend vide).
- Migration de la page **Research** vers la DB (reste sur fixtures).
- Écriture / dédup / upsert keywords (c'est #2).
