# Reservoir Import (Rust-WASM core + CSV/manual) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let users add org-scoped sources to the backlink reservoir via CSV import (with a downloadable template) and a manual single-add form, with the CSV→canonical-rows logic written in a standalone Rust crate compiled to WASM.

**Architecture:** A pure Rust crate `crates/import-core` (parse CSV, normalize URL, derive the path-aware `slugId`, map category→kind, apply defaults, validate) is compiled to WASM (`wasm-bindgen`, target `nodejs`) and called from Next server actions. Next owns persistence (Drizzle `onConflictDoUpdate` upsert, `flags` preserved) and the UI (dropzone import dialog + preview, manual add dialog, template download). The crate is the stable boundary a future queue worker will reuse natively.

**Tech Stack:** Rust (crates: `csv`, `url`, `serde`, `serde_json`, `serde-wasm-bindgen`, `wasm-bindgen`), wasm-pack; Next.js 16 App Router (server actions, route handler), Drizzle/Postgres, shadcn, bun, biome.

**Spec:** `docs/superpowers/specs/2026-06-04-reservoir-import-design.md`

> Two phases. **Phase A (Tasks 1–4)** delivers a tested Rust crate + working WASM callable from Next — independently shippable. **Phase B (Tasks 5–10)** adds the Next persistence + UI.

---

## File Structure

Rust crate (`crates/import-core/`):
- `Cargo.toml` (workspace root) + `crates/import-core/Cargo.toml`
- `src/lib.rs` — wasm-bindgen glue only (`ping`, `template_csv`, `parse_csv`, `normalize_row`)
- `src/model.rs` — `RawInput`, `CanonicalSource`, `RowResult` (serde, camelCase)
- `src/normalize.rs` — `normalize_url`, `domain_norm`, `slug_id`, `kind_from_category`
- `src/core.rs` — `normalize_row`, `parse_csv`, `template_csv` (pure; `#[cfg(test)]` unit tests)

Next:
- `src/lib/backlink/import-core/` — wasm-pack output (gitignored; `file:` dep named `import-core`)
- `next.config.ts` — `serverExternalPackages: ["import-core"]`
- `package.json` — `build:wasm` script + `import-core` file dep
- `src/lib/backlink/import.ts` — server actions + wasm wrapper + `upsertLinkSources`
- `src/app/(app)/backlink/reservoir/template/route.ts` — template download
- `src/components/backlink/reservoir-toolbar.tsx` — the 3 buttons
- `src/components/backlink/add-source-dialog.tsx` — manual add
- `src/components/backlink/import-sources-dialog.tsx` — dropzone + preview
- `src/components/backlink/reservoir-container.tsx` — mount the toolbar

Verification commands:
- Rust: `cargo test -p import-core`
- WASM build: `bun run build:wasm`
- Typecheck: `npx tsc --noEmit`
- Lint: `./node_modules/.bin/biome check <files>`
- DB tests: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test <file>` (guarded by `assertTestDb`; NEVER run against dev)
- Live: `d3k agent-browser …` (dev server running)

---

## Task 1: Toolchain spike — workspace + crate + `ping()` over WASM, called from a server action (DE-RISK)

**Goal:** Prove a `--target nodejs` WASM module built from a workspace crate can be imported and called inside a Next 16 server action, verified live. Resolve the next.config/loading details here, before any real logic.

**Files:**
- Create: `Cargo.toml` (workspace), `crates/import-core/Cargo.toml`, `crates/import-core/src/lib.rs`
- Modify: `package.json`, `next.config.ts`
- Create (temporary): `src/app/(app)/backlink/reservoir/_ping/page.tsx`

- [ ] **Step 1: Install wasm-pack**

Run: `cargo install wasm-pack` (or `brew install wasm-pack`). Verify: `wasm-pack --version`.

- [ ] **Step 2: Workspace `Cargo.toml`**

```toml
# Cargo.toml  (repo root)
[workspace]
members = ["crates/import-core"]
resolver = "2"
```

- [ ] **Step 3: Crate `Cargo.toml`**

```toml
# crates/import-core/Cargo.toml
[package]
name = "import-core"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-wasm-bindgen = "0.6"
csv = "1"
url = "2"
```

- [ ] **Step 4: Minimal `src/lib.rs` with `ping`**

```rust
// crates/import-core/src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn ping() -> String {
    "pong".to_string()
}
```

- [ ] **Step 5: Build the WASM package**

Add to `package.json` "scripts":
```json
"build:wasm": "wasm-pack build crates/import-core --release --target nodejs --out-dir ../../src/lib/backlink/import-core --out-name import_core"
```
Run: `bun run build:wasm`
Expected: creates `src/lib/backlink/import-core/` containing `import_core.js`, `import_core_bg.wasm`, `import_core.d.ts`, and a `package.json` with `"name": "import-core"`.

- [ ] **Step 6: Make it importable as a named package**

Add to root `package.json` "dependencies": `"import-core": "file:src/lib/backlink/import-core"`, then `bun install`.
Add to `next.config.ts` (inside the config object): `serverExternalPackages: ["import-core"]`.

- [ ] **Step 7: Temporary server action + page to prove the call**

```tsx
// src/app/(app)/backlink/reservoir/_ping/page.tsx  (TEMPORARY — removed in Task 4)
import { ping } from "import-core";

