# Ahrefs Domain Rating in Backlink Réservoir

**Date:** 2026-06-10
**Source:** Ahrefs "Get Domain Rating (free)" — `GET https://api.ahrefs.com/v3/public/domain-rating-free?target=<domain>&output=json`, **no API key**, returns `{ "domain_rating": { "domain_rating": <float 0-100> } }`.

**Decisions (brainstorm):** metric = rename DA→**DR** (single 0-100); access = **super-admin (env email) god-mode only**; storage = **shared per-domain cache**; trigger = **chunked client-driven bulk** button.

---

## Goal

A god-mode super-admin populates real Ahrefs Domain Rating for reservoir sources via the free keyless endpoint. DR is cached once per domain (shared across all orgs) and shown in the reservoir's existing strength badge, relabeled "DR".

## A. Metric: DA → DR

The reservoir already shows a 0-100 "DA" strength badge (`DaBadge`, a `RingGauge`). Ahrefs DR is the same concept from an authoritative free source. We relabel the displayed metric to **DR**:

- `src/components/backlink/da-badge.tsx` → `DrBadge` (rename component + the `title`/`aria` text to "DR"). Update its import sites (`columns.tsx`, anywhere `DaBadge` is used).
- The per-org `linkSources.da` column is **kept** as a manual fallback — not dropped, not renamed in the DB. Displayed DR = **cached Ahrefs DR if present, else manual `da`, else "—"**.
- No other "DA" semantics change; this is a display relabel + a new resolved `dr` value (Section F).

## B. Super-admin gate (god-mode)

No super-admin concept exists today (only Better Auth org `role: "owner"`). Introduce a lightweight env-based gate:

- `src/env.ts`: add `SUPER_ADMIN_EMAILS: z.string().optional()` (comma-separated emails). **The user must add `SUPER_ADMIN_EMAILS=...` to `.env.local`** (the agent cannot write `.env*`).
- New `src/lib/auth/super-admin.ts`:
  - `isSuperAdmin(email: string | null | undefined): boolean` — pure, case-insensitive, trims, splits the env list.
  - `assertSuperAdmin(): Promise<void>` — reads the session email (via the existing `active-org.ts` / Better Auth `auth.api.getSession`) and throws `Error("Forbidden: super-admin only")` if `!isSuperAdmin(email)`.
- A client-readable boolean is surfaced to the reservoir page (RSC) so the button renders only for super-admins (the action also re-checks server-side — defense in depth).

## C. Shared cache table `domain_ratings`

DR is a property of the domain, not the org, and the same ~380 default domains are seeded into every org. Store DR once, centrally:

```ts
export const domainRatings = pgTable("domain_ratings", {
  domainNorm: text("domain_norm").primaryKey(), // "producthunt.com"
  dr: integer("dr").notNull(),                   // 0-100 (rounded)
  fetchedAt: timestamp("fetched_at", { withTimezone: true }).defaultNow().notNull(),
});
```

Drizzle migration via `db:generate` + `db:migrate` + `db:migrate:test` (never `db:push`). No FK — `domainNorm` is a free key shared across orgs.

## D. Ahrefs DR client

`src/lib/backlink/ahrefs-dr.ts` (server-only), mirroring `src/lib/keywords/firecrawl.ts`:

- `fetchDomainRating(domainNorm: string): Promise<DrResult>` where `DrResult = { ok: true; dr: number } | { ok: false; reason: "invalid" | "rate_limited" | "error" }`.
- `GET https://api.ahrefs.com/v3/public/domain-rating-free?target=<domainNorm>&output=json`. No auth header.
- Parse `body.domain_rating.domain_rating` → `Math.round(...)`, clamp 0-100. Missing/NaN → `{ ok:false, reason:"invalid" }`.
- HTTP 429 → `{ ok:false, reason:"rate_limited" }`; other non-2xx / network throw caught → `{ ok:false, reason:"error" }`.
- A short per-request timeout (AbortController, ~8s).

## E. Bulk fetch — chunked, throttled, super-admin only

Server Actions cap payload/time, and ~380 sequential external calls would blow the timeout, so the bulk runs **client-driven in chunks** (the established pattern for the 1MB CSV limit):

- `src/lib/backlink/dr-actions.ts`:
  - `fetchStaleDomainsAction(maxAgeDays = 30): Promise<string[]>` — `assertSuperAdmin()`, returns **distinct `domainNorm`s** from `linkSources` whose `domain_ratings` row is missing or older than `maxAgeDays`. (Distinct across the directory — DR is shared.)
  - `fetchDomainRatingsChunkAction(domains: string[]): Promise<DrChunkResult>` — `assertSuperAdmin()`; for each domain (small concurrency, e.g. 4, with a ~150ms inter-batch delay) call the injected fetcher; **upsert** successful results into `domain_ratings` (`onConflictDoUpdate` set `dr`,`fetchedAt`); return `{ updated: number; rateLimited: boolean; failed: string[] }`. The fetcher is **dependency-injected** (default = real `fetchDomainRating`) so tests don't hit the network.
- UI: `reservoir-toolbar.tsx` gains a **"Rafraîchir DR (Ahrefs)"** button, rendered only when `isSuperAdmin` (prop from RSC). On click: call `fetchStaleDomainsAction`, then loop the returned domains in chunks of ~25 calling `fetchDomainRatingsChunkAction`, accumulating progress. Show a progress indicator ("N/total") via sonner or an inline counter; if a chunk returns `rateLimited`, stop and toast "Limite Ahrefs atteinte — réessaie plus tard". On finish, `router.refresh()` so badges update.

## F. Badge resolution

`getReservoir(orgId)` (`src/lib/backlink/queries.ts`) left-joins `domain_ratings` on `domainNorm`; each reservoir row gains `dr: number | null` (the cached value). The `ReservoirRow` type + `columns.tsx` use `dr ?? da` for the `DrBadge`. No per-org DR writes — display reads the shared cache.

## G. Testing

- **Pure:** `super-admin.test.ts` — `isSuperAdmin` allow/deny (case, spaces, empty env). `ahrefs-dr.test.ts` — `fetchDomainRating` with a mocked `fetch`: valid float → rounded int; 429 → `rate_limited`; 4xx/garbage JSON → `invalid`/`error`.
- **DB:** `dr-actions.test.ts` — seed `linkSources` + partial `domain_ratings`; `fetchStaleDomainsAction` returns only missing/stale distinct domains; `fetchDomainRatingsChunkAction` (with an injected stub fetcher) upserts and reports counts, and a 429 stub sets `rateLimited`. `queries` test: `getReservoir` resolves `dr` from cache over manual `da`.
- **Live (d3k, static reads):** as super-admin, the "Rafraîchir DR" button appears; a small run populates `domain_ratings` and badges flip from "—"/manual to real DR. As a non-super-admin, the button is absent and the action rejects.

## H. Out of scope

- Per-row DR refresh; auto-fetch on source add (regular users must not trigger Ahrefs).
- Other Ahrefs free metrics (traffic, backlink counts); scheduled/cron refresh.
- Backfilling DR into the org-bootstrap seed constant.
- A super-admin UI beyond this button (the env list is the source of truth).

## Notes / runbook

- `SUPER_ADMIN_EMAILS` must be set in `.env.local` (dev) and the deploy env (prod). Absent → no one is super-admin → the button never shows and the action always rejects (safe default).
- Ahrefs free-endpoint rate limits are undocumented; the chunked+throttled design + 429 backoff is the safeguard. Tune chunk size/concurrency/delay if 429s appear.
