# `/backlink/recherche` — prospection multi-sites depuis le pool global

**Date:** 2026-06-10 · **Linear:** [ELEVA-62](https://linear.app/elevaseo/issue/ELEVA-62) · **CdC:** `docs/specs/backlink/recherche.md`
**Périmètre v1 (brainstorm):** F1 contexte site · F2 liste · F3 score · F4 ciblage multi-sites · F5 footprint · F7 push planning. **Différé:** F6 shortlist, F8 matrice source×site, DnD (R2), TF/CF/trafic (pas de données).

**Réalité des données (vérifiée):** sources seedées 1140, **toutes `language='en'`**, `themes` vides partout ; sites sans thème topical ; `backlinks` sans contrainte d'unicité (index simple, 0 doublon actuel) ; statuts `prospect|planned|live|lost`.

---

## A. Score de pertinence (F3) — pur, TDD

`src/lib/backlink/relevance.ts` :

```ts
export function relevanceScore(source: { language: string; dr: number | null; da: number | null }, siteLanguage: string): number {
  const langMatch = source.language === siteLanguage ? 1 : 0.3;
  const strength = source.dr ?? source.da ?? 0; // 0-100
  return Math.round(langMatch * strength * 10) / 10;
}
```

Le terme **thématique** du CdC (TF thématique) reste structurellement absent v1 (aucune donnée des deux côtés) — il s'ajoutera comme multiplicateur quand `link_sources.themes` et un thème de site existeront. Tri par défaut = score desc.

**Footprint (F5)** : `footprintAlert(count: number): boolean` → `count >= 3` (seuil constant v1 ; le CdC pose la question « configurable ? » → plus tard).

## B. Unicité `backlinks` — migration

`backlinks` n'a qu'un index simple sur `(link_source_id, site_id)`. On le passe en **`uniqueIndex`** (0 doublon vérifié en dev) — Drizzle : remplacer `index("backlinks_source_site_idx")` par `uniqueIndex("backlinks_source_site_uq")` → migration `db:generate`+`migrate`+`migrate:test`. C'est le socle de l'`onConflictDoNothing` du multi-assign.

## C. Données — `src/lib/backlink/research.ts`

`getResearchPool(orgId, siteId): Promise<ResearchRow[]>` :
- Pool = `linkSources` de l'org + `dr` résolu (réutilise la logique DR par domainNorm du réservoir).
- Une requête groupée sur `backlinks` (jointe aux sources de l'org) → par source : `footprint` = `count(distinct site_id)`, `targetedByCurrent` = bool (existe une ligne pour `siteId`), `currentStatus` = le statut pour le site courant (null sinon).
- `score = relevanceScore(source, site.primaryLanguage)` ; `alert = footprintAlert(footprint)`.
- `ResearchRow = LinkSource & { score: number; footprint: number; alert: boolean; targetedByCurrent: boolean; currentStatus: BacklinkStatus | null }`.

### Actions — `src/lib/backlink/research-actions.ts` (`"use server"`)
- `assignProspectsAction(sourceIds: string[], siteIds: string[]): Promise<{ created: number; skipped: number }>` — N×M lignes `backlinks` (`id: randomUUID`, status `prospect`), **`onConflictDoNothing` sur l'unique** ; created = lignes réellement insérées (`.returning()`), skipped = N×M − created. Revalidate `/backlink/recherche` + `/backlink/reservoir`.
- `pushProspectsToPlanningAction(siteId: string, sourceIds: string[]): Promise<{ updated: number }>` — `UPDATE backlinks SET status='planned' WHERE site_id AND link_source_id IN (...) AND status='prospect'` (`.returning()` pour le compte ; ne touche pas live/lost).

## D. Page & UI

- **RSC** `src/app/(app)/backlink/recherche/page.tsx` (pattern réservoir) : org → sites (pour les pills multi-select) → site actif → `getResearchPool` → container. Breadcrumb Backlink → Recherche. **Sidebar** : « Recherche » entre Reservoir et Profil.
- **`research-container.tsx`** (client, seedé RSC, zéro fetch on mount ; refresh post-action via `router.refresh()`) :
  - Filtres : recherche (domaine/platform), type (kind), langue (**défaut « toutes »** — le seed est 100 % en, un défaut langue-du-site viderait la liste ; le score fait la priorisation), toggle « Masquer les déjà ciblés ».
  - **Table** (DataTable + pagination) : Domaine (platform + domainNorm) · DR (DrBadge `dr ?? da`) · Type (KindBadge) · Langue · Effort · **Score** (tri défaut desc) · **Footprint** (nombre + badge ⚠️ destructive si alert) · **Ciblé** (✓/statut courant pour le site actif).
  - **Barre de sélection** (pattern TargetingBar) quand ≥1 source cochée : pills **sites cibles** togglables (tous les sites de l'org, défaut = site actif coché) + bouton **« Créer prospects (N source(s) × M site(s)) »** → action → toast `{created} créés, {skipped} déjà existants` → clear + refresh. + bouton **« → Planning »** (agit sur le site actif uniquement : prospects sélectionnés → planned, toast).
  - **Sheet détail** au clic ligne : métriques source, score décomposé (langue ×, force), footprint détaillé (liste des sites qui la ciblent), statut courant.
- Conventions UI existantes : `SelectContent position="popper"`, badges existants (DrBadge/KindBadge), pagination shadcn.

## E. Tests

- **Purs (TDD)** : `relevanceScore` (langue match=1/mismatch=0.3, dr prioritaire sur da, null→0, arrondi) ; `footprintAlert` (2→false, 3→true).
- **DB** : `getResearchPool` (footprint multi-sites, targetedByCurrent, currentStatus, score) ; `assignProspectsAction` 2 sources × 2 sites = 4 créés puis re-run = 0 créés/4 skipped ; `pushProspectsToPlanningAction` ne touche que les prospects (live intact). Migration : unique violée → conflit silencieux.
- **Live (d3k)** : Acieb — tri par score (DR réels × 0.3), sélection 2 sources → pills 2 sites → créer 4 prospects (vérif DB), badge footprint, → Planning (statut planned en DB), sheet détail.

## Hors scope

F6 shortlist · F8 matrice · DnD R2 · seuil footprint configurable · TF/CF/trafic · thème dans le score (structure prête, données absentes) · page `/backlink/planning` (consommatrice des `planned`, chantier suivant).
