# Ahrefs Domain Rating — 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:** A god-mode super-admin fetches real Ahrefs Domain Rating (free, keyless endpoint) for reservoir source domains; DR is cached once per domain (shared across orgs) and shown in the reservoir's strength badge, relabeled "DR".

**Architecture:** Env-based super-admin gate → a shared `domain_ratings` cache table → a server-only Ahrefs client → a chunked client-driven bulk fetch (super-admin only) → the reservoir query resolves DR by `domainNorm` and the badge shows `dr ?? da`.

**Tech Stack:** Next.js 16 RSC + server actions (`"use server"`), Drizzle + Postgres, Better Auth session, `bun:test`, sonner toasts.

**Context the implementer must know:**
- Action files use the `"use server"` directive — every export must be an async, client-serializable server action. Non-serializable helpers (e.g. a runner taking an injected `fetch`) MUST live in a separate non-`"use server"` module.
- DB tests run via `bun run test src/lib/backlink/...` (sets test `DATABASE_URL` + loads `.env.local`). Never `db:push`. Mirror the DB-test harness in `src/lib/backlink/*.test.ts` (site/org seed + cleanup + `assertTestDb`).
- The agent CANNOT write `.env*`. `SUPER_ADMIN_EMAILS` must be added to `.env.local` by the user for live verification; absent → nobody is super-admin (safe default).
- `env` is exported from `src/env.ts` (a parsed Zod object + computed booleans). `auth` is at `src/lib/auth.ts`. Session email: `(await auth.api.getSession({ headers: await headers() }))?.user?.email`.

---

### Task 1: Super-admin gate

**Files:**
- Modify: `src/env.ts` (add `SUPER_ADMIN_EMAILS`)
- Create: `src/lib/auth/super-admin.ts`
- Test: `src/lib/auth/super-admin.test.ts`

- [ ] **Step 1: Add the env var**

In `src/env.ts`, inside the `z.object({ ... })` schema, add (next to the other optional secrets):

```ts
  SUPER_ADMIN_EMAILS: z.string().optional(),
```

- [ ] **Step 2: Write the failing test (pure email matcher)**

Create `src/lib/auth/super-admin.test.ts`:

```ts
import { expect, test } from "bun:test";
import { emailInCsv } from "./super-admin";

test("emailInCsv matches case-insensitively, trims, ignores blanks", () => {
  const csv = "Alice@Example.com, bob@x.io ,";
  expect(emailInCsv("alice@example.com", csv)).toBe(true);
  expect(emailInCsv("  BOB@X.IO ", csv)).toBe(true);
  expect(emailInCsv("carol@x.io", csv)).toBe(false);
});

test("emailInCsv is false for empty email or empty list", () => {
  expect(emailInCsv("a@b.com", "")).toBe(false);
  expect(emailInCsv(null, "a@b.com")).toBe(false);
  expect(emailInCsv(undefined, "a@b.com")).toBe(false);
});
```

- [ ] **Step 3: Run it to verify it fails**

Run: `bun run test src/lib/auth/super-admin.test.ts`
Expected: FAIL — module/export not found.

- [ ] **Step 4: Implement `super-admin.ts`**

Create `src/lib/auth/super-admin.ts`:

```ts
// src/lib/auth/super-admin.ts — god-mode gate via SUPER_ADMIN_EMAILS (env).
import { headers } from "next/headers";
import { env } from "@/env";
import { auth } from "@/lib/auth";

/** Pure: is `email` present in a comma-separated allow-list (case-insensitive)? */
export function emailInCsv(
  email: string | null | undefined,
  csv: string,
): boolean {
  if (!email) return false;
  const target = email.trim().toLowerCase();
  return csv
    .split(",")
    .map((e) => e.trim().toLowerCase())
    .filter(Boolean)
    .includes(target);
}

export function isSuperAdmin(email: string | null | undefined): boolean {
  return emailInCsv(email, env.SUPER_ADMIN_EMAILS ?? "");
}

/** Reads the session email and returns whether the current user is super-admin. */
export async function isCurrentUserSuperAdmin(): Promise<boolean> {
  const session = await auth.api.getSession({ headers: await headers() });
  return isSuperAdmin(session?.user?.email);
}

/** Throws if the current user is not a super-admin. Use to gate god-mode actions. */
export async function assertSuperAdmin(): Promise<void> {
  if (!(await isCurrentUserSuperAdmin())) {
    throw new Error("Forbidden: super-admin only");
  }
}
```

