Skip to content

Commit 90dff9a

Browse files
committed
feat(agency): multi-site per user + site picker + M:N social-binding spec
Migration 20260516000000_lx_site_multi.sql: - Drop unique(user_id) on lx_site. Users can now own multiple sites. - Add lx_site.name (defaults to domain) so the picker shows "Acme Client" instead of "acme-corp.com" when the user renames. - Add lx_site_user_idx for the agency dashboard. Site picker UX: - lib/lx/currentSite.ts — getCurrentSite() / listUserSites() / setCurrentSite() helpers. getCurrentSite reads a current_site_id cookie, validates the signed-in user owns the row, falls back to the user's first site when the cookie is missing or stale. - app/(app)/site-picker.tsx — dropdown in the nav. Single-site users see a 1-item picker (useful because the "+ New site" entry is reachable from the same control). 2+ sites flip it into a real picker. - app/actions/currentSite.ts — switchSite() server action that verifies ownership before writing the cookie. Site-scoped routes (everywhere we previously did .eq("user_id", user.id).maybeSingle() to fetch "the user's site"): - /autoblog (dashboard) - /autoblog/setup (form, with ?new=1 flag for the "+ New site" flow) - /autoblog/history - createOrUpdateSite() server action — takes optional siteId; new creates auto-set current_site_id so onboarding lands on the new site. - rotateWebhookSecret() / setSitePaused() — operate on current site. - /api/lx/sitemap/refresh, /api/lx/keywords/refresh, /api/lx/articles/generate — all read the current-site cookie. Social-posting PRD revision: Restructured sp_account to be user-scoped (one pool per user, agencies reuse "Acme LinkedIn" across multiple sites) and added a junction table sp_site_account with: - auto:bool — gate AI auto-generation per (site, account) - render_overrides:jsonb — per-site rendering knob overrides - enabled:bool — disable a binding without deleting it §6 now describes the auto-mode pipeline: a site publishes → one sp_post per auto=true binding, per-platform-rendered. Tests + typecheck + build all green. Migration applied to prod.
1 parent 36afd6a commit 90dff9a

14 files changed

Lines changed: 375 additions & 60 deletions

File tree

‎app/(app)/autoblog/history/page.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import { redirect } from "next/navigation";
33
import { createClient } from "@/lib/supabase/server";
4+
import { getCurrentSite } from "@/lib/lx/currentSite";
45
import { RetryButton } from "../actions";
56

67
export const metadata = { title: "Autoblog · History" };
@@ -21,11 +22,9 @@ export default async function AutoblogHistoryPage() {
2122
} = await supabase.auth.getUser();
2223
if (!user) redirect("/login");
2324

24-
const { data: site } = await supabase
25-
.from("lx_site")
26-
.select("id, domain")
27-
.eq("user_id", user.id)
28-
.maybeSingle();
25+
const site = (await getCurrentSite("id, domain")) as
26+
| { id: string; domain: string }
27+
| null;
2928
if (!site) redirect("/autoblog/setup");
3029

3130
const { data: articles } = await supabase

‎app/(app)/autoblog/page.tsx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import { redirect } from "next/navigation";
33
import { createClient } from "@/lib/supabase/server";
4+
import { getCurrentSite } from "@/lib/lx/currentSite";
45
import { DashboardActions } from "./actions";
56
import { Countdown } from "./countdown";
67
import { checkAutoblogReadiness, readinessLabel } from "@/lib/lx/readiness";
@@ -35,13 +36,22 @@ export default async function AutoblogDashboardPage() {
3536
} = await supabase.auth.getUser();
3637
if (!user) redirect("/login");
3738

38-
const { data: site } = await supabase
39-
.from("lx_site")
40-
.select(
41-
"id, domain, status, sitemap_status, last_sitemap_fetch_at, publish_days, publish_hour, daily_article_count, next_publish_at, webhook_url",
42-
)
43-
.eq("user_id", user.id)
44-
.maybeSingle();
39+
const site = (await getCurrentSite(
40+
"id, domain, status, sitemap_status, last_sitemap_fetch_at, publish_days, publish_hour, daily_article_count, next_publish_at, webhook_url",
41+
)) as
42+
| {
43+
id: string;
44+
domain: string;
45+
status: string;
46+
sitemap_status: string | null;
47+
last_sitemap_fetch_at: string | null;
48+
publish_days: number[];
49+
publish_hour: number;
50+
daily_article_count: number;
51+
next_publish_at: string | null;
52+
webhook_url: string | null;
53+
}
54+
| null;
4555

