|
| 1 | +# Crawlproof Agency Tier — PRD |
| 2 | + |
| 3 | +> Goal: support agencies — one Crawlproof account managing N client sites under a unified dashboard and billing. ~30% of outrank.so's revenue reportedly comes from agencies; the email from Tibo cites a single agency rolling outrank across ~50 client sites as worth $1,500/mo in affiliate commissions. We have the same opportunity if we drop our "one site per user" constraint. |
| 4 | +> |
| 5 | +> Scoped as a small PRD because the actual work is small: drop one DB constraint, add a site picker, scope a few queries. No new abstractions ("workspaces", "organizations", "teams") in v1 — they all add complexity that solo customers don't need and that agencies can live without. |
| 6 | +
|
| 7 | +--- |
| 8 | + |
| 9 | +## Status as of 2026-05-15 |
| 10 | + |
| 11 | +**Phase 0 — PRD: this document.** |
| 12 | + |
| 13 | +**Phase 1 — Multi-site core: PLANNED (next).** |
| 14 | +- Drop `lx_site_user_unique`; users can create multiple `lx_site` rows. |
| 15 | +- Add a `current_site_id` cookie + a site-picker dropdown in the app nav. |
| 16 | +- Scope the existing autoblog setup / dashboard / history / article-detail routes to the active site. |
| 17 | +- Same for `/social/*` once Social Posting Phase 1 ships (PRD §3 of social-posting-prd.md updates `sp_account` to be site-scoped). |
| 18 | + |
| 19 | +**Phase 2 — Agency dashboard: PLANNED.** |
| 20 | +- A `/agency` route that shows all sites at a glance: per-site article count, publish health, credit burn, last activity. |
| 21 | +- Bulk operations: pause/resume all sites, export an agency-wide report. |
| 22 | + |
| 23 | +**Phase 3 — Client handoff: PLANNED.** |
| 24 | +- Transfer a site (and its history) from an agency's account to a separate client account. The agency stops being billed for it; the client picks up. |
| 25 | +- Useful when agencies "graduate" clients off the agency plan. |
| 26 | + |
| 27 | +**Phase 4 — Workspace abstraction: PUNTED.** |
| 28 | +- The fancy version: organizations / workspaces / role-based team invites. Adds an entire RBAC layer we don't need today. Revisit only if a real customer asks for "let our intern manage site X but not site Y." Until then, all sites under one user. |
| 29 | + |
| 30 | +**Affiliate program: SEPARATE PRD.** |
| 31 | +- The 30%-lifetime piece from outrank's pitch is its own doc (Stripe payouts, referral attribution, /affiliate dashboard). Cross-references the agency tier but ships independently. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## 1. Competitive recon — how outrank.so handles agencies |
| 36 | + |
| 37 | +(Already partially captured in `docs/link-exchange-prd.md` §1.2 from the 2026-05-13 reverse-engineering session.) |
| 38 | + |
| 39 | +- Outrank's tenancy: `organizations 1─* users` and `organizations 1─* products`. The "product" = one customer site. Agencies create many products under one organization. |
| 40 | +- The active product is selected via a `current_product_id` cookie. Switching products in the UI just rewrites that cookie. |
| 41 | +- Per-product credit balance. Each product (= site) has its own `monthly_backlink_credits` and renewal date. |
| 42 | +- Per-product billing: outrank charges $89–99/mo *per product*. Agency with 50 clients = 50 line items. |
| 43 | + |
| 44 | +**What we copy:** the `current_site_id` cookie, the per-site grouping for dashboards. |
| 45 | + |
| 46 | +**What we change:** |
| 47 | +- Single billing rollup, not per-site. Agencies want one invoice, not 50. The credit ledger stays per-user; sites consume from the same balance. |
| 48 | +- No organizations table. The agency *is* the user. Until someone asks for team invites, we don't need RBAC. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## 2. Schema changes |
| 53 | + |
| 54 | +Minimal. The autoblog v1 migration already documented this transition: |
| 55 | + |
| 56 | +```sql |
| 57 | +-- supabase/migrations/v1: "v1: one site per user. Drop this unique |
| 58 | +-- index to enable multi-site later." |
| 59 | +create unique index if not exists lx_site_user_unique on public.lx_site(user_id); |
| 60 | +``` |
| 61 | + |
| 62 | +This migration drops it and adds a name field so the picker can show something meaningful: |
| 63 | + |
| 64 | +```sql |
| 65 | +-- Drop the v1 constraint that prevented multi-site agencies. |
| 66 | +drop index if exists public.lx_site_user_unique; |
| 67 | + |
| 68 | +-- A human-readable name shown in the site picker. Defaults to the |
| 69 | +-- domain. Agency can rename ("Acme client" vs. acme-corp.com). |
| 70 | +alter table public.lx_site |
| 71 | + add column if not exists name text; |
| 72 | + |
| 73 | +-- Backfill: existing rows take domain as the name. |
| 74 | +update public.lx_site set name = domain where name is null; |
| 75 | +``` |
| 76 | + |
| 77 | +That's it for Phase 1. Domain uniqueness (`lx_site_domain_unique`) stays — a single domain can't be enrolled twice across the whole network, agency or not. |
| 78 | + |
| 79 | +### 2.1 What about `sp_account` (Social Posting)? |
| 80 | + |
| 81 | +Updates `docs/social-posting-prd.md` §3: |
| 82 | + |
| 83 | +```sql |
| 84 | +create table sp_account ( |
| 85 | + id uuid primary key default gen_random_uuid(), |
| 86 | + user_id uuid not null references public.profiles(id) on delete cascade, |
| 87 | + site_id uuid not null references public.lx_site(id) on delete cascade, |
| 88 | + -- ^^ NEW |
| 89 | + ... |
| 90 | +); |
| 91 | +create unique index on sp_account(site_id, platform, external_id); |
| 92 | +``` |
| 93 | + |
| 94 | +Each connected social account belongs to a *site* (agency: a client). The agency operator's `user_id` is on the row for RLS, but the natural grouping is `site_id`. |
| 95 | + |
| 96 | +### 2.2 Per-site rollup queries (the agency dashboard) |
| 97 | + |
| 98 | +The agency dashboard is just one query per stat: |
| 99 | + |
| 100 | +```sql |
| 101 | +-- Articles published this month, grouped by site. |
| 102 | +select s.id, s.name, s.domain, count(a.id) as published_this_month |
| 103 | +from lx_site s |
| 104 | +left join lx_article a |
| 105 | + on a.site_id = s.id |
| 106 | + and a.status = 'published' |
| 107 | + and a.published_at >= date_trunc('month', now()) |
| 108 | +where s.user_id = $1 |
| 109 | +group by s.id; |
| 110 | +``` |
| 111 | + |
| 112 | +No joins across users, no permission gymnastics. Just `where user_id = $1`. |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## 3. UI surface changes |
| 117 | + |
| 118 | +### 3.1 Site picker |
| 119 | + |
| 120 | +Lives in the app nav (`app/(app)/layout.tsx`), to the left of the credits badge. A dropdown showing all the user's sites. Selecting one writes `current_site_id` to a cookie and refreshes. |
| 121 | + |
| 122 | +For single-site users it's a no-op (one row shown). For agencies, it's the navigation primitive that turns 50 client sites from chaos into a list. |
| 123 | + |
| 124 | +```tsx |
| 125 | +<SitePicker |
| 126 | + sites={sites} |
| 127 | + current={currentSiteId} |
| 128 | + onChange={(id) => { document.cookie = `current_site_id=${id}; path=/`; router.refresh(); }} |
| 129 | +/> |
| 130 | +``` |
| 131 | + |
| 132 | +### 3.2 Site-scoped routes |
| 133 | + |
| 134 | +These routes all currently fetch the user's single site. Update each to read `current_site_id` from cookie and fetch *that* site (with a 404 + redirect fallback if the cookie's stale): |
| 135 | + |
| 136 | +- `/autoblog` (dashboard) |
| 137 | +- `/autoblog/setup` (form) |
| 138 | +- `/autoblog/history` (article list) |
| 139 | +- `/autoblog/articles/[id]` (detail) — already site-scoped via the article row; just need to confirm RLS keeps it that way |
| 140 | + |
| 141 | +Server-side helper: `lib/lx/currentSite.ts` — given the cookie + user, return the active site or null. |
| 142 | + |
| 143 | +### 3.3 New site creation |
| 144 | + |
| 145 | +`/autoblog/setup` becomes "create / edit one of your sites." The discover wizard from the URL-onboarding flow runs on each new site. Existing setup flow works unchanged for the *current* site; adding a "+ New site" button in the picker lets users start a fresh wizard. |
| 146 | + |
| 147 | +### 3.4 Agency dashboard (Phase 2) |
| 148 | + |
| 149 | +`/agency` — only shown when the user has ≥2 sites (single-site users don't need it). Layout: |
| 150 | + |
| 151 | +- Top row: aggregate stats (sites managed, articles published this month, credit burn this month). |
| 152 | +- Table of sites, one row each: name, domain, status badge, articles-this-month, last article date, credit balance row showing credits consumed. |
| 153 | +- Per-row actions: pause, view dashboard, edit settings, transfer (Phase 3). |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## 4. Per-feature impact |
| 158 | + |
| 159 | +Walking the existing features to confirm what each needs. |
| 160 | + |
| 161 | +| Feature | Today | After multi-site | |
| 162 | +|---|---|---| |
| 163 | +| Autoblog dashboard | one site per user | site picker; selected site's stats | |
| 164 | +| Autoblog setup wizard | edits the user's site | edits the *current* site; "+ New site" creates another | |
| 165 | +| Autoblog article history | the user's articles | current site's articles | |
| 166 | +| Webhook delivery | uses `lx_site.webhook_url` | unchanged — each site has its own webhook | |
| 167 | +| Perf-report email | aggregates across the user's audits + autoblog | aggregates across **all sites** for that user; agency emails contain N sub-sections | |
| 168 | +| Social posting (when it lands) | site-scoped from day one (see §2.1) | — | |
| 169 | +| Link exchange (when it lands) | per-site toggle (`backlinks_enabled`) | per-site toggle, agency can flip it on for some sites and not others | |
| 170 | +| Credit ledger | per-user | per-user (no change); agency just sees one balance funding all sites | |
| 171 | + |
| 172 | +--- |
| 173 | + |
| 174 | +## 5. Pricing model |
| 175 | + |
| 176 | +**One credit balance per user, shared across all their sites.** No per-site billing. |
| 177 | + |
| 178 | +Why: agencies want one invoice. Credits are fungible — a credit that would have been spent on Site A's article can be spent on Site B's article. Simpler ops, less customer support friction. |
| 179 | + |
| 180 | +Stripe / billing impact: zero. The existing credit packs (Starter $9 / Growth $25 / Pro $99) work unchanged. A heavier-usage customer (50-site agency publishing daily) burns through Pro every ~2 weeks instead of every 9 months; they top up more often. |
| 181 | + |
| 182 | +### 5.1 Pricing the agency tier (later) |
| 183 | + |
| 184 | +When the affiliate PRD lands, we'll likely want an **agency tier** with discounted credits at volume (e.g. 5000 credits for $349 = $0.07/credit vs. the $0.20/credit Pro pack rate). That's a pricing move, not a schema move; deferred. |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## 6. Migration plan (zero downtime) |
| 189 | + |
| 190 | +1. Run the SQL migration during the next deploy. Drops the unique index, adds the `name` column, backfills. |
| 191 | +2. Ship the site-picker UI in the same deploy. Single-site users see a one-item dropdown they can ignore. |
| 192 | +3. The `current_site_id` cookie defaults to the user's first (and only) site for existing users. |
| 193 | +4. Add the "+ New site" button. |
| 194 | + |
| 195 | +No data migration of articles, integrations, keywords needed — all those tables already reference `lx_site.id`, not `user_id`. Multi-site just means the user can now own multiple rows in `lx_site`. |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 7. Risks |
| 200 | + |
| 201 | +- **Stale `current_site_id` cookie after delete.** If an agency deletes a site, any tab still holding that cookie 404s. Server-side helper falls back to the user's first site and rewrites the cookie. Handled in `currentSite.ts`. |
| 202 | +- **Cross-site data leaks via misscoped queries.** Every route that fetches site data needs `where site_id = $current AND user_id = $auth_uid`. Easy to forget the `user_id` half. Mitigation: a `getCurrentSiteOrThrow()` helper that always joins on both, used by every route handler. |
| 203 | +- **Articles attributed to the wrong site after a webhook URL collision.** Mitigation: `lx_site.webhook_secret` is per-site; the receiver's incoming bearer is what disambiguates which site published. Already enforced. |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +## 8. Build sequence |
| 208 | + |
| 209 | +1. **Migration** — drop unique index + add `name`. New file `20260516000000_lx_site_multi.sql`. |
| 210 | +2. **`currentSite.ts` helper** — reads cookie, validates ownership, returns the row or redirects. |
| 211 | +3. **Site picker** in `app/(app)/layout.tsx`. |
| 212 | +4. **Refactor autoblog routes** — `/autoblog`, `/autoblog/setup`, `/autoblog/history`, `/autoblog/articles/[id]` — to use the helper. |
| 213 | +5. **"+ New site" flow** — clicking it routes to `/autoblog/setup?new=1` which forces the discover wizard. |
| 214 | +6. **Agency dashboard** (Phase 2) — `/agency` route, only renders for users with `count(sites) >= 2`. |
| 215 | +7. **Client handoff** (Phase 3) — `/agency/transfer/[siteId]` form. Generates a transfer code; recipient claims via `/transfer/claim/[code]`. |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## 9. Open questions for before build |
| 220 | + |
| 221 | +- **Name field default.** "acme-corp.com" is a fine technical default; agencies will probably want "Acme Corp" without the TLD. Worth a `lib/lx/domainName.ts` heuristic (strip TLD, title-case)? |
| 222 | +- **Site limits.** Should we cap free-tier accounts to 1 site to prevent agency-on-a-free-plan? Yes — but enforce it as a server-side check at site-create time, not a UI hide. |
| 223 | +- **"+ New site" credit cost.** Free to create, or 1 credit? Outrank charges $89-99/mo *per product*. We charge per-article. Probably free to create; the credit cost shows up when articles publish. |
| 224 | +- **Per-site team invites (deferred to Phase 4).** Worth even mentioning in marketing copy? Agencies might assume it works. |
| 225 | + |
| 226 | +--- |
| 227 | + |
| 228 | +## 10. Out of scope for v1 |
| 229 | + |
| 230 | +- Organizations / workspaces / teams (Phase 4 punt) |
| 231 | +- Role-based access (only `is_admin` exists today, app-wide) |
| 232 | +- Per-site billing / per-site invoices |
| 233 | +- Per-site Stripe customers |
| 234 | +- Sub-user invites ("our intern manages site X") |
| 235 | +- Hierarchical site groups ("Acme has 3 sub-brands") |
| 236 | +- White-label / co-branded agency dashboards |
| 237 | +- Bulk import of sites from a CSV |
| 238 | +- API access ("hit our API to provision a site programmatically") — agencies want this, separate small PRD when it surfaces |
| 239 | + |
| 240 | +--- |
| 241 | + |
| 242 | +## 11. Dependencies on other PRDs |
| 243 | + |
| 244 | +- **Autoblog (`docs/link-exchange-prd.md`)** — shipped. The site-picker scoping touches every autoblog route; this PRD is what unlocks Autoblog for agencies. |
| 245 | +- **Social Posting (`docs/social-posting-prd.md`)** — PRD'd, Phase 1 about to start. Affected: `sp_account` schema needs `site_id`. Updated in §2.1 of this PRD; will also update the social-posting PRD's §3 in the same commit that lands this PRD. |
| 246 | +- **Viral Video (`docs/viral-video-prd.md`)** — PRD'd, not yet built. Affected: `vid_job.site_id` already exists in that PRD's schema. No change needed. |
| 247 | +- **Affiliate program (TBD)** — separate PRD when ready. Will reference this one for the "agencies onboard many sites" growth story. |
0 commit comments