- [ ] **Step 5: Run the test to verify it passes + typecheck**

Run: `bun run test src/lib/auth/super-admin.test.ts && npx tsc --noEmit`
Expected: PASS; tsc clean. (If the `auth` import path differs, match `src/lib/active-org.ts`, which imports `./auth`.)

- [ ] **Step 6: Commit**

```bash
git add src/env.ts src/lib/auth/super-admin.ts src/lib/auth/super-admin.test.ts
git commit -m "feat(auth): super-admin gate via SUPER_ADMIN_EMAILS env"
```

---

### Task 2: `domain_ratings` shared cache table

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (new table)
- Generate: a Drizzle migration under `drizzle/`

- [ ] **Step 1: Add the table**

In `src/lib/db/domain-schema.ts`, after `domainBacklinks` (keep backlink tables together), add:

```ts
/** Shared per-domain Ahrefs Domain Rating cache (org-agnostic; keyed by domain). */
export const domainRatings = pgTable("domain_ratings", {
  domainNorm: text("domain_norm").primaryKey(),
  dr: integer("dr").notNull(), // 0-100, rounded
  fetchedAt: timestamp("fetched_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
});
```

(`integer`, `text`, `timestamp`, `pgTable` are already imported in this file.)

- [ ] **Step 2: Generate the migration**

Run: `bun run db:generate`
Expected: a new `drizzle/0015_*.sql` creating `domain_ratings`. Inspect it — it must ONLY create that table (PK on `domain_norm`, no FK).

- [ ] **Step 3: Apply to dev + test DBs**

Run: `bun run db:migrate && bun run db:migrate:test`
Expected: both succeed.

- [ ] **Step 4: Commit**

```bash
git add src/lib/db/domain-schema.ts drizzle/
git commit -m "feat(db): domain_ratings shared per-domain DR cache"
```

---

### Task 3: Ahrefs DR client

**Files:**
- Create: `src/lib/backlink/ahrefs-dr.ts`
- Test: `src/lib/backlink/ahrefs-dr.test.ts`

- [ ] **Step 1: Write the failing test (mocked fetch)**

Create `src/lib/backlink/ahrefs-dr.test.ts`:

```ts
import { expect, test } from "bun:test";
import { fetchDomainRating } from "./ahrefs-dr";

function mockFetch(status: number, body: unknown) {
  return async () =>
    new Response(JSON.stringify(body), {
      status,
      headers: { "Content-Type": "application/json" },
    });
}

test("parses and rounds a valid DR", async () => {
  const r = await fetchDomainRating(
    "producthunt.com",
    mockFetch(200, { domain_rating: { domain_rating: 72.6 } }) as typeof fetch,
  );
  expect(r).toEqual({ ok: true, dr: 73 });
});

test("429 → rate_limited", async () => {
  const r = await fetchDomainRating("x.com", mockFetch(429, {}) as typeof fetch);
  expect(r).toEqual({ ok: false, reason: "rate_limited" });
});

test("missing field → invalid; other non-2xx → error", async () => {
  expect(
    await fetchDomainRating("x.com", mockFetch(200, { nope: 1 }) as typeof fetch),
  ).toEqual({ ok: false, reason: "invalid" });
  expect(
    await fetchDomainRating("x.com", mockFetch(500, {}) as typeof fetch),
  ).toEqual({ ok: false, reason: "error" });
});
```

- [ ] **Step 2: Run it to verify it fails**

Run: `bun run test src/lib/backlink/ahrefs-dr.test.ts`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement `ahrefs-dr.ts`**

Create `src/lib/backlink/ahrefs-dr.ts`:

```ts
// src/lib/backlink/ahrefs-dr.ts — Ahrefs free Domain Rating endpoint (no API key).
import "server-only";

export type DrResult =
  | { ok: true; dr: number }
  | { ok: false; reason: "invalid" | "rate_limited" | "error" };

const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";

interface DrBody {
  domain_rating?: { domain_rating?: number };
}

/** Fetch one domain's DR. `fetchFn` is injectable for tests (default: global fetch). */
export async function fetchDomainRating(
  domainNorm: string,
  fetchFn: typeof fetch = fetch,
): Promise<DrResult> {
  const url = `${ENDPOINT}?target=${encodeURIComponent(domainNorm)}&output=json`;
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 8000);
  try {
    const res = await fetchFn(url, { signal: ctrl.signal });
    if (res.status === 429) return { ok: false, reason: "rate_limited" };
    if (!res.ok) return { ok: false, reason: "error" };
    const body = (await res.json()) as DrBody;
    const raw = body?.domain_rating?.domain_rating;
    if (typeof raw !== "number" || Number.isNaN(raw)) {
      return { ok: false, reason: "invalid" };
    }
    return { ok: true, dr: Math.max(0, Math.min(100, Math.round(raw))) };
  } catch {
    return { ok: false, reason: "error" };
  } finally {
    clearTimeout(timer);
  }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `bun run test src/lib/backlink/ahrefs-dr.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/lib/backlink/ahrefs-dr.ts src/lib/backlink/ahrefs-dr.test.ts
