# `/backlink/planning` — campagne de netlinking par site

**Date:** 2026-06-10 · **Linear:** [ELEVA-63](https://linear.app/elevaseo/issue/ELEVA-63) · **CdC:** `docs/specs/backlink/planning.md`
**Réponses v1 aux questions ouvertes du CdC:** outreach = **simple suivi** (pas d'envoi) · campagnes (F8) = non · **re-check auto live/lost différé** (exige un worker crawler ; passage live/lost manuel sur le board) · vue calendrier différée · priorité manuelle persistée différée (tri par score).

**Périmètre v1:** F1 contexte site · F2 board kanban DnD · F3 priorisation par score · F4 outreach léger · F5 partiel (toxic→disavow export ; re-check différé) · F6 footprint sur cartes · F7 cadence.

---

## A. Modèle (1 migration)

1. **Statut** : `BacklinkStatus` (domain-schema) devient `"prospect" | "planned" | "in_progress" | "live" | "lost"` (colonne text — pas de changement DB ; les `Record<BacklinkStatus,…>` existants dans `research-columns.tsx` et `source-detail-sheet.tsx` gagnent la clé `in_progress`, tsc les force).
2. **`backlinks`** : + `toxic boolean notNull default false`, + `disavow boolean notNull default false`.
3. **`backlink_outreach`** : `backlinkId text PK` (FK → backlinks cascade), `contact text`, `channel text`, `attempts integer notNull default 0`, `lastContactAt timestamptz`, `notes text`, `updatedAt timestamptz notNull defaultNow`. Une ligne par backlink (upsert).

Migration via `db:generate` + `db:migrate` + `db:migrate:test`.

## B. Données — `src/lib/backlink/planning.ts` (server-only)

```ts
export type PlanningCard = {
  backlinkId: string;
  status: BacklinkStatus;
  toxic: boolean;
  disavow: boolean;
  firstSeen: string | null;        // ISO
  source: {                         // joint depuis linkSources (+ DR)
    id: string; platform: string; domainNorm: string; url: string;
    kind: SourceKind; dr: number | null; da: number | null; language: string;
  };
  score: number;                    // relevanceScore (langue du site × DR??DA) — réutilisé
  footprint: number;                // sites de l'org référencés par la source
  alert: boolean;                   // footprintAlert(footprint)
  outreach: {
    contact: string | null; channel: string | null; attempts: number;
    lastContactAt: string | null; notes: string | null;
  } | null;
};
```

- `getPlanningBoard(orgId, siteId): Promise<PlanningCard[]>` — backlinks du site (**isolation stricte par site** — critère CdC) joints aux sources de l'org, DR par domainNorm, footprint groupé (mêmes requêtes que research), outreach left-join. Tri par `score` desc (F3 v1).
- `getCadence(siteId): Promise<{ thisMonth: number; lastMonth: number }>` — count des backlinks du site avec `firstSeen` dans le mois courant / précédent (UTC). Pure helper `monthBounds(now)` TDD.
- **Disavow** : pure `buildDisavowFile(domains: string[]): string` — lignes `domain:<domainNorm>`, dédupliquées, triées, TDD. Query : domainNorms des backlinks du site flagués `disavow`.

### Actions — `src/lib/backlink/planning-actions.ts` (`"use server"`)
- `moveBacklinkAction(siteId, backlinkId, status)` — UPDATE scopé `(id AND site_id)` ; si `status === "live"` : `firstSeen = coalesce(firstSeen, now())`. Retour `{ ok }`.
- `upsertOutreachAction(siteId, backlinkId, patch: { contact?, channel?, attempts?, lastContactAt?, notes? })` — vérifie l'appartenance du backlink au site, puis upsert `backlink_outreach` (`onConflictDoUpdate` sur backlinkId, `updatedAt = now()`).
- `setBacklinkFlagsAction(siteId, backlinkId, flags: { toxic?, disavow? })` — UPDATE scopé site.
- `fetchDisavowDomainsAction(siteId): Promise<string[]>` — domainNorms flagués disavow (le client génère le fichier via `buildDisavowFile`, download côté client comme l'export enrichissement).
- Tous : `revalidatePath("/backlink/planning")`.

## C. UI — `src/components/backlink/planning/`

- **RSC** `src/app/(app)/backlink/planning/page.tsx` : org → sites → site actif → `getPlanningBoard` + `getCadence` → container. Breadcrumb Backlink → Planning. **Sidebar** : « Planning » après Recherche.
- **`planning-container.tsx`** (client, seedé RSC, zéro fetch on mount, refresh post-action `router.refresh()`) : bandeau cadence (« {thisMonth} lien(s) live ce mois · {lastMonth} le mois dernier » + compteurs par colonne) + toolbar (bouton « Exporter disavow ({n}) » actif si n>0 — fetch action → `buildDisavowFile` → download `disavow-{siteId}.txt`) + le board.
- **`planning-board.tsx` / `planning-column.tsx` / `planning-card.tsx`** — **miroir exact du pattern content-board** (`@dnd-kit/core` : DndContext + sensors, useDroppable par colonne, useDraggable par carte, `onDragEnd` → `moveBacklinkAction` optimiste + refresh). 5 colonnes : Prospect · Planifié · Outreach (`in_progress`) · Live · Perdu. Carte : platform (medium) + domainNorm (muted), DrBadge, score, badge ⚠️ footprint si alert, badge ☠️ si toxic. Clic carte (hors drag) → sheet.
- **`backlink-detail-sheet.tsx`** : SheetTitle platform, SheetDescription url. Métriques (DR, score, kind, langue, footprint). **Bloc outreach** : Inputs contact / canal (Select : email · form · linkedin · twitter · autre, position popper) / relances (compteur ±) / dernière prise de contact (date) / notes (Textarea) + bouton « Enregistrer » → `upsertOutreachAction` (toast). **Toggles** « Toxic ☠️ » et « Disavow » (Switch ou Button pressed) → `setBacklinkFlagsAction`. Statut + select de statut (alternative clavier au drag — A11y board opérable clavier, critère CdC).

## D. Tests

- **Purs (TDD)** : `monthBounds` (bornes UTC, fin d'année) ; `buildDisavowFile` (format `domain:`, dédup, tri, vide → "").
- **DB** : `getPlanningBoard` isolation par site (un backlink d'un autre site n'apparaît pas — critère CdC) + jointures score/footprint/outreach ; `moveBacklinkAction` pose firstSeen sur live (et ne l'écrase pas) + scope site (move d'un backlink d'un autre site = no-op) ; `upsertOutreachAction` upsert + scope ; `setBacklinkFlagsAction` ; `getCadence` (firstSeen ce mois/mois dernier).
- **Live (d3k, vrais événements pointeur)** : board 5 colonnes sur Acieb, drag d'une carte Prospect→Planifié (DB vérifiée), sheet outreach (saisie contact + relance), toggle disavow → bouton export actif → contenu du fichier, cadence après passage live, sélecteur de statut clavier.

## Hors scope

Re-check auto live/lost (worker crawler — queue transverse TBD) · campagnes/lots (F8) · vue calendrier · priorité manuelle persistée (ordre intra-colonne) · `last_seen` · rank-tracking.