export default function PingPage() {
  return <pre data-testid="wasm-ping">{ping()}</pre>;
}
```
> `import-core` is a Node-targeted module; importing it in a Server Component (runs in Node) is fine. If Turbopack errors on the `.wasm`, confirm `serverExternalPackages: ["import-core"]` is set and restart the dev server.

- [ ] **Step 8: Verify live**

```bash
d3k agent-browser open http://localhost:3000/backlink/reservoir/_ping
d3k agent-browser eval "document.querySelector('[data-testid=wasm-ping]')?.textContent"
```
Expected: `"pong"`. Also `d3k errors` shows no new errors. If the import fails, iterate on next.config (`serverExternalPackages`) / build target until `pong` renders. Document the working config in the commit message.

- [ ] **Step 9: Typecheck & commit**

Run: `npx tsc --noEmit` → no errors.
```bash
git add Cargo.toml crates package.json bun.lock next.config.ts "src/app/(app)/backlink/reservoir/_ping/page.tsx" .gitignore
git commit -m "feat(import-core): WASM toolchain spike — ping() callable from Next (de-risk)"
```
> Do NOT commit the gitignored `src/lib/backlink/import-core/` build output. Confirm `git status` shows it ignored.

---

## Task 2: Rust model + normalization (pure)

**Files:**
- Create: `crates/import-core/src/model.rs`, `crates/import-core/src/normalize.rs`
- Modify: `crates/import-core/src/lib.rs` (add `mod` declarations)

- [ ] **Step 1: Write `model.rs`**

```rust
// crates/import-core/src/model.rs
use serde::{Deserialize, Serialize};

/// Raw, mostly-string input (one CSV record or a manual form submission).
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawInput {
    #[serde(default)]
    pub url: String,
    #[serde(default)]
    pub platform: String,
    #[serde(default)]
    pub category: String,
    #[serde(default)]
    pub da: String,
    #[serde(default)]
    pub effort: String,
    #[serde(default, alias = "link_type")]
    pub link_type: String,
    #[serde(default)]
    pub language: String,
    #[serde(default)]
    pub themes: String,
    #[serde(default)]
    pub tags: String,
    #[serde(default)]
    pub note: String,
}

/// A validated, normalized source ready to persist.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalSource {
    pub id: String,
    pub platform: String,
    pub url: String,
    pub domain_norm: String,
    pub kind: String,
    pub da: Option<i32>,
    pub effort: String,
    pub link_type: String,
    pub language: String,
    pub themes: Vec<String>,
    pub tags: Vec<String>,
    pub note: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RowResult {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<CanonicalSource>,
}

impl RowResult {
    pub fn ok(source: CanonicalSource) -> Self {
        Self { ok: true, error: None, source: Some(source) }
    }
    pub fn err(msg: impl Into<String>) -> Self {
        Self { ok: false, error: Some(msg.into()), source: None }
    }
}
```

- [ ] **Step 2: Write `normalize.rs` with unit tests (TDD — tests first)**

```rust
// crates/import-core/src/normalize.rs
use url::Url;

/// Force https, lowercase host, strip a single trailing slash on root. "" on invalid.
pub fn normalize_url(raw: &str) -> String {
    let s = raw.trim();
    if s.is_empty() {
        return String::new();
    }
    let with_proto = if s.starts_with("http://") || s.starts_with("https://") {
        s.to_string()
    } else {
        format!("https://{s}")
    };
    let Ok(mut u) = Url::parse(&with_proto) else {
        return String::new();
    };
    let _ = u.set_scheme("https");
    let mut out = u.to_string();
    if out.ends_with('/') && u.path() == "/" {
        out.pop();
    }
    out
}

/// Registrable host without leading "www.", lowercased. "" on invalid.
pub fn domain_norm(raw: &str) -> String {
    let s = raw.trim();
    if s.is_empty() {
        return String::new();
    }
    let with_proto = if s.starts_with("http://") || s.starts_with("https://") {
        s.to_string()
    } else {
        format!("https://{s}")
    };
    match Url::parse(&with_proto) {
        Ok(u) => u
            .host_str()
            .unwrap_or("")
            .to_lowercase()
            .trim_start_matches("www.")
            .to_string(),
        Err(_) => String::new(),
    }
}

/// Deterministic, path-aware id: kebab-case of (domainNorm + path).
/// Matches the seeded ids, e.g. reddit.com/r/plugyourproduct -> reddit-com-r-plugyourproduct.
pub fn slug_id(raw: &str) -> String {
    let norm = normalize_url(raw);
    let (host, path) = match Url::parse(&norm) {
        Ok(u) => (
            u.host_str().unwrap_or("").trim_start_matches("www.").to_string(),
            u.path().to_string(),
        ),
        Err(_) => (String::new(), String::new()),
    };
    slugify(&format!("{host}{path}"))
}

fn slugify(s: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
            prev_dash = false;
        } else if !out.is_empty() && !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