git commit -m "feat(backlink): Ahrefs free Domain Rating client"
```

---

### Task 4: DR chunk runner + server actions

**Files:**
- Create: `src/lib/backlink/dr-chunk.ts` (testable runner, NOT `"use server"`)
- Create: `src/lib/backlink/dr-actions.ts` (`"use server"` — gated actions)
- Test: `src/lib/backlink/dr-chunk.test.ts` (DB)

- [ ] **Step 1: Write the failing DB test**

Create `src/lib/backlink/dr-chunk.test.ts`. Mirror the DB-test harness used in the other `src/lib/backlink/*.test.ts` (org seed + table cleanup + `assertTestDb`). Then:

```ts
import { beforeEach, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { domainRatings } from "@/lib/db/domain-schema";
import { runDrChunk } from "./dr-chunk";
import type { DrResult } from "./ahrefs-dr";
// ... harness: assertTestDb(), beforeEach cleans domain_ratings ...

const stub =
  (map: Record<string, DrResult>) =>
  async (d: string): Promise<DrResult> =>
    map[d] ?? { ok: false, reason: "error" };

test("upserts successful DRs, reports failed + rateLimited", async () => {
  const res = await runDrChunk(
    ["a.com", "b.com", "c.com"],
    stub({
      "a.com": { ok: true, dr: 50 },
      "b.com": { ok: false, reason: "rate_limited" },
      "c.com": { ok: false, reason: "error" },
    }),
  );
  expect(res.updated).toBe(1);
  expect(res.rateLimited).toBe(true);
  expect(res.failed).toEqual(["c.com"]);
  const rows = await db
    .select()
    .from(domainRatings)
    .where(eq(domainRatings.domainNorm, "a.com"));
  expect(rows[0]?.dr).toBe(50);
});

test("re-running updates dr + fetchedAt (upsert)", async () => {
  await runDrChunk(["a.com"], stub({ "a.com": { ok: true, dr: 40 } }));
  await runDrChunk(["a.com"], stub({ "a.com": { ok: true, dr: 60 } }));
  const rows = await db
    .select()
    .from(domainRatings)
    .where(eq(domainRatings.domainNorm, "a.com"));
  expect(rows.length).toBe(1);
  expect(rows[0]?.dr).toBe(60);
});
```

- [ ] **Step 2: Run it to verify it fails**

Run: `bun run test src/lib/backlink/dr-chunk.test.ts`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement the runner**

Create `src/lib/backlink/dr-chunk.ts`:

```ts
// src/lib/backlink/dr-chunk.ts — fetch + upsert DR for a list of domains (no auth; injectable fetcher).
import { db } from "@/lib/db/client";
import { domainRatings } from "@/lib/db/domain-schema";
import { fetchDomainRating, type DrResult } from "./ahrefs-dr";

export interface DrChunkResult {
  updated: number;
  rateLimited: boolean;
  failed: string[];
}

const CONCURRENCY = 4;
const DELAY_MS = 150;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** Fetch DR for each domain (bounded concurrency + delay) and upsert hits. */
export async function runDrChunk(
  domains: string[],
  fetcher: (d: string) => Promise<DrResult> = fetchDomainRating,
): Promise<DrChunkResult> {
  const failed: string[] = [];
  let updated = 0;
  let rateLimited = false;

  for (let i = 0; i < domains.length; i += CONCURRENCY) {
    const batch = domains.slice(i, i + CONCURRENCY);
    const results = await Promise.all(batch.map((d) => fetcher(d)));
    const hits: { domainNorm: string; dr: number }[] = [];
    results.forEach((r, j) => {
      const d = batch[j];
      if (r.ok) hits.push({ domainNorm: d, dr: r.dr });
      else if (r.reason === "rate_limited") rateLimited = true;
      else failed.push(d);
    });
    if (hits.length > 0) {
      await db
        .insert(domainRatings)
        .values(hits.map((h) => ({ domainNorm: h.domainNorm, dr: h.dr })))
        .onConflictDoUpdate({
          target: domainRatings.domainNorm,
          set: { dr: sql`excluded.dr`, fetchedAt: sql`now()` },
        });
      updated += hits.length;
    }
    if (i + CONCURRENCY < domains.length) await sleep(DELAY_MS);
  }
  return { updated, rateLimited, failed };
}
```

Add `import { sql } from "drizzle-orm";` at the top.

- [ ] **Step 4: Run the test to verify it passes**

Run: `bun run test src/lib/backlink/dr-chunk.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Implement the gated actions**

