diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index e37c7382..2444d563 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -39,6 +39,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
Recent
New
Ads
+ Promote
GitHub
Blog ↗
{profile?.is_admin && Admin}
diff --git a/app/(app)/promote/[id]/page.tsx b/app/(app)/promote/[id]/page.tsx
new file mode 100644
index 00000000..17d73971
--- /dev/null
+++ b/app/(app)/promote/[id]/page.tsx
@@ -0,0 +1,185 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import { cadenceLabel } from "@/lib/promote/generatePitch";
+import { PromoteListActions } from "@/components/promote/list-actions";
+import { PromoteEditForm } from "@/components/promote/promote-edit-form";
+import { AddLinksForm } from "@/components/promote/add-links-form";
+import { LinkList } from "@/components/promote/link-list";
+
+export const metadata = { title: "Promote list" };
+
+type Props = { params: Promise<{ id: string }> };
+
+export default async function PromoteDetailPage({ params }: Props) {
+ const { id } = await params;
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) notFound();
+
+ const { data: list } = await supabase
+ .from("promo_list")
+ .select("*")
+ .eq("id", id)
+ .eq("user_id", user.id)
+ .maybeSingle();
+ if (!list) notFound();
+
+ const [{ data: links }, { data: posts }, { data: accounts }] = await Promise.all([
+ supabase
+ .from("promo_link")
+ .select("id, url, title, angle, enabled, times_promoted, last_promoted_at, created_at")
+ .eq("list_id", id)
+ .order("created_at", { ascending: true }),
+ supabase
+ .from("promo_post")
+ .select("id, link_id, platform, body, status, error, credits_spent, posted_at, provider, external_post_id")
+ .eq("list_id", id)
+ .order("created_at", { ascending: false })
+ .limit(50),
+ supabase
+ .from("sp_account")
+ .select("id, platform, handle")
+ .eq("user_id", user.id)
+ .eq("status", "active")
+ .order("platform"),
+ ]);
+
+ const linkRows = (links ?? []) as Array<{
+ id: string;
+ url: string;
+ title: string | null;
+ angle: string | null;
+ enabled: boolean;
+ times_promoted: number;
+ last_promoted_at: string | null;
+ created_at: string;
+ }>;
+
+ const postRows = (posts ?? []) as Array<{
+ id: string;
+ link_id: string;
+ platform: string;
+ body: string;
+ status: string;
+ error: string | null;
+ credits_spent: number;
+ posted_at: string | null;
+ provider: string | null;
+ external_post_id: string | null;
+ }>;
+
+ const accountRows = (accounts ?? []) as Array<{
+ id: string;
+ platform: string;
+ handle: string;
+ }>;
+
+ const totalPosts = postRows.filter((p) => p.status === "posted").length;
+ const totalCredits = postRows.reduce((sum, p) => sum + (p.credits_spent ?? 0), 0);
+
+ return (
+
+
+
+
+ ← All lists
+
+
{list.name}
+
+
+
+
+ {/* Status bar */}
+
+
+ {list.status}
+
+ {list.pause_reason === "insufficient_credits" && (
+
+ Low credits — buy more
+
+ )}
+
+ {cadenceLabel(list.cadence_seconds)} · {list.post_mode} · {linkRows.length} link{linkRows.length !== 1 ? "s" : ""} · {totalPosts} posts · {totalCredits} credits spent
+
+
+
+ {/* Edit form */}
+
+
+ {/* Links */}
+
+
+ {/* Recent posts */}
+
+ Recent posts
+ {postRows.length === 0 ? (
+ No posts yet. The first drip will fire shortly if the list is running.
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/app/(app)/promote/accounts/page.tsx b/app/(app)/promote/accounts/page.tsx
new file mode 100644
index 00000000..63975d7e
--- /dev/null
+++ b/app/(app)/promote/accounts/page.tsx
@@ -0,0 +1,255 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import {
+ ConnectBlueskyForm,
+ ConnectDiscordForm,
+ ConnectTelegramForm,
+ ConnectViaCookiesForm,
+ DisconnectButton,
+} from "@/app/(app)/projects/[id]/social/setup/form";
+
+export const metadata = { title: "Promote · Accounts" };
+
+type AccountRow = {
+ id: string;
+ platform: string;
+ handle: string;
+ status: string;
+ last_post_at: string | null;
+ consecutive_failures: number;
+};
+
+const PLATFORM_LOGIN_URLS: Record = {
+ reddit: "https://www.reddit.com/login",
+ facebook_page: "https://www.facebook.com/login",
+ threads: "https://www.threads.net/login",
+ instagram: "https://www.instagram.com/accounts/login/",
+ x: "https://x.com/login",
+ linkedin: "https://www.linkedin.com/login",
+};
+
+export default async function PromoteAccountsPage() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) redirect("/login");
+
+ const { data: accounts } = await supabase
+ .from("sp_account")
+ .select(
+ "id, platform, handle, status, last_post_at, consecutive_failures",
+ )
+ .eq("user_id", user.id)
+ .order("created_at", { ascending: false });
+
+ return (
+
+
+
+ ← Promote
+
+
Social accounts
+
+ Connect your social accounts here. These are shared across Promote
+ and per-project Social features — connect once, use everywhere.
+
+
+
+ {/* Connected accounts */}
+
+ Connected accounts
+ {!accounts || accounts.length === 0 ? (
+
+ No accounts yet. Connect one below.
+
+ ) : (
+
+ {(accounts as AccountRow[]).map((a) => (
+ -
+
+
+ {a.handle}
+
+ {a.platform}
+
+
+ {a.status}
+
+
+ {a.last_post_at && (
+
+ Last post {new Date(a.last_post_at).toLocaleString()}
+
+ )}
+ {a.status !== "active" && PLATFORM_LOGIN_URLS[a.platform] && (
+
+ Session expired.{" "}
+
+ Log in to {a.platform}
+ {" "}
+ and re-export cookies below.
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Connect Bluesky */}
+
+
+ {/* Connect Reddit */}
+
+ Connect Reddit
+
+ Export cookies from reddit.com using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect Threads */}
+
+ Connect Threads
+
+ Export cookies from threads.net using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect Facebook Page */}
+
+ Connect Facebook Page
+
+ Export cookies from facebook.com using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect Instagram */}
+
+ Connect Instagram
+
+ Export cookies from instagram.com using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect X */}
+
+ Connect X (Twitter)
+
+ Export cookies from x.com using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect LinkedIn */}
+
+ Connect LinkedIn
+
+ Export cookies from linkedin.com using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect Mastodon */}
+
+ Connect Mastodon
+
+ Export cookies from your Mastodon instance using{" "}
+
+ Cookie-Editor
+
+ .
+
+
+
+
+ {/* Connect Discord */}
+
+ Connect Discord channel
+
+ In Discord: Edit Channel → Integrations → Webhooks → New Webhook → Copy URL.
+
+
+
+
+ {/* Connect Telegram */}
+
+ Connect Telegram channel
+
+ Create a bot via{" "}
+
+ @BotFather
+ {" "}
+ and add it as a channel admin.
+
+
+
+
+ );
+}
diff --git a/app/(app)/promote/new/page.tsx b/app/(app)/promote/new/page.tsx
new file mode 100644
index 00000000..17d5c6dc
--- /dev/null
+++ b/app/(app)/promote/new/page.tsx
@@ -0,0 +1,33 @@
+import { createClient } from "@/lib/supabase/server";
+import { PromoteForm } from "@/components/promote/promote-form";
+
+export const metadata = { title: "New promote list" };
+
+export default async function NewPromotePage() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ let accounts: Array<{ id: string; platform: string; handle: string }> = [];
+ if (user) {
+ const { data } = await supabase
+ .from("sp_account")
+ .select("id, platform, handle")
+ .eq("user_id", user.id)
+ .eq("status", "active")
+ .order("platform");
+ accounts = (data ?? []) as typeof accounts;
+ }
+
+ return (
+
+
New promote list
+
+ Paste links and we'll write unique marketing pitches for each, dripped across your connected social accounts.
+
+
+
+
+ );
+}
diff --git a/app/(app)/promote/page.tsx b/app/(app)/promote/page.tsx
new file mode 100644
index 00000000..ee92d6be
--- /dev/null
+++ b/app/(app)/promote/page.tsx
@@ -0,0 +1,180 @@
+import Link from "next/link";
+import { createClient } from "@/lib/supabase/server";
+import { PromoteListActions } from "@/components/promote/list-actions";
+import { cadenceLabel } from "@/lib/promote/generatePitch";
+
+export const metadata = { title: "Promote" };
+
+type ListRow = {
+ id: string;
+ name: string;
+ status: string;
+ cadence_seconds: number;
+ post_mode: string;
+ last_run_at: string | null;
+ pause_reason: string | null;
+ created_at: string;
+};
+
+type LinkCountRow = { list_id: string; count: number };
+
+export default async function PromotePage() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ let lists: ListRow[] = [];
+ const linkCountById = new Map();
+ const postCountById = new Map();
+ const creditsById = new Map();
+ let accountCount = 0;
+
+ if (user) {
+ const [{ data: listData }, { count: acctCount }] = await Promise.all([
+ supabase
+ .from("promo_list")
+ .select("id, name, status, cadence_seconds, post_mode, last_run_at, pause_reason, created_at")
+ .order("created_at", { ascending: false }),
+ supabase
+ .from("sp_account")
+ .select("id", { count: "exact", head: true })
+ .eq("user_id", user.id)
+ .eq("status", "active"),
+ ]);
+ lists = (listData as ListRow[]) ?? [];
+ accountCount = acctCount ?? 0;
+
+ if (lists.length > 0) {
+ const listIds = lists.map((l) => l.id);
+
+ // Fetch link counts per list
+ const { data: linkCounts } = await supabase
+ .from("promo_link")
+ .select("list_id")
+ .in("list_id", listIds);
+ for (const r of (linkCounts ?? []) as Array<{ list_id: string }>) {
+ linkCountById.set(r.list_id, (linkCountById.get(r.list_id) ?? 0) + 1);
+ }
+
+ // Fetch post counts + credits per list
+ const { data: postStats } = await supabase
+ .from("promo_post")
+ .select("list_id, credits_spent")
+ .in("list_id", listIds)
+ .eq("status", "posted");
+ for (const r of (postStats ?? []) as Array<{ list_id: string; credits_spent: number }>) {
+ postCountById.set(r.list_id, (postCountById.get(r.list_id) ?? 0) + 1);
+ creditsById.set(r.list_id, (creditsById.get(r.list_id) ?? 0) + (r.credits_spent ?? 0));
+ }
+ }
+ }
+
+ return (
+
+
+
Promote
+
+
+ Manage accounts
+
+
+ New promote list
+
+
+
+
+ Paste links. We write a fresh pitch for each and drip them across all your connected accounts.
+
+
+ {accountCount === 0 && (
+
+ You have no connected social accounts.{" "}
+
+ Connect an account
+ {" "}
+ to start promoting.
+
+ )}
+
+ {lists.length === 0 ? (
+
+ No promote lists yet.{" "}
+
+ Create your first list
+
+ .
+
+ ) : (
+
+ {lists.map((l) => {
+ const links = linkCountById.get(l.id) ?? 0;
+ const posts = postCountById.get(l.id) ?? 0;
+ const credits = creditsById.get(l.id) ?? 0;
+ return (
+ -
+
+
+
+ {l.name}
+
+
+ {links} link{links !== 1 ? "s" : ""} · {cadenceLabel(l.cadence_seconds)} ·{" "}
+ {l.post_mode}
+
+
+
+
+ {l.status}
+
+ {l.pause_reason === "insufficient_credits" && (
+
+ low credits
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function MiniStat({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}:
+ {value}
+
+ );
+}
diff --git a/app/actions/promote.ts b/app/actions/promote.ts
new file mode 100644
index 00000000..39fafb7c
--- /dev/null
+++ b/app/actions/promote.ts
@@ -0,0 +1,295 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { parseLinks, fetchLinkTitle } from "@/lib/promote/generatePitch";
+
+type Ok> = { ok: true } & T;
+type Err = { ok: false; error: string };
+
+async function requireUser(): Promise<
+ { ok: true; userId: string; email: string } | Err
+> {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+ return { ok: true, userId: user.id, email: user.email ?? "" };
+}
+
+// -------- Create a promote list --------
+export async function createPromoteList(input: {
+ name: string;
+ links: string;
+ cadenceSeconds: number;
+ postMode: "trickle" | "burst";
+ brandVoice: string;
+ targetAccountIds: string[] | null;
+}): Promise | Err> {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+
+ const name = (input.name ?? "").trim() || "Promote list";
+ const urls = parseLinks(input.links);
+ if (urls.length === 0) {
+ return { ok: false, error: "Paste at least one valid URL." };
+ }
+ if (urls.length > 200) {
+ return { ok: false, error: "Maximum 200 links per list." };
+ }
+
+ const cadence = input.cadenceSeconds;
+ if (cadence < 300 || cadence > 604800) {
+ return { ok: false, error: "Cadence must be between 5 minutes and 7 days." };
+ }
+
+ const svc = serviceClient();
+
+ // Create the list
+ const { data: list, error: listErr } = await svc
+ .from("promo_list")
+ .insert({
+ user_id: auth.userId,
+ name,
+ status: "running",
+ cadence_seconds: cadence,
+ post_mode: input.postMode ?? "trickle",
+ brand_voice: input.brandVoice?.trim() || null,
+ target_account_ids: input.targetAccountIds,
+ next_run_at: new Date().toISOString(),
+ })
+ .select("id")
+ .single();
+ if (listErr || !list) {
+ return { ok: false, error: listErr?.message ?? "Could not create list." };
+ }
+
+ // Insert links (fetch titles in the background — don't block the response)
+ const linkRows = urls.map((url) => ({
+ list_id: list.id,
+ url,
+ enabled: true,
+ }));
+ const { error: linksErr } = await svc.from("promo_link").insert(linkRows);
+ if (linksErr) {
+ // Clean up the list if links failed
+ await svc.from("promo_list").delete().eq("id", list.id);
+ return { ok: false, error: linksErr.message };
+ }
+
+ // Best-effort title fetching (non-blocking)
+ fetchTitlesForList(list.id, urls).catch(() => {});
+
+ revalidatePath("/promote");
+ return { ok: true, listId: list.id as string };
+}
+
+// Background title fetcher
+async function fetchTitlesForList(listId: string, urls: string[]) {
+ const svc = serviceClient();
+ for (const url of urls.slice(0, 50)) {
+ try {
+ const title = await fetchLinkTitle(url);
+ if (title) {
+ await svc
+ .from("promo_link")
+ .update({ title })
+ .eq("list_id", listId)
+ .eq("url", url);
+ }
+ } catch {
+ // ignore
+ }
+ }
+}
+
+// -------- Update a promote list --------
+export async function updatePromoteList(input: {
+ listId: string;
+ name: string;
+ cadenceSeconds: number;
+ postMode: "trickle" | "burst";
+ brandVoice: string;
+ targetAccountIds: string[] | null;
+}): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+
+ const svc = serviceClient();
+ const { error } = await svc
+ .from("promo_list")
+ .update({
+ name: input.name.trim() || "Promote list",
+ cadence_seconds: input.cadenceSeconds,
+ post_mode: input.postMode ?? "trickle",
+ brand_voice: input.brandVoice?.trim() || null,
+ target_account_ids: input.targetAccountIds,
+ })
+ .eq("id", input.listId)
+ .eq("user_id", auth.userId);
+ if (error) return { ok: false, error: error.message };
+
+ revalidatePath("/promote");
+ revalidatePath(`/promote/${input.listId}`);
+ return { ok: true };
+}
+
+// -------- Add links to an existing list --------
+export async function addLinksToList(input: {
+ listId: string;
+ links: string;
+}): Promise | Err> {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+
+ const urls = parseLinks(input.links);
+ if (urls.length === 0) {
+ return { ok: false, error: "Paste at least one valid URL." };
+ }
+
+ const svc = serviceClient();
+
+ // Verify ownership
+ const { data: list } = await svc
+ .from("promo_list")
+ .select("id")
+ .eq("id", input.listId)
+ .eq("user_id", auth.userId)
+ .maybeSingle();
+ if (!list) return { ok: false, error: "List not found." };
+
+ const linkRows = urls.map((url) => ({
+ list_id: input.listId,
+ url,
+ enabled: true,
+ }));
+ // Use upsert to skip duplicates (unique on list_id, url)
+ const { error, count } = await svc
+ .from("promo_link")
+ .upsert(linkRows, { onConflict: "list_id,url", ignoreDuplicates: true, count: "exact" });
+ if (error) return { ok: false, error: error.message };
+
+ // Best-effort title fetching
+ fetchTitlesForList(input.listId, urls).catch(() => {});
+
+ revalidatePath(`/promote/${input.listId}`);
+ return { ok: true, added: count ?? urls.length };
+}
+
+// -------- Remove a link --------
+export async function removeLink(linkId: string): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+
+ const svc = serviceClient();
+ // Verify ownership via join
+ const { data: link } = await svc
+ .from("promo_link")
+ .select("id, list_id, promo_list!inner(user_id)")
+ .eq("id", linkId)
+ .maybeSingle();
+ if (!link) return { ok: false, error: "Link not found." };
+ const listOwner = (link as any).promo_list?.user_id;
+ if (listOwner !== auth.userId) return { ok: false, error: "Not authorized." };
+
+ const { error } = await svc.from("promo_link").delete().eq("id", linkId);
+ if (error) return { ok: false, error: error.message };
+
+ revalidatePath(`/promote/${link.list_id}`);
+ return { ok: true };
+}
+
+// -------- Toggle link enabled --------
+export async function toggleLink(linkId: string, enabled: boolean): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+
+ const svc = serviceClient();
+ const { data: link } = await svc
+ .from("promo_link")
+ .select("id, list_id, promo_list!inner(user_id)")
+ .eq("id", linkId)
+ .maybeSingle();
+ if (!link) return { ok: false, error: "Link not found." };
+ const listOwner = (link as any).promo_list?.user_id;
+ if (listOwner !== auth.userId) return { ok: false, error: "Not authorized." };
+
+ const { error } = await svc
+ .from("promo_link")
+ .update({ enabled })
+ .eq("id", linkId);
+ if (error) return { ok: false, error: error.message };
+
+ revalidatePath(`/promote/${link.list_id}`);
+ return { ok: true };
+}
+
+// -------- Pause / Resume / Delete list --------
+export async function pausePromoteList(listId: string): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+ const svc = serviceClient();
+ const { error } = await svc
+ .from("promo_list")
+ .update({ status: "paused", pause_reason: "manual" })
+ .eq("id", listId)
+ .eq("user_id", auth.userId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath("/promote");
+ return { ok: true };
+}
+
+export async function resumePromoteList(listId: string): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+ const svc = serviceClient();
+ const { error } = await svc
+ .from("promo_list")
+ .update({
+ status: "running",
+ pause_reason: null,
+ next_run_at: new Date().toISOString(),
+ })
+ .eq("id", listId)
+ .eq("user_id", auth.userId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath("/promote");
+ return { ok: true };
+}
+
+export async function deletePromoteList(listId: string): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+ const svc = serviceClient();
+ const { error } = await svc
+ .from("promo_list")
+ .delete()
+ .eq("id", listId)
+ .eq("user_id", auth.userId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath("/promote");
+ return { ok: true };
+}
+
+// -------- Post now (fire one tick immediately) --------
+export async function postNow(listId: string): Promise {
+ const auth = await requireUser();
+ if (!auth.ok) return auth;
+ const svc = serviceClient();
+ // Set next_run_at to now so the sweep picks it up immediately.
+ const { error } = await svc
+ .from("promo_list")
+ .update({
+ next_run_at: new Date().toISOString(),
+ status: "running",
+ pause_reason: null,
+ })
+ .eq("id", listId)
+ .eq("user_id", auth.userId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath("/promote");
+ revalidatePath(`/promote/${listId}`);
+ return { ok: true };
+}
diff --git a/components/promote/add-links-form.tsx b/components/promote/add-links-form.tsx
new file mode 100644
index 00000000..824ab2a5
--- /dev/null
+++ b/components/promote/add-links-form.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { addLinksToList } from "@/app/actions/promote";
+
+export function AddLinksForm({ listId }: { listId: string }) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [links, setLinks] = useState("");
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setNotice("");
+ start(async () => {
+ const result = await addLinksToList({ listId, links });
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ setLinks("");
+ setNotice(`Added ${result.added} link${result.added !== 1 ? "s" : ""}.`);
+ router.refresh();
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/components/promote/link-list.tsx b/components/promote/link-list.tsx
new file mode 100644
index 00000000..5c9bd88b
--- /dev/null
+++ b/components/promote/link-list.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { removeLink, toggleLink } from "@/app/actions/promote";
+
+type LinkRow = {
+ id: string;
+ url: string;
+ title: string | null;
+ angle: string | null;
+ enabled: boolean;
+ times_promoted: number;
+ last_promoted_at: string | null;
+ created_at: string;
+};
+
+export function LinkList({ links }: { links: LinkRow[] }) {
+ if (links.length === 0) {
+ return No links yet.
;
+ }
+
+ return (
+
+ {links.map((link) => (
+
+ ))}
+
+ );
+}
+
+function LinkItem({ link }: { link: LinkRow }) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+
+ const handleToggle = () =>
+ start(async () => {
+ await toggleLink(link.id, !link.enabled);
+ router.refresh();
+ });
+
+ const handleRemove = () => {
+ if (!confirm("Remove this link?")) return;
+ start(async () => {
+ await removeLink(link.id);
+ router.refresh();
+ });
+ };
+
+ return (
+
+
+
+ {link.url}
+
+ {link.title && (
+
{link.title}
+ )}
+ {link.angle && (
+
+ Angle: {link.angle}
+
+ )}
+
+
+
+ {link.times_promoted}x
+
+
+
+
+
+ );
+}
diff --git a/components/promote/list-actions.tsx b/components/promote/list-actions.tsx
new file mode 100644
index 00000000..90d3107d
--- /dev/null
+++ b/components/promote/list-actions.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import {
+ pausePromoteList,
+ resumePromoteList,
+ deletePromoteList,
+ postNow,
+} from "@/app/actions/promote";
+
+export function PromoteListActions({
+ id,
+ status,
+ showEdit = true,
+}: {
+ id: string;
+ status: string;
+ showEdit?: boolean;
+}) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+
+ const handlePause = () =>
+ start(async () => {
+ await pausePromoteList(id);
+ router.refresh();
+ });
+
+ const handleResume = () =>
+ start(async () => {
+ await resumePromoteList(id);
+ router.refresh();
+ });
+
+ const handleDelete = () => {
+ if (!confirm("Delete this promote list and all its posts?")) return;
+ start(async () => {
+ await deletePromoteList(id);
+ router.refresh();
+ });
+ };
+
+ const handlePostNow = () =>
+ start(async () => {
+ await postNow(id);
+ router.refresh();
+ });
+
+ return (
+
+ {status === "running" ? (
+
+ ) : status === "paused" ? (
+
+ ) : null}
+
+ {showEdit && (
+
+ Edit
+
+ )}
+
+
+ );
+}
diff --git a/components/promote/promote-edit-form.tsx b/components/promote/promote-edit-form.tsx
new file mode 100644
index 00000000..1300d2e0
--- /dev/null
+++ b/components/promote/promote-edit-form.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { updatePromoteList } from "@/app/actions/promote";
+import { CADENCE_PRESETS } from "@/lib/promote/generatePitch";
+
+type Account = { id: string; platform: string; handle: string };
+
+type ListData = {
+ id: string;
+ name: string;
+ cadence_seconds: number;
+ post_mode: string;
+ brand_voice: string;
+ target_account_ids: string[] | null;
+};
+
+export function PromoteEditForm({
+ list,
+ accounts,
+}: {
+ list: ListData;
+ accounts: Account[];
+}) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ const [name, setName] = useState(list.name);
+ const [cadence, setCadence] = useState(list.cadence_seconds);
+ const [postMode, setPostMode] = useState<"trickle" | "burst">(
+ (list.post_mode as "trickle" | "burst") ?? "trickle",
+ );
+ const [brandVoice, setBrandVoice] = useState(list.brand_voice ?? "");
+ const [useAllAccounts, setUseAllAccounts] = useState(!list.target_account_ids);
+ const [selectedAccounts, setSelectedAccounts] = useState(
+ list.target_account_ids ?? [],
+ );
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess(false);
+ start(async () => {
+ const result = await updatePromoteList({
+ listId: list.id,
+ name,
+ cadenceSeconds: cadence,
+ postMode,
+ brandVoice,
+ targetAccountIds: useAllAccounts ? null : selectedAccounts,
+ });
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ setSuccess(true);
+ router.refresh();
+ });
+ };
+
+ const toggleAccount = (id: string) => {
+ setSelectedAccounts((prev) =>
+ prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id],
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/components/promote/promote-form.tsx b/components/promote/promote-form.tsx
new file mode 100644
index 00000000..b11d7d91
--- /dev/null
+++ b/components/promote/promote-form.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { createPromoteList } from "@/app/actions/promote";
+import { CADENCE_PRESETS } from "@/lib/promote/generatePitch";
+
+type Account = { id: string; platform: string; handle: string };
+
+export function PromoteForm({ accounts }: { accounts: Account[] }) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [error, setError] = useState("");
+
+ const [name, setName] = useState("");
+ const [links, setLinks] = useState("");
+ const [cadence, setCadence] = useState(1800);
+ const [postMode, setPostMode] = useState<"trickle" | "burst">("trickle");
+ const [brandVoice, setBrandVoice] = useState("");
+ const [useAllAccounts, setUseAllAccounts] = useState(true);
+ const [selectedAccounts, setSelectedAccounts] = useState([]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ start(async () => {
+ const result = await createPromoteList({
+ name,
+ links,
+ cadenceSeconds: cadence,
+ postMode,
+ brandVoice,
+ targetAccountIds: useAllAccounts ? null : selectedAccounts,
+ });
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ router.push(`/promote/${result.listId}`);
+ });
+ };
+
+ const toggleAccount = (id: string) => {
+ setSelectedAccounts((prev) =>
+ prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id],
+ );
+ };
+
+ // Estimate daily burn
+ const linksCount = links
+ .split(/[\n,\s]+/)
+ .filter((s) => /^https?:\/\/.+/i.test(s.trim())).length;
+ const postsPerDay = postMode === "trickle"
+ ? Math.floor(86400 / cadence)
+ : Math.floor(86400 / cadence) * (useAllAccounts ? accounts.length : selectedAccounts.length || accounts.length);
+
+ return (
+
+ );
+}
diff --git a/lib/promote/generatePitch.ts b/lib/promote/generatePitch.ts
new file mode 100644
index 00000000..146ee3c3
--- /dev/null
+++ b/lib/promote/generatePitch.ts
@@ -0,0 +1,266 @@
+// Dual-provider (Anthropic + OpenAI) copywriter for the Promote feature.
+// Every call produces a fresh, unique marketing pitch for a given link × platform
+// combination. Reuses the structured-output helper from lib/lx/backendAi.ts.
+
+import Anthropic from "@anthropic-ai/sdk";
+import OpenAI from "openai";
+import { z } from "zod/v4";
+import { generateStructuredOutput } from "@/lib/lx/backendAi";
+import { env } from "@/lib/env";
+
+const RENDER_MODEL = "claude-haiku-4-5-20251001";
+
+const PitchSchema = z.object({
+ text: z
+ .string()
+ .describe(
+ "The post body — a marketing pitch for the link. Do NOT include the URL itself; the caller appends it.",
+ ),
+ title: z
+ .string()
+ .describe("Reddit post title; empty string for every other platform."),
+ hashtags: z
+ .array(z.string())
+ .describe(
+ "Platform-appropriate hashtags (no leading #). Empty if the platform doesn't use them.",
+ ),
+});
+
+type PlatformProfile = {
+ maxChars: number;
+ usesHashtags: "many" | "few" | "rare" | "none";
+ voice: string;
+};
+
+const PLATFORM_PROFILES: Record = {
+ bluesky: {
+ maxChars: 280,
+ usesHashtags: "few",
+ voice:
+ "Tight, punchy hook. One sentence or two at most. Conversational, no LinkedIn-speak.",
+ },
+ x: {
+ maxChars: 260,
+ usesHashtags: "few",
+ voice:
+ "Sharp, scroll-stopping. Front-load the value. Hashtags only if they add reach.",
+ },
+ threads: {
+ maxChars: 480,
+ usesHashtags: "few",
+ voice: "Conversational. One short hook, then one supporting line if needed.",
+ },
+ mastodon: {
+ maxChars: 480,
+ usesHashtags: "few",
+ voice:
+ "Slightly longer than Bluesky. Plain, informative, no marketing fluff. 2-3 hashtags are idiomatic.",
+ },
+ linkedin: {
+ maxChars: 1500,
+ usesHashtags: "many",
+ voice:
+ "1-3 short paragraphs. Lead with the insight, not the brag. End with 3-5 relevant hashtags.",
+ },
+ facebook: {
+ maxChars: 600,
+ usesHashtags: "rare",
+ voice: "Friendly, accessible. Plain language. Hashtags rarely.",
+ },
+ facebook_page: {
+ maxChars: 600,
+ usesHashtags: "rare",
+ voice: "Friendly, accessible. Plain language. Hashtags rarely.",
+ },
+ discord: {
+ maxChars: 1800,
+ usesHashtags: "none",
+ voice:
+ "Casual, peer-to-peer. Like sharing a link in a team chat. No hashtags.",
+ },
+ telegram: {
+ maxChars: 800,
+ usesHashtags: "none",
+ voice: "Brief, headline-style. No hashtags.",
+ },
+ reddit: {
+ maxChars: 800,
+ usesHashtags: "none",
+ voice:
+ "Title is a question or claim that earns the click without clickbait. Body is plain prose, no hashtags, no emojis.",
+ },
+ instagram: {
+ maxChars: 1800,
+ usesHashtags: "many",
+ voice:
+ "Visual-first caption. Lead with one punchy hook sentence, add 2-3 lines of context, close with 5-10 relevant hashtags.",
+ },
+};
+
+const HASHTAG_GUIDANCE: Record = {
+ many: "Include 3-5 hashtags.",
+ few: "Include 1-3 hashtags if they actually add discovery; otherwise none.",
+ rare: "Hashtags only if extremely relevant (0-1).",
+ none: "Do not include hashtags.",
+};
+
+const SYSTEM_PROMPT = `You write marketing pitches for social media. You write the way a sharp growth marketer writes — never the way an LLM writes. Every pitch must be unique and fresh. Avoid: "In today's digital landscape", "unlock", "leverage", "synergy", "game-changer", "delve", "navigate", em-dashes used as breath marks, every sentence starting with the same structure. Use: real specifics from the page, concrete benefits, a clear hook. If you don't have a real reason to add a sentence, don't add it. The pitch should make someone want to click the link.`;
+
+export type GeneratePitchArgs = {
+ url: string;
+ title: string | null;
+ angle: string | null;
+ platform: string;
+ brandVoice: string | null;
+ recentBodies: string[];
+ anthropic: Anthropic | null;
+ openai: OpenAI | null;
+};
+
+export type PitchResult = {
+ body: string;
+ title?: string;
+ hashtags: string[];
+ provider: string;
+ model: string;
+};
+
+export async function generatePitch(
+ args: GeneratePitchArgs,
+): Promise {
+ const {
+ url,
+ title,
+ angle,
+ platform,
+ brandVoice,
+ recentBodies,
+ anthropic,
+ openai,
+ } = args;
+
+ if (!anthropic && !openai) {
+ throw new Error(
+ "No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).",
+ );
+ }
+
+ const profile = PLATFORM_PROFILES[platform] ?? PLATFORM_PROFILES["threads"];
+
+ const avoidSection =
+ recentBodies.length > 0
+ ? `\n\nAvoid repeating these recent pitches for this link on this platform (write something with a DIFFERENT angle, hook, and structure):\n${recentBodies
+ .slice(0, 5)
+ .map((b, i) => `--- Previous pitch ${i + 1} ---\n${b}`)
+ .join("\n\n")}`
+ : "";
+
+ const userPrompt = [
+ `Platform: ${platform}.`,
+ `Soft char limit: ${profile.maxChars}.`,
+ `Platform voice: ${profile.voice}`,
+ `${HASHTAG_GUIDANCE[profile.usesHashtags]}`,
+ brandVoice ? `Brand voice / instructions: ${brandVoice}` : "",
+ angle ? `Marketing angle to emphasize: ${angle}` : "",
+ `Page title: ${title ?? "(no title — derive from URL)"}`,
+ `URL to promote (do NOT include in the text field; the renderer appends it): ${url}`,
+ avoidSection,
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+
+ const { output, provider: usedProvider } = await generateStructuredOutput({
+ name: "promote_pitch",
+ schema: PitchSchema,
+ system: SYSTEM_PROMPT,
+ user: userPrompt,
+ anthropic,
+ openai,
+ preference: env.backendAiProvider,
+ anthropicModel: RENDER_MODEL,
+ openaiModel: env.backendAiOpenaiModel,
+ maxTokens: 800,
+ anthropicEffort: false,
+ });
+
+ const text = (output.text ?? "").trim();
+ if (!text) {
+ throw new Error("Pitch generation produced empty text.");
+ }
+
+ const postTitle =
+ platform === "reddit" && output.title?.trim()
+ ? output.title.trim()
+ : undefined;
+
+ const hashtags = (output.hashtags ?? [])
+ .filter((h): h is string => typeof h === "string")
+ .map((h) => (h.startsWith("#") ? h : `#${h}`))
+ .slice(0, 8);
+
+ // Assemble final body: text + hashtags + url
+ const tagsBlock = hashtags.length > 0 ? hashtags.join(" ") : "";
+ const parts = [text, url, tagsBlock].filter(Boolean);
+ const joiner = platform === "linkedin" || platform === "discord" || platform === "telegram" ? "\n\n" : "\n";
+ const body = parts.join(joiner);
+
+ return {
+ body,
+ title: postTitle,
+ hashtags,
+ provider: usedProvider,
+ model: usedProvider === "anthropic" ? RENDER_MODEL : (env.backendAiOpenaiModel ?? "gpt-4o-mini"),
+ };
+}
+
+// ---------- Link helpers ----------
+
+/** Parse a raw textarea value into deduplicated, validated URLs. */
+export function parseLinks(raw: string): string[] {
+ const urls = raw
+ .split(/[\n,\s]+/)
+ .map((s) => s.trim())
+ .filter((s) => /^https?:\/\/.+/i.test(s));
+ return [...new Set(urls)];
+}
+
+/** Best-effort fetch of / og:title for a URL. */
+export async function fetchLinkTitle(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: { "User-Agent": "CrawlProofPromote/1.0" },
+ signal: AbortSignal.timeout(8_000),
+ redirect: "follow",
+ });
+ if (!res.ok) return null;
+ const html = await res.text();
+ // Try og:title first
+ const ogMatch = html.match(/
+ const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
+ if (titleMatch?.[1]) return titleMatch[1].trim().slice(0, 200);
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+// ---------- Cadence presets ----------
+
+export const CADENCE_PRESETS = [
+ { label: "Every 15 min", seconds: 900 },
+ { label: "Every 30 min", seconds: 1800 },
+ { label: "Every hour", seconds: 3600 },
+ { label: "Every 3 hours", seconds: 10800 },
+ { label: "Every 6 hours", seconds: 21600 },
+ { label: "Daily", seconds: 86400 },
+] as const;
+
+export function cadenceLabel(seconds: number): string {
+ const preset = CADENCE_PRESETS.find((p) => p.seconds === seconds);
+ if (preset) return preset.label;
+ if (seconds < 3600) return `Every ${Math.round(seconds / 60)} min`;
+ if (seconds < 86400) return `Every ${Math.round(seconds / 3600)} hours`;
+ return `Every ${Math.round(seconds / 86400)} days`;
+}
diff --git a/lib/promote/sweep.ts b/lib/promote/sweep.ts
new file mode 100644
index 00000000..68fa1e99
--- /dev/null
+++ b/lib/promote/sweep.ts
@@ -0,0 +1,331 @@
+// Promote sweep: processes due promo_lists, picks the next link,
+// generates a fresh pitch via LLM, publishes via the sp platform layer,
+// debits 1 credit per post, and advances the scheduler.
+
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type Anthropic from "@anthropic-ai/sdk";
+import type OpenAI from "openai";
+import { generatePitch } from "@/lib/promote/generatePitch";
+import { postViaAccount, type PostResult } from "@/lib/sp/post";
+
+export type PromoteSweepClients = {
+ anthropic: Anthropic | null;
+ openai: OpenAI | null;
+};
+
+export type PromoteSweepResult = {
+ listsProcessed: number;
+ postsAttempted: number;
+ postsSucceeded: number;
+ postsFailed: number;
+ listsPaused: number;
+};
+
+type PromoList = {
+ id: string;
+ user_id: string;
+ name: string;
+ cadence_seconds: number;
+ post_mode: "trickle" | "burst";
+ target_account_ids: string[] | null;
+ brand_voice: string | null;
+ quiet_start: number | null;
+ quiet_end: number | null;
+ timezone: string | null;
+};
+
+type PromoLink = {
+ id: string;
+ url: string;
+ title: string | null;
+ angle: string | null;
+};
+
+type SpAccount = {
+ id: string;
+ platform: string;
+ status: string;
+ handle: string;
+};
+
+/**
+ * Main sweep entry point. Called by the worker on a 60s interval.
+ * Selects all promo_lists where status='running' and next_run_at <= now(),
+ * then processes each.
+ */
+export async function processDuePromoteLists(
+ supabase: SupabaseClient,
+ clients: PromoteSweepClients,
+ limit = 10,
+): Promise {
+ const result: PromoteSweepResult = {
+ listsProcessed: 0,
+ postsAttempted: 0,
+ postsSucceeded: 0,
+ postsFailed: 0,
+ listsPaused: 0,
+ };
+
+ const { data: lists, error } = await supabase
+ .from("promo_list")
+ .select(
+ "id, user_id, name, cadence_seconds, post_mode, target_account_ids, brand_voice, quiet_start, quiet_end, timezone",
+ )
+ .eq("status", "running")
+ .lte("next_run_at", new Date().toISOString())
+ .order("next_run_at", { ascending: true })
+ .limit(limit);
+
+ if (error || !lists || lists.length === 0) return result;
+
+ for (const list of lists as PromoList[]) {
+ try {
+ const r = await processOneList(supabase, list, clients);
+ result.listsProcessed++;
+ result.postsAttempted += r.attempted;
+ result.postsSucceeded += r.succeeded;
+ result.postsFailed += r.failed;
+ if (r.paused) result.listsPaused++;
+ } catch (err) {
+ console.error(
+ `[promote] unhandled error processing list ${list.id}:`,
+ err,
+ );
+ // Bump next_run_at to avoid tight-loop on broken lists
+ await supabase
+ .from("promo_list")
+ .update({
+ next_run_at: new Date(
+ Date.now() + list.cadence_seconds * 1000,
+ ).toISOString(),
+ })
+ .eq("id", list.id);
+ }
+ }
+
+ return result;
+}
+
+async function processOneList(
+ supabase: SupabaseClient,
+ list: PromoList,
+ clients: PromoteSweepClients,
+): Promise<{ attempted: number; succeeded: number; failed: number; paused: boolean }> {
+ const out = { attempted: 0, succeeded: 0, failed: 0, paused: false };
+
+ // Resolve accounts
+ const accounts = await resolveAccounts(supabase, list);
+ if (accounts.length === 0) {
+ // No accounts connected — skip and bump
+ await advanceScheduler(supabase, list);
+ return out;
+ }
+
+ // Pick the next link(s) using round-robin (least-recently-promoted first)
+ const { data: links } = await supabase
+ .from("promo_link")
+ .select("id, url, title, angle")
+ .eq("list_id", list.id)
+ .eq("enabled", true)
+ .order("last_promoted_at", { ascending: true, nullsFirst: true })
+ .limit(1);
+
+ if (!links || links.length === 0) {
+ await advanceScheduler(supabase, list);
+ return out;
+ }
+
+ const link = links[0] as PromoLink;
+
+ // Determine which (link, account) pairs to post this tick
+ const postTargets: Array<{ link: PromoLink; account: SpAccount }> = [];
+ if (list.post_mode === "burst") {
+ // Burst: post to every account this tick
+ for (const account of accounts) {
+ postTargets.push({ link, account });
+ }
+ } else {
+ // Trickle: one post per tick, round-robin accounts
+ // Pick the account that was least recently used for this list
+ const { data: recentPosts } = await supabase
+ .from("promo_post")
+ .select("account_id")
+ .eq("list_id", list.id)
+ .eq("status", "posted")
+ .order("posted_at", { ascending: false })
+ .limit(accounts.length);
+
+ const recentAccountIds = new Set(
+ (recentPosts ?? []).map((p: any) => p.account_id as string),
+ );
+ // Pick first account not recently used, or the first one
+ const nextAccount =
+ accounts.find((a) => !recentAccountIds.has(a.id)) ?? accounts[0];
+ postTargets.push({ link, account: nextAccount });
+ }
+
+ for (const { link: targetLink, account } of postTargets) {
+ // Check credits before each post
+ const { data: hasCredit } = await supabase.rpc("consume_credit", {
+ p_owner: list.user_id,
+ p_count: 1,
+ });
+
+ if (!hasCredit) {
+ // Insufficient credits — auto-pause
+ await supabase
+ .from("promo_list")
+ .update({
+ status: "paused",
+ pause_reason: "insufficient_credits",
+ })
+ .eq("id", list.id);
+ out.paused = true;
+ break;
+ }
+
+ out.attempted++;
+
+ try {
+ // Fetch recent bodies for anti-repeat
+ const { data: recentPitches } = await supabase
+ .from("promo_post")
+ .select("body")
+ .eq("link_id", targetLink.id)
+ .eq("platform", account.platform)
+ .eq("status", "posted")
+ .order("created_at", { ascending: false })
+ .limit(5);
+
+ const recentBodies = (recentPitches ?? []).map(
+ (p: any) => p.body as string,
+ );
+
+ // Generate a fresh pitch
+ const pitch = await generatePitch({
+ url: targetLink.url,
+ title: targetLink.title,
+ angle: targetLink.angle,
+ platform: account.platform,
+ brandVoice: list.brand_voice,
+ recentBodies,
+ anthropic: clients.anthropic,
+ openai: clients.openai,
+ });
+
+ // Publish via the existing sp platform layer
+ const postResult: PostResult = await postViaAccount({
+ supabase,
+ userId: list.user_id,
+ input: {
+ accountId: account.id,
+ text: pitch.body,
+ title: pitch.title,
+ },
+ source: "manual", // reuse existing source type
+ });
+
+ // Record the promo_post
+ await supabase.from("promo_post").insert({
+ list_id: list.id,
+ link_id: targetLink.id,
+ account_id: account.id,
+ platform: account.platform,
+ body: pitch.body,
+ provider: pitch.provider,
+ model: pitch.model,
+ status: postResult.ok ? "posted" : "failed",
+ external_post_id: postResult.ok ? postResult.platformPostId : null,
+ error: postResult.ok ? null : postResult.error,
+ credits_spent: 1,
+ posted_at: postResult.ok ? new Date().toISOString() : null,
+ });
+
+ if (postResult.ok) {
+ out.succeeded++;
+ } else {
+ out.failed++;
+ // Refund credit on post failure
+ await supabase.rpc("consume_credit", {
+ p_owner: list.user_id,
+ p_count: -1,
+ });
+ }
+ } catch (err) {
+ out.failed++;
+ const message = err instanceof Error ? err.message : "Unknown error";
+
+ // Record the failed post
+ await supabase.from("promo_post").insert({
+ list_id: list.id,
+ link_id: targetLink.id,
+ account_id: account.id,
+ platform: account.platform,
+ body: `[generation failed: ${message}]`,
+ status: "failed",
+ error: message,
+ credits_spent: 0,
+ });
+
+ // Refund credit
+ await supabase.rpc("consume_credit", {
+ p_owner: list.user_id,
+ p_count: -1,
+ });
+ }
+ }
+
+ // Update the link's round-robin cursor
+ await supabase
+ .from("promo_link")
+ .update({
+ last_promoted_at: new Date().toISOString(),
+ times_promoted: (link as any).times_promoted
+ ? (link as any).times_promoted + 1
+ : 1,
+ })
+ .eq("id", link.id);
+
+ // Advance the scheduler
+ await advanceScheduler(supabase, list);
+
+ return out;
+}
+
+async function resolveAccounts(
+ supabase: SupabaseClient,
+ list: PromoList,
+): Promise {
+ if (list.target_account_ids && list.target_account_ids.length > 0) {
+ // Pinned accounts
+ const { data } = await supabase
+ .from("sp_account")
+ .select("id, platform, status, handle")
+ .in("id", list.target_account_ids)
+ .eq("status", "active");
+ return (data ?? []) as SpAccount[];
+ }
+
+ // All active accounts for this user
+ const { data } = await supabase
+ .from("sp_account")
+ .select("id, platform, status, handle")
+ .eq("user_id", list.user_id)
+ .eq("status", "active");
+ return (data ?? []) as SpAccount[];
+}
+
+async function advanceScheduler(
+ supabase: SupabaseClient,
+ list: PromoList,
+) {
+ await supabase
+ .from("promo_list")
+ .update({
+ last_run_at: new Date().toISOString(),
+ next_run_at: new Date(
+ Date.now() + list.cadence_seconds * 1000,
+ ).toISOString(),
+ })
+ .eq("id", list.id);
+}
diff --git a/supabase/migrations/20260713000000_promote.sql b/supabase/migrations/20260713000000_promote.sql
new file mode 100644
index 00000000..e6c825b0
--- /dev/null
+++ b/supabase/migrations/20260713000000_promote.sql
@@ -0,0 +1,152 @@
+-- Promote: drip-publish AI-written marketing pitches across all connected
+-- social accounts on a recurring cadence. Global (account-level), not
+-- per-project. Reuses sp_account for connections and consume_credit for billing.
+
+-- ---------- promo_list ----------
+create table if not exists public.promo_list (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.profiles(id) on delete cascade,
+
+ name text not null default 'Promote list',
+ status text not null default 'running'
+ check (status in ('running','paused','archived')),
+
+ -- Drip cadence in seconds. Default 1800 (30 min).
+ cadence_seconds int not null default 1800
+ check (cadence_seconds between 300 and 604800),
+
+ -- 'trickle' = one post per tick, round-robin (default, safest).
+ -- 'burst' = one post to EVERY targeted account each tick.
+ post_mode text not null default 'trickle'
+ check (post_mode in ('trickle','burst')),
+
+ -- NULL target_account_ids => "all active connected accounts" (dynamic).
+ -- Non-null => an explicit pinned array of sp_account ids.
+ target_account_ids uuid[],
+
+ -- Freeform brand voice / global generation instructions.
+ brand_voice text,
+
+ -- Optional local quiet-hours window (Phase 2).
+ quiet_start smallint, -- hour 0-23, null = disabled
+ quiet_end smallint,
+ timezone text,
+
+ -- Scheduler bookkeeping.
+ next_run_at timestamptz not null default now(),
+ last_run_at timestamptz,
+
+ -- Auto-pause reason (e.g. 'insufficient_credits').
+ pause_reason text,
+
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index on public.promo_list (status, next_run_at);
+create index on public.promo_list (user_id);
+
+-- ---------- promo_link ----------
+create table if not exists public.promo_link (
+ id uuid primary key default gen_random_uuid(),
+ list_id uuid not null references public.promo_list(id) on delete cascade,
+
+ url text not null,
+ title text, -- best-effort fetched /og:title
+ angle text, -- per-link marketing hint, optional
+
+ -- Fairness: least-recently-promoted link is picked first.
+ last_promoted_at timestamptz,
+ times_promoted int not null default 0,
+
+ enabled boolean not null default true,
+ created_at timestamptz not null default now(),
+ unique (list_id, url)
+);
+
+create index on public.promo_link (list_id, enabled, last_promoted_at);
+
+-- ---------- promo_post ----------
+create table if not exists public.promo_post (
+ id uuid primary key default gen_random_uuid(),
+ list_id uuid not null references public.promo_list(id) on delete cascade,
+ link_id uuid not null references public.promo_link(id) on delete cascade,
+ account_id uuid not null references public.sp_account(id) on delete cascade,
+
+ platform text not null, -- denormalized from sp_account for querying
+ body text not null, -- the LLM-generated pitch actually posted
+ provider text, -- 'anthropic' | 'openai'
+ model text,
+
+ status text not null default 'pending'
+ check (status in ('pending','posted','failed','skipped')),
+ external_post_id text, -- platform post id / permalink
+ error text,
+ credits_spent int not null default 0,
+
+ created_at timestamptz not null default now(),
+ posted_at timestamptz
+);
+
+create index on public.promo_post (list_id, created_at desc);
+create index on public.promo_post (link_id, platform);
+
+-- ---------- RLS ----------
+alter table public.promo_list enable row level security;
+create policy "promo_list owner all"
+ on public.promo_list for all
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);
+
+alter table public.promo_link enable row level security;
+create policy "promo_link via owned list"
+ on public.promo_link for all
+ using (
+ exists (
+ select 1 from public.promo_list l
+ where l.id = list_id and l.user_id = auth.uid()
+ )
+ )
+ with check (
+ exists (
+ select 1 from public.promo_list l
+ where l.id = list_id and l.user_id = auth.uid()
+ )
+ );
+
+alter table public.promo_link enable row level security;
+
+alter table public.promo_post enable row level security;
+create policy "promo_post via owned list"
+ on public.promo_post for select
+ using (
+ exists (
+ select 1 from public.promo_list l
+ where l.id = list_id and l.user_id = auth.uid()
+ )
+ );
+
+-- ---------- updated_at trigger ----------
+-- Reuse the generic set_updated_at trigger function if it exists, otherwise create one.
+create or replace function public.promo_set_updated_at()
+returns trigger
+language plpgsql as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+create trigger promo_list_updated_at
+ before update on public.promo_list
+ for each row execute function public.promo_set_updated_at();
+
+-- ---------- Service-role grants ----------
+-- The worker uses the service-role key which bypasses RLS, but these grants
+-- ensure the tables are accessible to authenticated users via the anon key.
+grant select, insert, update, delete on public.promo_list to authenticated;
+grant select, insert, update, delete on public.promo_link to authenticated;
+grant select on public.promo_post to authenticated;
+grant all on public.promo_list to service_role;
+grant all on public.promo_link to service_role;
+grant all on public.promo_post to service_role;
diff --git a/tests/promote/generate-pitch.test.ts b/tests/promote/generate-pitch.test.ts
new file mode 100644
index 00000000..c8d4158e
--- /dev/null
+++ b/tests/promote/generate-pitch.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from "vitest";
+import { parseLinks, cadenceLabel, CADENCE_PRESETS } from "@/lib/promote/generatePitch";
+
+describe("parseLinks", () => {
+ it("extracts valid URLs from newline-separated input", () => {
+ const input = `https://example.com/page-1
+https://example.com/page-2
+https://example.com/page-3`;
+ expect(parseLinks(input)).toEqual([
+ "https://example.com/page-1",
+ "https://example.com/page-2",
+ "https://example.com/page-3",
+ ]);
+ });
+
+ it("handles comma-separated input", () => {
+ const input = "https://a.com, https://b.com, https://c.com";
+ expect(parseLinks(input)).toEqual([
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ ]);
+ });
+
+ it("handles space-separated input", () => {
+ const input = "https://a.com https://b.com";
+ expect(parseLinks(input)).toEqual(["https://a.com", "https://b.com"]);
+ });
+
+ it("deduplicates URLs", () => {
+ const input = `https://a.com
+https://a.com
+https://b.com
+https://a.com`;
+ expect(parseLinks(input)).toEqual(["https://a.com", "https://b.com"]);
+ });
+
+ it("filters out invalid URLs", () => {
+ const input = `https://good.com
+not-a-url
+ftp://not-http.com
+https://also-good.com
+just text`;
+ expect(parseLinks(input)).toEqual([
+ "https://good.com",
+ "https://also-good.com",
+ ]);
+ });
+
+ it("handles empty input", () => {
+ expect(parseLinks("")).toEqual([]);
+ expect(parseLinks(" \n\n ")).toEqual([]);
+ });
+
+ it("handles http:// URLs", () => {
+ const input = "http://example.com";
+ expect(parseLinks(input)).toEqual(["http://example.com"]);
+ });
+
+ it("handles mixed separators", () => {
+ const input = "https://a.com,https://b.com\nhttps://c.com https://d.com";
+ const result = parseLinks(input);
+ expect(result).toContain("https://a.com");
+ expect(result).toContain("https://b.com");
+ expect(result).toContain("https://c.com");
+ expect(result).toContain("https://d.com");
+ expect(result.length).toBe(4);
+ });
+});
+
+describe("cadenceLabel", () => {
+ it("returns preset labels for known cadence values", () => {
+ expect(cadenceLabel(900)).toBe("Every 15 min");
+ expect(cadenceLabel(1800)).toBe("Every 30 min");
+ expect(cadenceLabel(3600)).toBe("Every hour");
+ expect(cadenceLabel(10800)).toBe("Every 3 hours");
+ expect(cadenceLabel(21600)).toBe("Every 6 hours");
+ expect(cadenceLabel(86400)).toBe("Daily");
+ });
+
+ it("generates labels for custom minute-based cadences", () => {
+ expect(cadenceLabel(600)).toBe("Every 10 min");
+ expect(cadenceLabel(2700)).toBe("Every 45 min");
+ });
+
+ it("generates labels for custom hour-based cadences", () => {
+ expect(cadenceLabel(7200)).toBe("Every 2 hours");
+ expect(cadenceLabel(43200)).toBe("Every 12 hours");
+ });
+
+ it("generates labels for custom day-based cadences", () => {
+ expect(cadenceLabel(172800)).toBe("Every 2 days");
+ });
+});
+
+describe("CADENCE_PRESETS", () => {
+ it("has 6 presets with valid seconds values", () => {
+ expect(CADENCE_PRESETS.length).toBe(6);
+ for (const preset of CADENCE_PRESETS) {
+ expect(preset.seconds).toBeGreaterThanOrEqual(300);
+ expect(preset.seconds).toBeLessThanOrEqual(604800);
+ expect(preset.label).toBeTruthy();
+ }
+ });
+
+ it("is sorted by ascending seconds", () => {
+ for (let i = 1; i < CADENCE_PRESETS.length; i++) {
+ expect(CADENCE_PRESETS[i].seconds).toBeGreaterThan(
+ CADENCE_PRESETS[i - 1].seconds,
+ );
+ }
+ });
+});
diff --git a/worker/index.ts b/worker/index.ts
index f5af4f04..32134907 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -38,6 +38,7 @@ import { listInstallationRepos } from "../lib/github/app";
import { processUserAlerts } from "../lib/alerts/worker";
import { processDuePortScans } from "../lib/prober-queue";
import { processDueMonitors } from "../lib/uptime";
+import { processDuePromoteLists } from "../lib/promote/sweep";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
@@ -1259,6 +1260,21 @@ setInterval(
() => auditStuckSweep().catch((e) => console.error("[worker] audit stuck sweep", e)),
60_000,
);
+// Promote drip engine: generate fresh AI marketing pitches and publish
+// them across connected social accounts on a recurring cadence.
+async function promoteSweep() {
+ const r = await processDuePromoteLists(supabase, { anthropic, openai });
+ if (r.postsAttempted > 0) {
+ console.log(
+ `[worker] promote sweep lists=${r.listsProcessed} attempted=${r.postsAttempted} ok=${r.postsSucceeded} fail=${r.postsFailed} paused=${r.listsPaused}`,
+ );
+ }
+}
+setInterval(
+ () => promoteSweep().catch((e) => console.error("[worker] promote sweep", e)),
+ 60_000,
+);
+
// Port-drift scans: bridge queued rows to the "prober" BullMQ queue and
// reconcile results (uptime-monitoring-prd.md §12). Short interval so the
// Security tab's SSE stream gets snappy queued→running→done feedback.
@@ -1291,4 +1307,5 @@ server.listen(port, bindHost, () => {
console.log(`[worker] listening on ${bindHost}:${port}`);
sweep().catch(() => {});
socialFeedSweep().catch((e) => console.error("[worker] social feed sweep", e));
+ promoteSweep().catch((e) => console.error("[worker] promote sweep", e));
});