/// TSV/CSV "Category" -> kind slug. Unknown/empty -> "other".
pub fn kind_from_category(cat: &str) -> &'static str {
    match cat.trim() {
        "Directory" | "directory" => "directory",
        "AI Directory" | "ai-directory" => "ai-directory",
        "Media" | "media" | "Interviews" => "media",
        "Community" | "community" => "community",
        "Subreddit" | "subreddit" => "subreddit",
        "Review Site" | "review-site" => "review-site",
        "Launch Platform" | "launch-platform" => "launch-platform",
        "Blog" | "blog" => "blog",
        "Forum" | "forum" => "forum",
        "Marketplace" | "marketplace" => "marketplace",
        "Newsletter" | "newsletter" => "newsletter",
        "Design" | "design" | "Creative" => "design",
        "Platform" | "platform" | "Beta Testing" => "platform",
        "Jobs" | "jobs" => "jobs",
        "Tools" | "tools" => "tools",
        _ => "other",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_url() {
        assert_eq!(normalize_url("http://Foo.com/"), "https://foo.com");
        assert_eq!(normalize_url("producthunt.com"), "https://producthunt.com");
        assert_eq!(normalize_url("  "), "");
    }

    #[test]
    fn domain_norm_strips_www() {
        assert_eq!(domain_norm("https://www.Reddit.com/r/x"), "reddit.com");
        assert_eq!(domain_norm("discord.gg/abc"), "discord.gg");
    }

    #[test]
    fn slug_id_matches_seeded_ids() {
        assert_eq!(slug_id("https://www.reddit.com/r/plugyourproduct"), "reddit-com-r-plugyourproduct");
        assert_eq!(slug_id("https://reddit.com//r/thesidehustle/"), "reddit-com-r-thesidehustle");
        assert_eq!(slug_id("https://discord.com/invite/UrFqwSwxvN"), "discord-com-invite-urfqwswxvn");
        assert_eq!(slug_id("https://producthunt.com"), "producthunt-com");
    }

    #[test]
    fn category_maps_to_kind() {
        assert_eq!(kind_from_category("Launch Platform"), "launch-platform");
        assert_eq!(kind_from_category("Creative"), "design");
        assert_eq!(kind_from_category("Nonsense"), "other");
    }
}
```

- [ ] **Step 3: Declare modules in `lib.rs`**

Add near the top of `crates/import-core/src/lib.rs`:
```rust
mod model;
mod normalize;
```

- [ ] **Step 4: Run tests**

Run: `cargo test -p import-core`
Expected: all `normalize` + `model` tests PASS. If a `slug_id` case mismatches, open `src/lib/backlink/fixtures.ts`, find the real `id`/`url` pair, and fix `slugify`/`slug_id` until the assertions match the seeded format.

- [ ] **Step 5: Commit**

```bash
git add crates/import-core/src
git commit -m "feat(import-core): model + url/slug/category normalization with tests"
```

---

## Task 3: Rust validation + `parse_csv` + `template_csv` (pure)

**Files:**
- Create: `crates/import-core/src/core.rs`
- Modify: `crates/import-core/src/lib.rs` (add `mod core;`)

- [ ] **Step 1: Write `core.rs` (tests first)**

```rust
// crates/import-core/src/core.rs
use crate::model::{CanonicalSource, RawInput, RowResult};
use crate::normalize::{domain_norm, kind_from_category, normalize_url, slug_id};

const EFFORTS: [&str; 3] = ["low", "medium", "high"];
const LINK_TYPES: [&str; 3] = ["dofollow", "nofollow", "mixed"];

fn split_list(s: &str) -> Vec<String> {
    s.split([',', ';'])
        .map(|p| p.trim().to_string())
        .filter(|p| !p.is_empty())
        .collect()
}

/// Validate + normalize one raw row into a CanonicalSource (or an error).
pub fn normalize_row(raw: RawInput) -> RowResult {
    let platform = raw.platform.trim().to_string();
    if platform.is_empty() {
        return RowResult::err("platform requise");
    }
    let url = normalize_url(&raw.url);
    if url.is_empty() {
        return RowResult::err("url invalide ou manquante");
    }

    let da = match raw.da.trim() {
        "" => None,
        d => match d.parse::<i32>() {
            Ok(n) if (0..=100).contains(&n) => Some(n),
            _ => return RowResult::err("da doit être un entier entre 0 et 100"),
        },
    };

    let effort = match raw.effort.trim().to_lowercase() {
        e if e.is_empty() => "medium".to_string(),
        e if EFFORTS.contains(&e.as_str()) => e,
        _ => return RowResult::err("effort doit être low | medium | high"),
    };
    let link_type = match raw.link_type.trim().to_lowercase() {
        t if t.is_empty() => "dofollow".to_string(),
        t if LINK_TYPES.contains(&t.as_str()) => t,
        _ => return RowResult::err("link_type doit être dofollow | nofollow | mixed"),
    };
    let language = match raw.language.trim().to_lowercase() {
        l if l.is_empty() => "en".to_string(),
        l if l.len() == 2 && l.chars().all(|c| c.is_ascii_alphabetic()) => l,
        _ => return RowResult::err("language : code 2 lettres (ex. en)"),
    };

    let note = raw.note.trim();
    RowResult::ok(CanonicalSource {
        id: slug_id(&raw.url),
        platform,
        domain_norm: domain_norm(&raw.url),
        url,
        kind: kind_from_category(&raw.category).to_string(),
        da,
        effort,
        link_type,
        language,
        themes: split_list(&raw.themes),
        tags: split_list(&raw.tags),
        note: if note.is_empty() { None } else { Some(note.to_string()) },
    })
}

/// Parse a CSV string (header row required) into per-row results.
pub fn parse_csv(input: &str) -> Vec<RowResult> {
    let mut rdr = csv::ReaderBuilder::new()
        .has_headers(true)
        .flexible(true)
        .trim(csv::Trim::All)
        .from_reader(input.as_bytes());
    let mut out = Vec::new();
    for rec in rdr.deserialize::<RawInput>() {
        match rec {
            Ok(raw) => out.push(normalize_row(raw)),
            Err(e) => out.push(RowResult::err(format!("ligne illisible: {e}"))),
        }
    }
    out
}