Create `src/lib/backlink/dr-actions.ts`:

```ts
// src/lib/backlink/dr-actions.ts — super-admin god-mode actions for DR refresh.
"use server";
import { eq, isNull, lt, or } from "drizzle-orm";
import { assertSuperAdmin } from "@/lib/auth/super-admin";
import { db } from "@/lib/db/client";
import { domainRatings, linkSources } from "@/lib/db/domain-schema";
import { fetchDomainRating } from "./ahrefs-dr";
import { runDrChunk, type DrChunkResult } from "./dr-chunk";

/** Distinct source domains missing a DR or older than `maxAgeDays`. Super-admin only. */
export async function fetchStaleDomainsAction(
  maxAgeDays = 30,
): Promise<string[]> {
  await assertSuperAdmin();
  const cutoff = new Date(Date.now() - maxAgeDays * 86_400_000);
  const rows = await db
    .selectDistinct({ domainNorm: linkSources.domainNorm })
    .from(linkSources)
    .leftJoin(
      domainRatings,
      eq(domainRatings.domainNorm, linkSources.domainNorm),
    )
    .where(or(isNull(domainRatings.domainNorm), lt(domainRatings.fetchedAt, cutoff)));
  return rows.map((r) => r.domainNorm);
}

/** Fetch + cache DR for a chunk of domains. Super-admin only. */
export async function fetchDomainRatingsChunkAction(
  domains: string[],
): Promise<DrChunkResult> {
  await assertSuperAdmin();
  if (domains.length === 0) return { updated: 0, rateLimited: false, failed: [] };
  return runDrChunk(domains, fetchDomainRating);
}
```

- [ ] **Step 6: Typecheck**

Run: `npx tsc --noEmit`
Expected: No errors. (Confirm `selectDistinct` + `leftJoin` + `or/isNull/lt` exist in the Drizzle version; adjust if the API differs.)

- [ ] **Step 7: Commit**

```bash
git add src/lib/backlink/dr-chunk.ts src/lib/backlink/dr-chunk.test.ts src/lib/backlink/dr-actions.ts
git commit -m "feat(backlink): chunked DR fetch runner + super-admin actions"
```

---

### Task 5: Reservoir query resolves DR

**Files:**
- Modify: `src/lib/backlink/types.ts` (add `dr` to `LinkSource`)
- Modify: `src/lib/backlink/queries.ts` (`getReservoir` resolves DR)
- Test: `src/lib/backlink/queries.test.ts` (new or extend)

- [ ] **Step 1: Add `dr` to the type**

In `src/lib/backlink/types.ts`, in `interface LinkSource`, after `da: number | null;` add:

```ts
  dr: number | null; // cached Ahrefs Domain Rating (shared per-domain), null = not fetched
```

- [ ] **Step 2: Write the failing test**

Create `src/lib/backlink/queries.test.ts` (mirror the DB-test harness from sibling `*.test.ts`; seed an org + two `linkSources` with the same `domainNorm` and a `domain_ratings` row for it):

```ts
import { expect, test } from "bun:test";
import { db } from "@/lib/db/client";
import { domainRatings, linkSources } from "@/lib/db/domain-schema";
import { getReservoir } from "./queries";
// ... harness: assertTestDb(), seed ORG, beforeEach cleanup ...

test("getReservoir resolves dr from the shared cache by domainNorm", async () => {
  await db.insert(linkSources).values({
    id: "ls1", orgId: ORG, platform: "PH", url: "https://producthunt.com",
    domainNorm: "producthunt.com", kind: "launch-platform", da: 40,
    effort: "low", linkType: "dofollow", language: "en",
  } as never);
  await db.insert(domainRatings).values({ domainNorm: "producthunt.com", dr: 91 });

  const { sources } = await getReservoir(ORG);
  const s = sources.find((x) => x.id === "ls1")!;
  expect(s.dr).toBe(91); // cache wins
  expect(s.da).toBe(40); // manual kept as fallback data
});

test("dr is null when no cache row exists", async () => {
  await db.insert(linkSources).values({
    id: "ls2", orgId: ORG, platform: "X", url: "https://x.io",
    domainNorm: "x.io", kind: "other", da: null,
    effort: "low", linkType: "dofollow", language: "en",
  } as never);
  const { sources } = await getReservoir(ORG);
  expect(sources.find((x) => x.id === "ls2")!.dr).toBeNull();
});
```

