# Design — Reservoir backlinks : ajout de sources (Rust-WASM core + CSV/manuel)

Date : 2026-06-04
Branche : `feature/reservoir-import`

## Contexte

`/backlink/reservoir` = pool **GLOBAL** de link sources, déjà **org-scopé**
(`linkSources.orgId`, partagé pour toute la team de l'org). Table existante
`linkSources` (`src/lib/db/domain-schema.ts`) : `id` (PK = slug path-aware dérivé
de l'URL, ex. `reddit-com-r-plugyourproduct`), `platform`, `url`, `domainNorm`,
`kind`, `da` (nullable), `effort`, `linkType`, `language`, `themes[]`, `tags[]`,
`flags{toxic,disavow}`, `note`. Index non-unique `(orgId, domainNorm)`. Seedée à
~380 sources depuis un TSV. Helpers existants : `normalizeUrl`, `domainNorm`,
`dedupeMerge` (`src/lib/backlink/normalize.ts`), enums + `KIND_FROM_CATEGORY`
(`src/lib/backlink/types.ts`).

Objectif : permettre d'**ajouter** des sources au reservoir (org-scopé) via
**import CSV** (avec gabarit téléchargeable) **et** un **ajout manuel** d'une
source. La logique lourde est écrite en **Rust**, dans un **module séparé**,
conçu pour devenir plus tard un **worker à file d'attente sur un serveur à part**.

Décisions de cadrage (validées) :
- Modes : **CSV en masse + ajout manuel (1 source)**.
- UX import : **modal dropzone + preview** (convention R2 : drag-and-drop,
  clavier-accessible).
- Doublons : **upsert** (la source existante est mise à jour par les valeurs du
  CSV ; nouvelle = insert).
- Gabarit : **colonnes complètes** (`url, platform, category, da, effort,
  link_type, language, themes, tags, note`).
- Binding Rust : **WASM in-process** (wasm-bindgen, target nodejs), appelé depuis
  les server actions.

## Frontière des responsabilités

- **Rust `import-core` = transformation pure** : CSV/ligne → rows canoniques
  validées + normalisées. **Aucun I/O, aucune DB.**
- **Next = persistance Drizzle + UI.**
- **Futur worker** (serveur à part, file d'attente) = `import-core` natif +
  absorbe la persistance. Le crate est la frontière stable.

## 1. Crate Rust `crates/import-core`

Workspace cargo à la racine (`Cargo.toml` workspace + `crates/import-core/`).

Dépendances : `csv` (parser robuste RFC4180), `url` (normalisation), `serde` +
`serde_json`, `wasm-bindgen`, `serde-wasm-bindgen`.

Logique pure (testée `cargo test`) :
- `normalize_url`, `domain_norm` (parité avec le TS existant : https forcé, host
  minuscule, drop `www.`, strip slash final).
- `slug_id(url)` : id déterministe **path-aware** = `domainNorm` + path, kebab-case
  (`https://www.reddit.com/r/saas` → `reddit-com-r-saas`). Doit reproduire le
  format des ids seedés (vérifié par tests sur des exemples des fixtures).
- `category → kind` (port de `KIND_FROM_CATEGORY`, fallback `other`).
- Défauts : effort `medium`, linkType `dofollow`, language `en`, kind `other`,
  da `null`, themes/tags `[]`, flags `{false,false}`.
- Validation par ligne : `url` requise + parsable, `platform` requise (non vide),
  `da` ∈ [0,100] ou vide, `effort`/`link_type`/`kind` ∈ enums (sinon défaut ou
  erreur selon le champ), `language` 2 lettres (défaut `en`). `themes`/`tags`
  splittés sur `,` (ou `;`) et trimés.

API WASM (`wasm-bindgen`, **target `nodejs`**) :
- `template_csv() -> String` — **source de vérité unique** du gabarit : header
  complet + 2 lignes exemples.
- `parse_csv(csv: &str) -> JsValue` — `Vec<RowResult>` en JSON.
- `normalize_row(input: JsValue) -> JsValue` — un `RowResult` (ajout manuel).

Types de bord :
```
RowResult = { ok: bool, error?: string, source?: CanonicalSource }
CanonicalSource = { id, platform, url, domainNorm, kind, da, effort,
                    linkType, language, themes, tags, note }
```
(`flags` non issus du CSV → défaut `{toxic:false, disavow:false}` posé côté insert.)

Build : `wasm-pack build crates/import-core --target nodejs --out-dir
src/lib/backlink/import-core` → pkg importable (CommonJS + `.wasm` + `.d.ts`).
Script `package.json` `build:wasm` ; hook `prebuild` l'exécute ; pkg **gitignoré**
(artefact de build). Prérequis : `wasm-pack` (à installer ; `cargo install
wasm-pack` ou brew).

## 2. Next — server actions (`src/lib/backlink/`)

- `import.ts` (nouveau) — wrappe le pkg WASM (import server-only) :
  - `previewImportAction(csvText: string)` : `parse_csv` → classe chaque row ok
    en `new` / `update` (1 query des ids existants de l'org via
    `getActiveOrgId`) → renvoie `{ rows: PreviewRow[], counts }`. **Aucune
    écriture.**
  - `commitImportAction(csvText: string)` : **re-parse côté serveur** (jamais de
    confiance aux rows envoyées par le client) → `upsertLinkSources(orgId, okRows)`
    → `revalidatePath("/backlink/reservoir")` → renvoie les compteurs.
  - `addSourceAction(input)` : `normalize_row(input)` → si ok
    `upsertLinkSources(orgId, [row])` → revalidate. Renvoie `{ok}` / `{error}`.
- `upsertLinkSources(orgId, rows)` (helper Drizzle, dans `actions.ts` ou
  `import.ts`) : `db.insert(linkSources).values(...).onConflictDoUpdate({ target:
  linkSources.id, set: {...} })`. `id = slugId` (déjà calculé par le core). Tous
  les rows reçoivent `orgId`. Le `set` de l'update couvre les colonnes issues du
  CSV (`platform, url, domainNorm, kind, da, effort, linkType, language, themes,
  tags, note`) mais **exclut `flags`** (toxic/disavow absents du CSV → on ne
  clobber pas un flag posé à la main). `orgId`/`id` ne sont pas dans le `set`.
- Org-scopé via `getActiveOrgId()`. **Role-gate owner/admin différé** (la session
  Better Auth n'alimente pas encore `getActiveOrgId/UserId`).

Note multi-tenant (différé) : `linkSources.id` étant un slug global (PK), deux
orgs partageant une URL entreraient en collision d'id. Acceptable tant que la
session est mono-org (`demo-org`) ; à reprendre (id composite ou
`unique(orgId, id)`) quand le multi-org réel arrivera. Conserver `id = slugId`
pour rester cohérent avec les 380 sources seedées (l'upsert dédoublonne contre
elles).

## 3. Gabarit téléchargeable

Route handler `src/app/(app)/backlink/reservoir/template/route.ts` :
`GET` → `import_core.template_csv()` avec
`Content-Type: text/csv; charset=utf-8` +
`Content-Disposition: attachment; filename="reservoir-template.csv"`.
Le bouton UI = `<a href="/backlink/reservoir/template" download>`.

## 4. UI (`src/components/backlink/`, shadcn via le skill)

- Toolbar dans `reservoir-container` (header) : **`+ Ajouter une source`** ·
  **`Importer CSV`** · **`Gabarit ↓`** (lien download).
- `import-sources-dialog.tsx` (client) : `Dialog` shadcn avec **dropzone**
  (zone drag-and-drop + bouton « parcourir » + `<input type=file>` caché,
  clavier-accessible) → lit `file.text()` côté client → `previewImportAction(text)`
  → **Table preview** shadcn (colonnes clés + badge statut `nouvelle` /
  `mise à jour` / `erreur`+message) → ligne de compteurs « N nouvelles ·
  M mises à jour · Z erreurs » → bouton `Importer (N+M)` (désactivé si 0) →
  `commitImportAction(text)` → toast/résumé + ferme.
- `add-source-dialog.tsx` (client) : `Dialog` + form (url, platform, category
  `Select`, da, effort `Select`, link_type `Select`, language, themes, tags,
  note) → `addSourceAction(input)`. url + platform requis ; reste optionnel
  (défauts posés par le core).
- Composants shadcn requis : Dialog (présent), Button/Input/Select/Table/Badge
  (présents). Pas de nouveau primitive a priori (dropzone = markup simple).

## Flux de données (import)

```
fichier .csv ─(client file.text())→ previewImportAction(text)
   → [WASM] parse_csv → RowResult[] → classify new/update (ids org)
   → preview table + compteurs
confirm → commitImportAction(text)
   → [WASM] parse_csv (re-parse serveur) → okRows
   → upsertLinkSources(orgId, okRows) [Drizzle onConflictDoUpdate]
   → revalidatePath → compteurs
```

## Tests

- **Rust** (`cargo test` dans `import-core`) : `slug_id` (exemples fixtures :
  reddit/discord/producthunt…), `normalize_url`/`domain_norm` parité, mapping
  category→kind, défauts, validation (url manquante/invalide, da hors borne,
  platform vide), `parse_csv` (guillemets, virgules dans note, CRLF, header
  complet + colonnes manquantes tolérées), `template_csv` non vide & header
  attendu.
- **Next** (bun, DB-gated `DB_TESTS=1` + `assertTestDb`) : `upsertLinkSources`
  insère une nouvelle source et **met à jour** une existante (da/kind/tags) ;
  org-scopée.
- **Live d3k** : toolbar, download gabarit (200 + bon Content-Disposition),
  import CSV (preview new/update/erreur → commit → lignes apparaissent dans la
  table), ajout manuel.

## Risques

- **R1 (dé-risqué en 1er) — charger un WASM `target nodejs` dans une server
  action Next 16 / Turbopack.** 1ère tâche du plan = un **"hello wasm"** : un
  `ping()` Rust appelé depuis une server action, vérifié live, AVANT toute la
  logique. Ajuster si besoin `next.config.ts` (`serverExternalPackages` /
  inclusion du `.wasm` dans le file tracing).
- R2 — parité exacte du `slug_id` Rust avec les ids seedés (sinon l'upsert ne
  dédoublonne pas contre le seed). Couvert par des tests Rust sur des exemples
  réels des fixtures.

## Hors scope (YAGNI)

- Le worker / la file d'attente / le serveur à part (étape suivante ; le crate
  est prêt à être réutilisé).
- Role-gating owner/admin du write (session pas encore branchée).
- Mapping de colonnes interactif (on impose le header du gabarit).
- Édition/suppression d'une source existante depuis le reservoir.
- Persistance déplacée en Rust (reste côté Next pour ce slice).
- id composite multi-org.