/// The downloadable template — the single source of truth for the columns.
pub fn template_csv() -> String {
    let header = "url,platform,category,da,effort,link_type,language,themes,tags,note";
    let r1 = "https://www.producthunt.com,Product Hunt,Launch Platform,91,medium,dofollow,en,\"startup,tech\",\"launch\",Top launch platform";
    let r2 = "https://www.reddit.com/r/SaaS,r/SaaS,Subreddit,,high,nofollow,en,saas,community,";
    format!("{header}\n{r1}\n{r2}\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn raw(url: &str, platform: &str) -> RawInput {
        RawInput { url: url.into(), platform: platform.into(), ..Default::default() }
    }

    #[test]
    fn rejects_missing_url_and_platform() {
        assert!(!normalize_row(raw("", "X")).ok);
        assert!(!normalize_row(raw("https://x.com", "")).ok);
    }

    #[test]
    fn applies_defaults() {
        let r = normalize_row(raw("https://x.com", "X"));
        let s = r.source.unwrap();
        assert_eq!(s.effort, "medium");
        assert_eq!(s.link_type, "dofollow");
        assert_eq!(s.language, "en");
        assert_eq!(s.kind, "other");
        assert_eq!(s.da, None);
        assert_eq!(s.id, "x-com");
    }

    #[test]
    fn validates_da_range() {
        let mut r = raw("https://x.com", "X");
        r.da = "150".into();
        assert!(!normalize_row(r).ok);
    }

    #[test]
    fn splits_themes_and_tags() {
        let mut r = raw("https://x.com", "X");
        r.themes = "a, b ; c".into();
        let s = normalize_row(r).source.unwrap();
        assert_eq!(s.themes, vec!["a", "b", "c"]);
    }

    #[test]
    fn parses_csv_with_quotes_and_blank_da() {
        let csv = "url,platform,category,da,note\nhttps://a.com,A,Blog,50,\"hello, world\"\n,Bad,,,";
        let rows = parse_csv(csv);
        assert_eq!(rows.len(), 2);
        assert!(rows[0].ok);
        assert_eq!(rows[0].source.as_ref().unwrap().note.as_deref(), Some("hello, world"));
        assert!(!rows[1].ok); // missing url
    }

    #[test]
    fn template_has_header() {
        assert!(template_csv().starts_with("url,platform,category,da,effort,link_type,language,themes,tags,note"));
    }
}
```

- [ ] **Step 2: Declare module**

Add `mod core;` to `crates/import-core/src/lib.rs`.

- [ ] **Step 3: Run tests**

Run: `cargo test -p import-core`
Expected: all PASS (normalize + core). Iterate on the CSV parsing / validation if any fail.

- [ ] **Step 4: Commit**

```bash
git add crates/import-core/src
git commit -m "feat(import-core): row validation, parse_csv, template_csv with tests"
```

---

## Task 4: Expose WASM API + smoke-test from Next, remove ping scaffold

**Files:**
- Modify: `crates/import-core/src/lib.rs`
- Delete: `src/app/(app)/backlink/reservoir/_ping/page.tsx`

- [ ] **Step 1: Replace `lib.rs` with the real exports**

```rust
// crates/import-core/src/lib.rs
mod core;
mod model;
mod normalize;

use model::RawInput;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn template_csv() -> String {
    core::template_csv()
}

#[wasm_bindgen]
pub fn parse_csv(input: &str) -> Result<JsValue, JsValue> {
    let rows = core::parse_csv(input);
    serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
}

#[wasm_bindgen]
pub fn normalize_row(input: JsValue) -> Result<JsValue, JsValue> {
    let raw: RawInput =
        serde_wasm_bindgen::from_value(input).map_err(|e| JsValue::from_str(&e.to_string()))?;
    let res = core::normalize_row(raw);
    serde_wasm_bindgen::to_value(&res).map_err(|e| JsValue::from_str(&e.to_string()))
}
```

- [ ] **Step 2: Rebuild WASM**

Run: `bun run build:wasm`
Expected: regenerates `src/lib/backlink/import-core/` with `template_csv`, `parse_csv`, `normalize_row` in the `.d.ts`.

- [ ] **Step 3: Smoke-test from a server action (replace ping page temporarily)**

Temporarily edit any server-rendered surface OR reuse the `_ping` page to call `template_csv()`:
```tsx
// _ping/page.tsx (still temporary)
import { template_csv } from "import-core";
export default function PingPage() {
  return <pre data-testid="wasm-ping">{template_csv().slice(0, 40)}</pre>;
}
```
```bash
d3k agent-browser open http://localhost:3000/backlink/reservoir/_ping
d3k agent-browser eval "document.querySelector('[data-testid=wasm-ping]')?.textContent"
```
Expected: starts with `url,platform,category,da`. Confirms the real exports load.

- [ ] **Step 4: Remove the temporary scaffold**

```bash
rm "src/app/(app)/backlink/reservoir/_ping/page.tsx"
rmdir "src/app/(app)/backlink/reservoir/_ping"
```

- [ ] **Step 5: Typecheck & commit**

Run: `npx tsc --noEmit` → no errors.
```bash
git add crates/import-core/src/lib.rs "src/app/(app)/backlink/reservoir"
git commit -m "feat(import-core): expose template_csv/parse_csv/normalize_row over WASM"
```

---

## Task 5: shared types + `upsertLinkSources` Drizzle helper + DB test

**Files:**
- Create: `src/lib/backlink/import-types.ts` (plain types — importable by client + server)
- Create: `src/lib/backlink/import.ts` (server-only helper)
- Test: `src/lib/backlink/import.test.ts`

> Why a separate types file: `import-actions.ts` (Task 6) is `"use server"`, and a
> `"use server"` module may only export async functions — not types. And `import.ts`
> is `"server-only"` (throws if imported client-side). Shared types live in a plain
> module both sides import (type-only imports are erased, so no server code leaks to
> the client).

- [ ] **Step 1a: Write `import-types.ts`**

```ts
// src/lib/backlink/import-types.ts — shared types (no runtime code).
import type { Effort, LinkType, SourceKind } from "@/lib/backlink/types";