- [ ] **Step 3: Run it to verify it fails**

Run: `bun run test src/lib/backlink/queries.test.ts`
Expected: FAIL — `sources[].dr` is undefined.

- [ ] **Step 4: Resolve DR in `getReservoir`**

In `src/lib/backlink/queries.ts`: import `domainRatings`, and after fetching `rows`, build a DR map and set `dr` on each source.

```ts
// add to imports:
import { domainRatings } from "@/lib/db/domain-schema";

// inside getReservoir, after `rows` is fetched and before/while building `sources`:
const domains = [...new Set(rows.map((r) => r.domainNorm))];
const drRows = domains.length
  ? await db
      .select({ domainNorm: domainRatings.domainNorm, dr: domainRatings.dr })
      .from(domainRatings)
      .where(inArray(domainRatings.domainNorm, domains))
  : [];
const drByDomain = new Map(drRows.map((d) => [d.domainNorm, d.dr]));
```

Then in the `rows.map((r) => ({ ... }))`, add: `dr: drByDomain.get(r.domainNorm) ?? null,`.

- [ ] **Step 5: Run the test to verify it passes + typecheck**

Run: `bun run test src/lib/backlink/queries.test.ts && npx tsc --noEmit`
Expected: PASS (2 tests); tsc clean. (Other code building `LinkSource` — e.g. `fixtures.ts` — now needs `dr`; set `dr: null` there. Fix any tsc errors this surfaces.)

- [ ] **Step 6: Commit**

```bash
git add src/lib/backlink/types.ts src/lib/backlink/queries.ts src/lib/backlink/queries.test.ts src/lib/backlink/fixtures.ts
git commit -m "feat(backlink): reservoir resolves DR from shared cache (dr field)"
```

---

### Task 6: Badge rename + super-admin toolbar button

**Files:**
- Rename/Modify: `src/components/backlink/da-badge.tsx` → `DrBadge`
- Modify: `src/components/backlink/columns.tsx` (use `DrBadge`, `dr ?? da`)
- Modify: `src/components/backlink/reservoir-toolbar.tsx` (the button)
- Modify: `src/components/backlink/reservoir-container.tsx` (thread `isSuperAdmin` + run the loop)
- Modify: `src/app/(app)/backlink/reservoir/page.tsx` (compute `isSuperAdmin`)

- [ ] **Step 1: Rename the badge to DR**

In `src/components/backlink/da-badge.tsx`: rename the exported function `DaBadge` → `DrBadge`, change the doc comment "Domain Authority" → "Domain Rating", and the `title` from `` `DA ${value}/100` `` → `` `DR ${value}/100` ``. (Keep the file name OR rename the file to `dr-badge.tsx` and update imports — match the simplest diff; the component export name is what matters.)

- [ ] **Step 2: Use DrBadge with the resolved value**

In `src/components/backlink/columns.tsx`: change the import `DaBadge` → `DrBadge`, the column header label "DA" → "DR", and the cell to render `<DrBadge value={row.original.dr ?? row.original.da} />`.

- [ ] **Step 3: Verify the rename compiles**

Run: `npx tsc --noEmit`
Expected: No errors (no `DaBadge` references remain — `grep -rn DaBadge src/` returns nothing).

- [ ] **Step 4: Add `isSuperAdmin` to the page → container → toolbar**

In `src/app/(app)/backlink/reservoir/page.tsx`: import `isCurrentUserSuperAdmin` from `@/lib/auth/super-admin`, `const isSuperAdmin = await isCurrentUserSuperAdmin();`, and pass `isSuperAdmin={isSuperAdmin}` to `ReservoirContainer`.

In `reservoir-container.tsx`: accept `isSuperAdmin: boolean` prop, pass to `ReservoirToolbar`.

- [ ] **Step 5: Add the gated bulk-fetch button + chunk loop**