4656
if (!site) {
4757
return (

‎app/(app)/autoblog/setup/form.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export function SetupForm({ initial }: { initial: Existing | null }) {
254254
setNotice(null);
255255
startTransition(async () => {
256256
const res = await createOrUpdateSite({
257+
siteId: initial?.id,
257258
domain,
258259
blogRootUrl: blogRoot,
259260
sitemapUrl: sitemap,

‎app/(app)/autoblog/setup/page.tsx‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import Link from "next/link";
22
import { redirect } from "next/navigation";
33
import { createClient } from "@/lib/supabase/server";
4+
import { getCurrentSite } from "@/lib/lx/currentSite";
45
import { SetupForm } from "./form";
56

67
export const metadata = { title: "Autoblog · Setup" };
78

8-
export default async function AutoblogSetupPage() {
9+
const SITE_COLUMNS =
10+
"id, domain, blog_root_url, sitemap_url, niche, target_audiences, description, webhook_url, webhook_secret, daily_article_count, publish_days, publish_hour, internal_links_per_article, backlinks_enabled, external_links_per_article, status";
11+
12+
export default async function AutoblogSetupPage({
13+
searchParams,
14+
}: {
15+
searchParams: Promise<{ new?: string }>;
16+
}) {
917
const supabase = await createClient();
1018
const {
1119
data: { user },
1220
} = await supabase.auth.getUser();
1321
if (!user) redirect("/login");
1422

15-
const { data: site } = await supabase
16-
.from("lx_site")
17-
.select(
18-
"id, domain, blog_root_url, sitemap_url, niche, target_audiences, description, webhook_url, webhook_secret, daily_article_count, publish_days, publish_hour, internal_links_per_article, backlinks_enabled, external_links_per_article, status",
19-
)
20-
.eq("user_id", user.id)
21-
.maybeSingle();
23+
// ?new=1 forces the discover wizard, used by "+ New site" in the
24+
// picker. Otherwise resolve the active site via the picker cookie.
25+
const params = await searchParams;
26+
const isNew = params?.new === "1";
27+
const site = isNew ? null : await getCurrentSite(SITE_COLUMNS);
2228

2329
return (
2430
<div className="mx-auto max-w-2xl">
@@ -33,7 +39,7 @@ export default async function AutoblogSetupPage() {
3339
your webhook. You handle publishing. Link Exchange ships later — for now,
3440
articles include internal links from your sitemap only.
3541
</p>
36-
<SetupForm initial={site ?? null} />
42+
<SetupForm initial={(site as any) ?? null} />
3743
</div>
3844
);
3945
}

‎app/(app)/layout.tsx‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import Link from "next/link";
2+
import { cookies } from "next/headers";
23
import { redirect } from "next/navigation";
34
import { createClient } from "@/lib/supabase/server";
5+
import { listUserSites, CURRENT_SITE_COOKIE } from "@/lib/lx/currentSite";
6+
import { SitePicker } from "./site-picker";
47

58
export default async function AppLayout({ children }: { children: React.ReactNode }) {
69
const supabase = await createClient();
@@ -9,11 +12,16 @@ export default async function AppLayout({ children }: { children: React.ReactNod
912
} = await supabase.auth.getUser();
1013
if (!user) redirect("/login");
1114

12-
const { data: profile } = await supabase
13-
.from("profiles")
14-
.select("display_name, email, credits_balance")
15-
.eq("id", user.id)
16-
.maybeSingle();
15+
const [{ data: profile }, sites, cookieStore] = await Promise.all([
16+
supabase
17+
.from("profiles")
18+
.select("display_name, email, credits_balance")
19+
.eq("id", user.id)
20+
.maybeSingle(),
21+
listUserSites(),
22+
cookies(),
23+
]);
24+
const currentSiteId = cookieStore.get(CURRENT_SITE_COOKIE)?.value ?? null;
1725

1826
return (
1927
<div>
@@ -34,6 +42,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
3442
<Link href="/autoblog" className="hidden sm:inline">
3543
Autoblog
3644
</Link>
45+
<SitePicker sites={sites} currentId={currentSiteId} />
3746
<Link href="/settings" className="hidden sm:inline">
3847
Settings
3948
</Link>

‎app/(app)/site-picker.tsx‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { switchSite } from "@/app/actions/currentSite";
6+
import type { SiteSummary } from "@/lib/lx/currentSite";
7+
8+
// The agency-tier site picker. Renders as a small dropdown in the nav.
9+
// Single-site users still see it (showing their one site), so the
10+
// "+ New site" path is discoverable from day one. ≥2 sites flips it
11+
// into a proper picker.
12+
export function SitePicker({
13+
sites,
14+
currentId,
15+
}: {
16+
sites: SiteSummary[];
17+
currentId: string | null;
18+
}) {
19+
const router = useRouter();
20+
const [pending, start] = useTransition();
21+
const [open, setOpen] = useState(false);
22+
23+
const current = sites.find((s) => s.id === currentId) ?? sites[0];
24+
25+
if (sites.length === 0) {
26+
return (
27+
<a
28+
href="/autoblog/setup"
29+
className="text-xs uppercase tracking-wider text-[var(--color-muted)] hover:text-[var(--color-fg)]"
30+
>
31+
+ Add site
32+
</a>
33+
);
34+
}
35+
36+
const display = current?.name || current?.domain || "Site";
37+
38+
return (
39+
<div className="relative">
40+
<button
41+
type="button"
42+
onClick={() => setOpen((o) => !o)}
43+
className="inline-flex max-w-[14rem] items-center gap-1.5 truncate rounded border border-[var(--color-border)] px-2 py-1 text-xs"
44+
title={current?.domain}
45+
>
46+
<span className="size-1.5 shrink-0 rounded-full bg-[var(--color-accent)]" />
47+
<span className="truncate">{display}</span>
48+
<span className="text-[var(--color-muted)]">▾</span>
49+
</button>
50+
{open && (
51+
<div
52+
className="absolute right-0 z-30 mt-1 w-64 rounded border border-[var(--color-border)] bg-[var(--color-bg)] py-1 shadow-lg"
53+
onMouseLeave={() => setOpen(false)}
54+
>
55+
{sites.map((s) => (
56+
<button
57+
key={s.id}
58+
type="button"
59+
disabled={pending}
60+
onClick={() => {
61+
setOpen(false);
62+
start(async () => {
63+
await switchSite(s.id);
64+
router.refresh();
65+
});
66+
}}
67+
className={
68+
"block w-full px-3 py-2 text-left text-xs hover:bg-[var(--color-border)]/30 " +
69+
(s.id === current?.id ? "text-[var(--color-accent)]" : "")
70+
}
71+
>
72+
<div className="truncate">{s.name || s.domain}</div>
73+
{s.name && s.name !== s.domain && (
74+
<div className="truncate text-[10px] text-[var(--color-muted)]">{s.domain}</div>
75+
)}
76+
</button>
77+
))}
78+
<div className="my-1 border-t border-[var(--color-border)]/50" />
79+
<a
80+
href="/autoblog/setup?new=1"
81+
className="block px-3 py-2 text-xs text-[var(--color-muted)] hover:bg-[var(--color-border)]/30 hover:text-[var(--color-fg)]"
82+
onClick={() => setOpen(false)}
83+
>
84+
+ New site
85+
</a>
86+
</div>
87+
)}
88+
</div>
89+
);
90+
}

‎app/actions/currentSite.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { createClient } from "@/lib/supabase/server";
5+
import { setCurrentSite } from "@/lib/lx/currentSite";
6+
7+
// Switch the active site cookie. Verifies the signed-in user actually
8+
// owns the target site before flipping — otherwise a stale cookie or
9+
// a hand-crafted request could read another user's data.
10+
export async function switchSite(
11+
siteId: string,
12+
): Promise<{ ok: boolean; error?: string }> {
13+
const supabase = await createClient();
14+
const {
15+
data: { user },
16+
} = await supabase.auth.getUser();
17+
if (!user) return { ok: false, error: "Not authenticated." };
18+
19+
const { data } = await supabase
20+
.from("lx_site")
21+
.select("id")
22+
.eq("id", siteId)
23+
.eq("user_id", user.id)
24+
.maybeSingle();
25+
if (!data) return { ok: false, error: "Site not found." };
26+
27+
await setCurrentSite(siteId);
28+
revalidatePath("/autoblog");
29+
return { ok: true };
30+
}

‎app/actions/linkExchange.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { detectSitemapUrl } from "@/lib/lx/sitemap";
77
import { generateWebhookSecret } from "@/lib/lx/secret";
88
import { nextPublishAt } from "@/lib/lx/schedule";
99
import { enqueueSitemapCrawl } from "@/lib/lx/workerClient";
10+
import { setCurrentSite, getCurrentSite } from "@/lib/lx/currentSite";
1011
import {
1112
discoverBlogUrls,
1213
enrichBlogProfile,
@@ -119,6 +120,9 @@ export async function enrichFromUrls(input: {
119120
// On first create, generates a webhook secret and seeds next_publish_at.
120121
// ------------------------------------------------------------
121122
export type SiteInput = {
123+
// Optional: when set, update that specific site (multi-site agency
124+
// model). When unset, create a new lx_site row.
125+
siteId?: string;
122126
domain: string;
123127
blogRootUrl: string;
124128
sitemapUrl: string;
@@ -203,12 +207,21 @@ export async function createOrUpdateSite(
203207
};
204208
}
205209

206-
// Look up the existing row (v1: one per user).
207-
const { data: existing } = await supabase
208-
.from("lx_site")
209-
.select("id, webhook_secret")
210-
.eq("user_id", user.id)
211-
.maybeSingle();
210+
// Multi-site: if siteId is provided, edit that one; verify
211+
// ownership. Otherwise, this is a create.
212+
let existing:
213+
| { id: string; webhook_secret: string | null }
214+
| null = null;
215+
if (input.siteId) {
216+
const { data } = await supabase
217+
.from("lx_site")
218+
.select("id, webhook_secret")
219+
.eq("id", input.siteId)
220+
.eq("user_id", user.id)
221+
.maybeSingle();
222+
if (!data) return { ok: false, error: "Site not found." };
223+
existing = data as { id: string; webhook_secret: string | null };
224+
}
212225

213226
const nextAt = nextPublishAt(publishDays, publishHour);
214227

@@ -283,6 +296,9 @@ export async function createOrUpdateSite(
283296
}
284297

285298
await enqueueSitemapCrawl(inserted.id);
299+
// Multi-site: a fresh create becomes the active site so the user
300+
// doesn't have to manually pick it from the picker after onboarding.
301+
await setCurrentSite(inserted.id);
286302
revalidatePath("/autoblog");
287303
return { ok: true, siteId: inserted.id, webhookSecret };
288304
}
@@ -299,10 +315,14 @@ export async function rotateWebhookSecret(): Promise<
299315
} = await supabase.auth.getUser();
300316
if (!user) return { ok: false, error: "Not authenticated." };
301317

318+
const site = (await getCurrentSite("id")) as { id: string } | null;
319+
if (!site) return { ok: false, error: "No site selected." };
320+
302321
const webhookSecret = generateWebhookSecret();
303322
const { error } = await supabase
304323
.from("lx_site")
305324
.update({ webhook_secret: webhookSecret })
325+
.eq("id", site.id)
306326
.eq("user_id", user.id);
307327
if (error) return { ok: false, error: error.message };
308328
return { ok: true, webhookSecret };
@@ -318,9 +338,13 @@ export async function setSitePaused(paused: boolean): Promise<Ok | Err> {
318338
} = await supabase.auth.getUser();
319339
if (!user) return { ok: false, error: "Not authenticated." };
320340

341+
const site = (await getCurrentSite("id")) as { id: string } | null;
342+
if (!site) return { ok: false, error: "No site selected." };
343+
321344
const { error } = await supabase
322345
.from("lx_site")
323346
.update({ status: paused ? "paused" : "active" })
347+
.eq("id", site.id)
324348
.eq("user_id", user.id);
325349
if (error) return { ok: false, error: error.message };
326350
revalidatePath("/autoblog");

‎app/api/lx/articles/generate/route.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { NextResponse } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
33
import { enqueueArticleGenerate } from "@/lib/lx/workerClient";
4+
import { getCurrentSite } from "@/lib/lx/currentSite";
45

56
export const runtime = "nodejs";
67

7-
// Manually queue an article generation for the caller's site. The worker
8+
// Manually queue an article generation for the current site. The worker
89
// will pick the next queued keyword whose scheduled_for has passed; if
910
// none is due, the call is a no-op.
1011
export async function POST() {
@@ -16,11 +17,9 @@ export async function POST() {
1617
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
1718
}
1819

19-
const { data: site } = await supabase
20-
.from("lx_site")
21-
.select("id, status")
22-
.eq("user_id", user.id)
23-
.maybeSingle();
20+
const site = (await getCurrentSite("id, status")) as
21+
| { id: string; status: string }
22+
| null;
2423
if (!site) {
2524
return NextResponse.json(
2625
{ ok: false, error: "no site configured" },

0 commit comments

Comments
 (0)