/** A canonical source row as produced by the Rust core (camelCase boundary). */
export interface CanonicalSource {
  id: string;
  platform: string;
  url: string;
  domainNorm: string;
  kind: SourceKind;
  da: number | null;
  effort: Effort;
  linkType: LinkType;
  language: string;
  themes: string[];
  tags: string[];
  note: string | null;
}

export type PreviewStatus = "new" | "update" | "error";

export interface PreviewRow {
  status: PreviewStatus;
  error?: string;
  id?: string;
  platform?: string;
  url?: string;
  da?: number | null;
}

export interface PreviewResult {
  rows: PreviewRow[];
  counts: { created: number; updated: number; errors: number };
}
```

- [ ] **Step 1b: Write the helper**

```ts
// src/lib/backlink/import.ts
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { linkSources } from "@/lib/db/domain-schema";
import type { CanonicalSource } from "./import-types";

/** Upsert sources for an org. New rows insert; existing (same id) update —
 *  EXCEPT `flags`, which are not in the CSV and must not be clobbered. */
export async function upsertLinkSources(
  orgId: string,
  rows: CanonicalSource[],
): Promise<void> {
  if (rows.length === 0) return;
  await db
    .insert(linkSources)
    .values(
      rows.map((r) => ({
        id: r.id,
        orgId,
        platform: r.platform,
        url: r.url,
        domainNorm: r.domainNorm,
        kind: r.kind,
        da: r.da,
        effort: r.effort,
        linkType: r.linkType,
        language: r.language,
        themes: r.themes,
        tags: r.tags,
        flags: { toxic: false, disavow: false },
        note: r.note,
      })),
    )
    .onConflictDoUpdate({
      target: linkSources.id,
      set: {
        platform: sql`excluded.platform`,
        url: sql`excluded.url`,
        domainNorm: sql`excluded.domain_norm`,
        kind: sql`excluded.kind`,
        da: sql`excluded.da`,
        effort: sql`excluded.effort`,
        linkType: sql`excluded.link_type`,
        language: sql`excluded.language`,
        themes: sql`excluded.themes`,
        tags: sql`excluded.tags`,
        note: sql`excluded.note`,
        // flags intentionally omitted — preserve existing toxic/disavow.
      },
    });
}
```
- [ ] **Step 2: Write the DB test**

```ts
// src/lib/backlink/import.test.ts
// Integration test — gated on DB_TESTS=1, run against the *_test DB only.
import { afterAll, beforeEach, describe, expect, test } from "bun:test";

const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("upsertLinkSources (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let client: typeof import("@/lib/db/client").client;
  let schema: typeof import("@/lib/db/domain-schema");
  let helper: typeof import("./import");
  type CanonicalSource = import("./import-types").CanonicalSource;
  const ORG = "demo-org";

  beforeEach(async () => {
    ({ db, client } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    helper = await import("./import");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await db.delete(schema.linkSources);
  });

  afterAll(async () => {
    await client.end();
  });

  const row = (over: Partial<CanonicalSource> = {}) => ({
    id: "x-com",
    platform: "X",
    url: "https://x.com",
    domainNorm: "x.com",
    kind: "other" as const,
    da: 10,
    effort: "medium" as const,
    linkType: "dofollow" as const,
    language: "en",
    themes: [],
    tags: [],
    note: null,
    ...over,
  });

  test("inserts a new source", async () => {
    await helper.upsertLinkSources(ORG, [row()]);
    const got = await db.select().from(schema.linkSources);
    expect(got).toHaveLength(1);
    expect(got[0].platform).toBe("X");
  });

  test("updates existing but preserves flags", async () => {
    await db.insert(schema.linkSources).values({
      ...row({ da: 5 }),
      orgId: ORG,
      flags: { toxic: true, disavow: false },
    });
    await helper.upsertLinkSources(ORG, [row({ da: 99, platform: "X2" })]);
    const [got] = await db.select().from(schema.linkSources);
    expect(got.da).toBe(99);
    expect(got.platform).toBe("X2");
    expect(got.flags).toEqual({ toxic: true, disavow: false }); // preserved
  });
});
```

- [ ] **Step 3: Run the DB test (against the TEST db only)**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/backlink/import.test.ts`
Expected: 2 pass. (The `assertTestDb` guard prevents accidental dev wipes.)

- [ ] **Step 4: Typecheck & commit**

Run: `npx tsc --noEmit` → no errors.
```bash
git add src/lib/backlink/import-types.ts src/lib/backlink/import.ts src/lib/backlink/import.test.ts
git commit -m "feat(backlink): shared import types + upsertLinkSources helper (preserves flags) + DB test"
```

---

## Task 6: Server actions — preview / commit / addSource

**Files:**
- Create: `src/lib/backlink/import-actions.ts`

- [ ] **Step 1: Create `import-actions.ts` (`"use server"`) with the wasm wrapper + actions**