In `reservoir-container.tsx` (client component), add a handler and wire a button via the toolbar (the toolbar receives `isSuperAdmin` and an `onRefreshDr` callback; render the button only when `isSuperAdmin`):

```tsx
const [drBusy, setDrBusy] = useState(false);
const [drProgress, setDrProgress] = useState<{ done: number; total: number } | null>(null);

async function refreshDr() {
  setDrBusy(true);
  try {
    const domains = await fetchStaleDomainsAction();
    if (domains.length === 0) {
      toast.info("DR déjà à jour");
      return;
    }
    setDrProgress({ done: 0, total: domains.length });
    const CHUNK = 25;
    let updated = 0;
    for (let i = 0; i < domains.length; i += CHUNK) {
      const slice = domains.slice(i, i + CHUNK);
      const res = await fetchDomainRatingsChunkAction(slice);
      updated += res.updated;
      setDrProgress({ done: Math.min(i + CHUNK, domains.length), total: domains.length });
      if (res.rateLimited) {
        toast.error("Limite Ahrefs atteinte — réessaie plus tard");
        break;
      }
    }
    toast.success(`${updated} DR mis à jour`);
    router.refresh();
  } catch {
    toast.error("Échec du rafraîchissement DR");
  } finally {
    setDrBusy(false);
    setDrProgress(null);
  }
}
```

Imports: `fetchStaleDomainsAction`, `fetchDomainRatingsChunkAction` from `@/lib/backlink/dr-actions`; `toast` from `sonner`; `useRouter` from `next/navigation` (`const router = useRouter()`).

In `reservoir-toolbar.tsx`: accept `isSuperAdmin: boolean`, `drBusy: boolean`, `drProgress`, `onRefreshDr: () => void`. Render, only when `isSuperAdmin`:

```tsx
<Button variant="outline" size="sm" onClick={onRefreshDr} disabled={drBusy}>
  {drBusy
    ? `DR ${drProgress?.done ?? 0}/${drProgress?.total ?? 0}…`
    : "Rafraîchir DR (Ahrefs)"}
</Button>
```

- [ ] **Step 6: Typecheck + reservoir tests**

Run: `npx tsc --noEmit && bun run test src/lib/backlink/`
Expected: tsc clean; backlink tests pass.

- [ ] **Step 7: Commit**

```bash
git add src/components/backlink/ src/app/(app)/backlink/reservoir/page.tsx
git commit -m "feat(backlink): DR badge + super-admin Rafraîchir DR button (chunked)"
```

---

### Task 7: Live verify + roadmap

**Files:**
- Modify: `docs/project/roadmap.md`

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

Run: `npx tsc --noEmit && bun run test src/lib/backlink/ src/lib/auth/super-admin.test.ts`
Expected: tsc clean; all pass.

- [ ] **Step 2: Set the super-admin env (USER action)**

Ask the user to add to `.env.local`: `SUPER_ADMIN_EMAILS=elevaseo99@gmail.com,bastien@elevaseo.com` (the agent cannot write `.env*`). Restart the dev server so `env.ts` re-parses.

- [ ] **Step 3: Live verify (d3k, static reads)**

As the super-admin user on `/backlink/reservoir`: the "Rafraîchir DR (Ahrefs)" button appears; click it; watch progress; confirm `domain_ratings` fills (`psql … SELECT count(*) FROM domain_ratings`) and badges flip from "—"/manual to real DR. Confirm the column header reads "DR". (Optionally) with the env unset / a non-listed user, the button is absent.

- [ ] **Step 4: Update roadmap**

In `docs/project/roadmap.md` (Backlink section), add a ✅ line: Ahrefs DR (free, keyless) in the réservoir — super-admin god-mode bulk refresh, shared `domain_ratings` cache, badge relabeled DR (`dr ?? da`). Note the prod prerequisite: set `SUPER_ADMIN_EMAILS` in the deploy env.

- [ ] **Step 5: Commit**

```bash
git add docs/project/roadmap.md
git commit -m "docs(roadmap): Ahrefs DR in backlink réservoir shipped (super-admin)"
```

---

## Notes for the executor
- Tasks are dependency-ordered: 1 (gate) → 2 (table) → 3 (client) → 4 (actions, needs 1-3) → 5 (query, needs 2) → 6 (UI, needs 1,4,5) → 7.
- Server-action files are `"use server"`; keep the injectable runner in `dr-chunk.ts` (non-action), not in `dr-actions.ts`.
- If `bun run db:migrate:test` errors on env, it loads `.env.local` (already fixed earlier in the repo).
