Skip to content

Commit 36afd6a

Browse files
committed
docs(prds): agency-tier PRD + site_id scoping on sp_account
Forwarded outrank.so marketing email re: their agency push surfaced the gap — we have a unique(user_id) index on lx_site that explicitly documents itself as "drop to enable multi-site later." Now's later. New: docs/agency-prd.md Multi-site per user with the smallest possible surface change. Drop the unique index, add a name column on lx_site, add a current_site_id cookie + site-picker dropdown, scope the existing autoblog routes. No workspaces / no organizations / no RBAC layer — the agency *is* the user. One credit balance funds all their sites so they get one invoice. Three phases: Phase 1: multi-site core (the migration + UI scoping) Phase 2: /agency dashboard across-all-sites Phase 3: client handoff (transfer a site's history off the agency's plan to a separate user) Phase 4: workspace abstraction — PUNTED until someone asks. Affiliate program (the 30%-lifetime piece in outrank's pitch) is explicitly out of scope for this PRD; gets its own when ready. Update: docs/social-posting-prd.md sp_account gains site_id; uniqueness moves from (user_id, platform, external_id) to (site_id, platform, external_id). sp_post gains site_id. Cross-references agency-prd.md §2.1 for the why. Means social-posting Phase 1 lands site-scoped from day one — no painful re-scope migration later.
1 parent 5bf4de9 commit 36afd6a

2 files changed

Lines changed: 258 additions & 2 deletions

File tree

‎docs/agency-prd.md‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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.

‎docs/social-posting-prd.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,17 @@ What we want from recon:
7272
New tables, all prefixed `sp_`.
7373

7474
```sql
75-
-- A user's connected social account on one platform.
75+
-- A connected social account on one platform, for one site.
76+
--
77+
-- Scoped to a site (lx_site.id), not just the user, so an agency
78+
-- managing N client sites has each client's social accounts in their
79+
-- own bucket. See docs/agency-prd.md §2.1 for the multi-site rationale.
80+
-- user_id stays on the row for RLS; site_id is the natural grouping
81+
-- for dashboards and per-client billing rollups.
7682
create table sp_account (
7783
id uuid primary key default gen_random_uuid(),
7884
user_id uuid not null references public.profiles(id) on delete cascade,
85+
site_id uuid not null references public.lx_site(id) on delete cascade,
7986
platform text not null check (platform in (
8087
'x','linkedin','reddit','mastodon','bluesky','threads','pinterest','tumblr',
8188
'facebook_page','instagram_business','youtube','tiktok','instagram','snapchat'
@@ -114,12 +121,14 @@ create table sp_account (
114121
created_at timestamptz not null default now(),
115122
updated_at timestamptz not null default now()
116123
);
117-
create unique index on sp_account(user_id, platform, external_id);
124+
create unique index on sp_account(site_id, platform, external_id);
125+
create index on sp_account(user_id); -- RLS lookups
118126

119127
-- A queued or sent post. One row per (platform target, scheduled-time) pair.
120128
create table sp_post (
121129
id uuid primary key default gen_random_uuid(),
122130
user_id uuid not null references public.profiles(id) on delete cascade,
131+
site_id uuid not null references public.lx_site(id) on delete cascade,
123132
account_id uuid not null references sp_account(id) on delete cascade,
124133
-- Source of truth for the content. Either crawlproof-generated or user-paste.
125134
source text not null check (source in ('autoblog','manual','rss','api')),

0 commit comments

Comments
 (0)