```ts
// src/lib/backlink/import-actions.ts
"use server";
import { inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import * as wasm from "import-core";
import { getActiveOrgId } from "@/lib/active-org";
import { db } from "@/lib/db/client";
import { linkSources } from "@/lib/db/domain-schema";
import { upsertLinkSources } from "./import";
import type { CanonicalSource, PreviewResult, PreviewRow } from "./import-types";

const RESERVOIR_PATH = "/backlink/reservoir";

interface RowResult {
  ok: boolean;
  error?: string;
  source?: CanonicalSource;
}

function okSources(rows: RowResult[]): CanonicalSource[] {
  return rows.filter((r) => r.ok && r.source).map((r) => r.source as CanonicalSource);
}

/** Parse + classify new/update against the org's existing ids. No write. */
export async function previewImportAction(csvText: string): Promise<PreviewResult> {
  const rows = wasm.parse_csv(csvText) as RowResult[];
  const ids = okSources(rows).map((s) => s.id);
  const orgId = await getActiveOrgId();
  const existing = ids.length
    ? new Set(
        (
          await db
            .select({ id: linkSources.id })
            .from(linkSources)
            .where(inArray(linkSources.id, ids))
        ).map((r) => r.id),
      )
    : new Set<string>();

  const out: PreviewRow[] = rows.map((r) => {
    if (!r.ok || !r.source) return { status: "error", error: r.error };
    const s = r.source;
    return {
      status: existing.has(s.id) ? "update" : "new",
      id: s.id,
      platform: s.platform,
      url: s.url,
      da: s.da,
    };
  });
  return {
    rows: out,
    counts: {
      created: out.filter((r) => r.status === "new").length,
      updated: out.filter((r) => r.status === "update").length,
      errors: out.filter((r) => r.status === "error").length,
    },
  };
}

/** Re-parse server-side (never trust the client) + upsert the ok rows. */
export async function commitImportAction(
  csvText: string,
): Promise<{ created: number; updated: number; errors: number }> {
  const rows = wasm.parse_csv(csvText) as RowResult[];
  const sources = okSources(rows);
  const orgId = await getActiveOrgId();
  const ids = sources.map((s) => s.id);
  const existing = ids.length
    ? new Set(
        (
          await db
            .select({ id: linkSources.id })
            .from(linkSources)
            .where(inArray(linkSources.id, ids))
        ).map((r) => r.id),
      )
    : new Set<string>();
  const updated = sources.filter((s) => existing.has(s.id)).length;
  await upsertLinkSources(orgId, sources);
  revalidatePath(RESERVOIR_PATH);
  return {
    created: sources.length - updated,
    updated,
    errors: rows.length - sources.length,
  };
}

/** Manual single add. */
export async function addSourceAction(
  input: Record<string, string>,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const res = wasm.normalize_row(input) as RowResult;
  if (!res.ok || !res.source) {
    return { ok: false, error: res.error ?? "Source invalide" };
  }
  const orgId = await getActiveOrgId();
  await upsertLinkSources(orgId, [res.source]);
  revalidatePath(RESERVOIR_PATH);
  return { ok: true };
}
```
> All three exports are `async` server actions, so the `"use server"` file is valid.
> Shared types come from `./import-types` (type-only import, erased) and the
> `upsertLinkSources` helper from the `"server-only"` `./import`.

- [ ] **Step 2: Typecheck & lint**

Run: `npx tsc --noEmit` → no errors. Run `./node_modules/.bin/biome check src/lib/backlink/import-actions.ts` → clean (write-fix import order if needed).

- [ ] **Step 3: Commit**

```bash
git add src/lib/backlink/import-actions.ts
git commit -m "feat(backlink): preview/commit/addSource server actions over the WASM core"
```

---

## Task 7: Template download route handler

**Files:**
- Create: `src/app/(app)/backlink/reservoir/template/route.ts`

- [ ] **Step 1: Write the route**

```ts
// src/app/(app)/backlink/reservoir/template/route.ts
import { template_csv } from "import-core";

export function GET(): Response {
  return new Response(template_csv(), {
    headers: {
      "Content-Type": "text/csv; charset=utf-8",
      "Content-Disposition": 'attachment; filename="reservoir-template.csv"',
    },
  });
}
```

- [ ] **Step 2: Verify live**

```bash
curl -sI http://localhost:3000/backlink/reservoir/template | grep -i "content-disposition"
curl -s http://localhost:3000/backlink/reservoir/template | head -1
```
Expected: header `attachment; filename="reservoir-template.csv"`; first line `url,platform,category,da,effort,link_type,language,themes,tags,note`.

- [ ] **Step 3: Commit**

```bash
git add "src/app/(app)/backlink/reservoir/template/route.ts"
git commit -m "feat(backlink): downloadable CSV template served from the Rust core"
```

---

## Task 8: Manual add dialog + toolbar

**Files:**
- Create: `src/components/backlink/reservoir-toolbar.tsx`, `src/components/backlink/add-source-dialog.tsx`
- Modify: `src/components/backlink/reservoir-container.tsx`

- [ ] **Step 1: `add-source-dialog.tsx`**

```tsx
// src/components/backlink/add-source-dialog.tsx
"use client";

import { useState } from "react";
import { addSourceAction } from "@/lib/backlink/import-actions";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { EFFORTS, LINK_TYPES, SOURCE_KINDS } from "@/lib/backlink/types";

export function AddSourceDialog({
  open,
  onOpenChange,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
}) {
  const [form, setForm] = useState<Record<string, string>>({
    url: "",
    platform: "",
    category: "directory",
    da: "",
    effort: "medium",
    link_type: "dofollow",
    language: "en",
    themes: "",
    tags: "",
    note: "",
  });
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));

  async function submit() {
    setError(null);
    setPending(true);
    try {
      const res = await addSourceAction(form);
      if (!res.ok) {
        setError(res.error);
        return;
      }
      onOpenChange(false);
    } finally {
      setPending(false);
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Ajouter une source</DialogTitle>
          <DialogDescription>
            Ajoutée au reservoir de l'organisation.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-3">
          <Input
            placeholder="URL (https://…)"
            aria-label="URL"
            value={form.url}
            onChange={(e) => set("url", e.target.value)}
          />
          <Input
            placeholder="Plateforme (ex. Product Hunt)"
            aria-label="Plateforme"
            value={form.platform}
            onChange={(e) => set("platform", e.target.value)}
          />
          <div className="flex gap-2">
            <Select value={form.category} onValueChange={(v) => set("category", v)}>
              <SelectTrigger className="flex-1" aria-label="Catégorie">
                <SelectValue />
              </SelectTrigger>
              <SelectContent position="popper">
                {SOURCE_KINDS.map((k) => (
                  <SelectItem key={k.id} value={k.label}>
                    {k.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input
              type="number"
              placeholder="DA"
              aria-label="DA"
              className="w-24"
              value={form.da}
              onChange={(e) => set("da", e.target.value)}
            />
          </div>
          <div className="flex gap-2">
            <Select value={form.effort} onValueChange={(v) => set("effort", v)}>
              <SelectTrigger className="flex-1" aria-label="Effort">
                <SelectValue />
              </SelectTrigger>
              <SelectContent position="popper">
                {EFFORTS.map((e) => (
                  <SelectItem key={e} value={e}>
                    {e}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Select value={form.link_type} onValueChange={(v) => set("link_type", v)}>
              <SelectTrigger className="flex-1" aria-label="Type de lien">
                <SelectValue />
              </SelectTrigger>
              <SelectContent position="popper">
                {LINK_TYPES.map((t) => (
                  <SelectItem key={t} value={t}>
                    {t}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          {error ? (
            <p className="text-destructive text-sm" role="alert">
              {error}
            </p>
          ) : null}
          <div className="flex justify-end">
            <Button onClick={submit} disabled={!form.url.trim() || !form.platform.trim() || pending}>
              {pending ? "Ajout…" : "Ajouter"}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```
> `position="popper"` on every `SelectContent` (project gotcha: the default `item-aligned` pushes options out of reach — see the reservoir filter fix).

- [ ] **Step 2: `reservoir-toolbar.tsx`**

```tsx
// src/components/backlink/reservoir-toolbar.tsx
"use client";

import { DownloadIcon, PlusIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { AddSourceDialog } from "@/components/backlink/add-source-dialog";
import { ImportSourcesDialog } from "@/components/backlink/import-sources-dialog";
import { Button } from "@/components/ui/button";

export function ReservoirToolbar() {
  const [addOpen, setAddOpen] = useState(false);
  const [importOpen, setImportOpen] = useState(false);
  return (
    <div className="flex items-center gap-2">
      <Button variant="outline" onClick={() => setAddOpen(true)}>
        <PlusIcon /> Ajouter une source
      </Button>
      <Button variant="outline" onClick={() => setImportOpen(true)}>
        <UploadIcon /> Importer CSV
      </Button>
      <Button variant="ghost" asChild>
        <a href="/backlink/reservoir/template" download>
          <DownloadIcon /> Gabarit
        </a>
      </Button>
      <AddSourceDialog open={addOpen} onOpenChange={setAddOpen} />
      <ImportSourcesDialog open={importOpen} onOpenChange={setImportOpen} />
    </div>
  );
}
```
> This imports `ImportSourcesDialog` (built in Task 9). To keep Task 8 compiling on its own, create a minimal stub `import-sources-dialog.tsx` now that renders an empty `Dialog` with the right props, and flesh it out in Task 9. Stub:
```tsx
// src/components/backlink/import-sources-dialog.tsx (stub — completed in Task 9)
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function ImportSourcesDialog({
  open,
  onOpenChange,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
}) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Importer des sources</DialogTitle>
        </DialogHeader>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 3: Mount the toolbar in `reservoir-container.tsx`**

Read `src/components/backlink/reservoir-container.tsx`. It renders a header block that contains `<BacklinkFiltersBar .../>`. Add `<ReservoirToolbar />` just above the filters bar (import it at the top). Place it so the toolbar sits at the top-right of the reservoir header; if there's an existing header `div`, add the toolbar as a sibling row above the filters bar.

- [ ] **Step 4: Verify live (manual add)**

```bash
d3k agent-browser open http://localhost:3000/backlink/reservoir
# rows count before:
d3k agent-browser eval "(()=>document.querySelectorAll('table tbody tr').length)()"
# click "Ajouter une source", fill URL + Plateforme, submit; expect rows +1 and the new platform present
```
Expected: a new source is added (rows increases; the row appears after the action revalidates). `d3k errors` clean.

- [ ] **Step 5: Typecheck, lint, commit**

```bash
npx tsc --noEmit
./node_modules/.bin/biome check src/components/backlink/reservoir-toolbar.tsx src/components/backlink/add-source-dialog.tsx src/components/backlink/import-sources-dialog.tsx src/components/backlink/reservoir-container.tsx
git add src/components/backlink/reservoir-toolbar.tsx src/components/backlink/add-source-dialog.tsx src/components/backlink/import-sources-dialog.tsx src/components/backlink/reservoir-container.tsx
git commit -m "feat(backlink): reservoir toolbar + manual add-source dialog + template link"
```

---

## Task 9: CSV import dialog (dropzone + preview)

**Files:**
- Modify: `src/components/backlink/import-sources-dialog.tsx` (replace the stub)

- [ ] **Step 1: Implement the dialog**

```tsx
// src/components/backlink/import-sources-dialog.tsx
"use client";

import { UploadCloudIcon } from "lucide-react";
import { useRef, useState } from "react";
import {
  commitImportAction,
  previewImportAction,
} from "@/lib/backlink/import-actions";
import type { PreviewResult } from "@/lib/backlink/import-types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

export function ImportSourcesDialog({
  open,
  onOpenChange,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
}) {
  const [csv, setCsv] = useState<string | null>(null);
  const [preview, setPreview] = useState<PreviewResult | null>(null);
  const [pending, setPending] = useState(false);
  const [dragOver, setDragOver] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function reset() {
    setCsv(null);
    setPreview(null);
    setPending(false);
  }

  async function loadFile(file: File) {
    const text = await file.text();
    setCsv(text);
    setPending(true);
    try {
      setPreview(await previewImportAction(text));
    } finally {
      setPending(false);
    }
  }

  async function commit() {
    if (!csv) return;
    setPending(true);
    try {
      await commitImportAction(csv);
      onOpenChange(false);
      reset();
    } finally {
      setPending(false);
    }
  }

  const importable = preview
    ? preview.counts.created + preview.counts.updated
    : 0;

  return (
    <Dialog
      open={open}
      onOpenChange={(o) => {
        if (!o) reset();
        onOpenChange(o);
      }}
    >
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>Importer des sources</DialogTitle>
          <DialogDescription>
            CSV au format du gabarit. Les doublons (même URL) sont mis à jour.
          </DialogDescription>
        </DialogHeader>

        {!preview ? (
          <button
            type="button"
            onClick={() => inputRef.current?.click()}
            onDragOver={(e) => {
              e.preventDefault();
              setDragOver(true);
            }}
            onDragLeave={() => setDragOver(false)}
            onDrop={(e) => {
              e.preventDefault();
              setDragOver(false);
              const f = e.dataTransfer.files[0];
              if (f) loadFile(f);
            }}
            className={`flex h-40 w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed text-muted-foreground text-sm transition-colors ${
              dragOver ? "border-ring bg-muted/50" : "border-input"
            }`}
          >
            <UploadCloudIcon className="size-6" />
            {pending ? "Analyse…" : "Dépose ton .csv ici, ou clique pour parcourir"}
            <input
              ref={inputRef}
              type="file"
              accept=".csv,text/csv"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0];
                if (f) loadFile(f);
              }}
            />
          </button>
        ) : (
          <div className="grid gap-3">
            <div className="max-h-72 overflow-auto rounded-lg border">
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>Statut</TableHead>
                    <TableHead>Plateforme</TableHead>
                    <TableHead>URL</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {preview.rows.map((r, i) => (
                    <TableRow key={`${r.id ?? "err"}-${i}`}>
                      <TableCell>
                        {r.status === "new" ? (
                          <Badge>nouvelle</Badge>
                        ) : r.status === "update" ? (
                          <Badge variant="secondary">mise à jour</Badge>
                        ) : (
                          <Badge variant="destructive">erreur</Badge>
                        )}
                      </TableCell>
                      <TableCell>{r.platform ?? r.error}</TableCell>
                      <TableCell className="text-muted-foreground text-xs">
                        {r.url}
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </div>
            <p className="text-muted-foreground text-sm">
              {preview.counts.created} nouvelle(s) · {preview.counts.updated} mise(s)
              à jour · {preview.counts.errors} erreur(s)
            </p>
            <div className="flex justify-end gap-2">
              <Button variant="outline" onClick={reset} disabled={pending}>
                Choisir un autre fichier
              </Button>
              <Button onClick={commit} disabled={pending || importable === 0}>
                {pending ? "Import…" : `Importer (${importable})`}
              </Button>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 2: Verify live (import flow)**

```bash
# Build a small CSV and import it through the UI.
printf 'url,platform,category,da,effort,link_type,language,themes,tags,note\nhttps://example-import-test.com,Import Test,Directory,42,medium,dofollow,en,test,,hello\n' > /tmp/reservoir-test.csv
d3k agent-browser open http://localhost:3000/backlink/reservoir
# click "Importer CSV", drop/select /tmp/reservoir-test.csv, confirm preview shows 1 "nouvelle", click "Importer (1)"
d3k agent-browser eval "(()=>[...document.querySelectorAll('table tbody tr')].some(r=>r.innerText.includes('Import Test')))()"
```
Expected: preview lists the row as `nouvelle`; after import, the reservoir table contains "Import Test". Re-importing the same CSV shows it as `mise à jour`. `d3k errors` clean.

- [ ] **Step 3: Typecheck, lint, commit**

```bash
npx tsc --noEmit
./node_modules/.bin/biome check src/components/backlink/import-sources-dialog.tsx
git add src/components/backlink/import-sources-dialog.tsx
git commit -m "feat(backlink): CSV import dialog with dropzone + preview"
```

---

## Task 10: Final verification & cleanup

- [ ] **Step 1: Full checks**

```bash
cargo test -p import-core            # all Rust tests pass
bun run build:wasm                   # builds cleanly
npx tsc --noEmit                     # no errors
./node_modules/.bin/biome check src/lib/backlink src/components/backlink "src/app/(app)/backlink"
DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/backlink/import.test.ts
```

- [ ] **Step 2: Live end-to-end (d3k)**

- Toolbar shows the 3 buttons; "Gabarit" downloads a CSV (200 + attachment header).
- Manual add: adds a source (row appears).
- CSV import: dropzone → preview (new/update/error badges + counts) → import → rows appear; re-import shows updates; `flags` on a previously-flagged source are preserved.
- The reservoir filters (search + dropdowns from the earlier fix) still work.
- `d3k errors` shows no new app errors.

- [ ] **Step 3: Confirm build output stays untracked**

`git status` must show `src/lib/backlink/import-core/` ignored (never committed).

---

## Notes / deferred (from spec)

- The queue worker / separate server reuse `import-core` natively (next phase).
- Role-gating (owner/admin) the write — deferred until the session feeds `getActiveOrgId/UserId`.
- Multi-org `id` collision (PK is the bare slug) — acceptable while single-org `demo-org`; revisit with real multi-org.
- Persistence stays in Next this slice (worker absorbs it later).
