Skip to content

Commit ac86ee1

Browse files
ralyodioclaude
andcommitted
feat(autoblog): guest post generation (Pass 2)
Wires the "Generate this" button on each suggested topic in the opportunity panel through to a full guest-post article generation and webhook delivery to the partner blog. Flow: - Author clicks "Generate this" on (partner, topic) pair. - /api/lx/guest-posts/generate burns an author credit upfront-check, enqueues to the worker. - Worker calls generateGuestPost (new module). It loads both sites, burns the AUTHOR credit (consume_credit RPC against author user), calls Claude with a host-voice system prompt + a mandatory author-backlink slot, validates the backlink is in the body, renders images, persists lx_article with site_id=target, author_site_id=author, target_site_id=target, is_guest_post=true. - Delivery: existing deliverArticle routes to target's webhook (now generalized to read target_site_id first, falling back to site_id, so own-blog posts behave identically). Schema: - New RLS policy "lx_article via owned author" so the author can SELECT guest posts they wrote (target's owner already had access via the existing site_id policy; the author needed parallel access). Shared infrastructure (no duplication): - articleGen.ts now exports ArticleSchema, INLINE_IMAGE_COUNT, buildSystemPrompt, generateImage, generateInlineImage, uploadImage, uniqueSlug, refundCredit so guestPostGen reuses the visual + LLM contract that own-blog posts ship with. The receiver side needs no changes — the existing crawlproof webhook + gatePost flow accepts guest posts identically to own-blog posts under Phase 2's quality gate, which is shipped on threatcrush, coinpayportal, and sh1pt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ebc821 commit ac86ee1

8 files changed

Lines changed: 601 additions & 15 deletions

File tree

‎app/(app)/projects/[id]/autoblog/guest-posts.tsx‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ export function GuestPostOpportunities({ projectId }: { projectId: string }) {
1515
const [busy, setBusy] = useState(false);
1616
const [opps, setOpps] = useState<Opportunity[] | null>(null);
1717
const [error, setError] = useState<string | null>(null);
18+
const [generating, setGenerating] = useState<string | null>(null);
19+
const [notice, setNotice] = useState<string | null>(null);
1820

1921
async function find() {
2022
setBusy(true);
2123
setError(null);
24+
setNotice(null);
2225
try {
2326
const res = await fetch(
2427
`/api/lx/guest-posts/opportunities?projectId=${encodeURIComponent(projectId)}`,
@@ -37,6 +40,35 @@ export function GuestPostOpportunities({ projectId }: { projectId: string }) {
3740
}
3841
}
3942

43+
async function generate(opp: Opportunity, topic: string) {
44+
const key = `${opp.partner_site_id}:${topic}`;
45+
setGenerating(key);
46+
setError(null);
47+
setNotice(null);
48+
try {
49+
const res = await fetch(
50+
`/api/lx/guest-posts/generate?projectId=${encodeURIComponent(projectId)}`,
51+
{
52+
method: "POST",
53+
headers: { "content-type": "application/json" },
54+
body: JSON.stringify({ targetSiteId: opp.partner_site_id, topic }),
55+
},
56+
);
57+
const json = await res.json().catch(() => ({}));
58+
if (!res.ok || json?.ok === false) {
59+
setError(json?.error ?? "Could not generate guest post.");
60+
} else {
61+
setNotice(
62+
`Guest post queued for ${opp.partner_domain} on "${topic}". Generation takes 1–3 minutes; it will land on the partner blog once delivered.`,
63+
);
64+
}
65+
} catch (err) {
66+
setError(err instanceof Error ? err.message : String(err));
67+
} finally {
68+
setGenerating(null);
69+
}
70+
}
71+
4072
return (
4173
<section className="space-y-3">
4274
<div className="flex flex-wrap items-baseline justify-between gap-2">
@@ -57,6 +89,9 @@ export function GuestPostOpportunities({ projectId }: { projectId: string }) {
5789
</button>
5890
</div>
5991

92+
{notice && (
93+
<p className="text-sm text-[var(--color-pass)]">{notice}</p>
94+
)}
6095
{error && (
6196
<p className="text-sm text-[var(--color-fail)]">{error}</p>
6297
)}
@@ -100,10 +135,27 @@ export function GuestPostOpportunities({ projectId }: { projectId: string }) {
100135
<p className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
101136
Suggested topics
102137
</p>
103-
<ul className="ml-4 list-disc space-y-0.5 text-sm">
104-
{o.suggested_topics.map((t) => (
105-
<li key={t}>{t}</li>
106-
))}
138+
<ul className="space-y-1.5 text-sm">
139+
{o.suggested_topics.map((t) => {
140+
const key = `${o.partner_site_id}:${t}`;
141+
const isBusy = generating === key;
142+
return (
143+
<li
144+
key={t}
145+
className="flex items-center justify-between gap-2"
146+
>
147+
<span className="flex-1">{t}</span>
148+
<button
149+
type="button"
150+
className="btn text-xs"
151+
disabled={generating !== null}
152+
onClick={() => generate(o, t)}
153+
>
154+
{isBusy ? "Queuing…" : "Generate this"}
155+
</button>
156+
</li>
157+
);
158+
})}
107159
</ul>
108160
</div>
109161
) : (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { enqueueGuestPostGenerate } from "@/lib/lx/workerClient";
4+
import { getProjectById } from "@/lib/lx/currentSite";
5+
6+
export const runtime = "nodejs";
7+
8+
// Manually trigger a guest-post generation. Body: { targetSiteId, topic }.
9+
// Author site is resolved from the projectId query param. Worker is
10+
// fire-and-forget; the article shows up on the author's dashboard
11+
// (filter is_guest_post=true) when generation lands.
12+
export async function POST(req: NextRequest) {
13+
const supabase = await createClient();
14+
const {
15+
data: { user },
16+
} = await supabase.auth.getUser();
17+
if (!user) {
18+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
19+
}
20+
21+
const projectIdParam = req.nextUrl.searchParams.get("projectId");
22+
if (!projectIdParam) {
23+
return NextResponse.json(
24+
{ ok: false, error: "projectId required" },
25+
{ status: 400 },
26+
);
27+
}
28+
const project = await getProjectById(projectIdParam, { siteColumns: "id, status" });
29+
const authorLx = project?.lx_site as { id?: string; status?: string } | null;
30+
if (!authorLx?.id) {
31+
return NextResponse.json(
32+
{ ok: false, error: "autoblog not configured for this project" },
33+
{ status: 400 },
34+
);
35+
}
36+
if (authorLx.status !== "active") {
37+
return NextResponse.json(
38+
{ ok: false, error: `author site is ${authorLx.status}` },
39+
{ status: 400 },
40+
);
41+
}
42+
43+
let body: { targetSiteId?: string; topic?: string };
44+
try {
45+
body = await req.json();
46+
} catch {
47+
return NextResponse.json({ ok: false, error: "bad json" }, { status: 400 });
48+
}
49+
if (!body.targetSiteId || !body.topic) {
50+
return NextResponse.json(
51+
{ ok: false, error: "targetSiteId and topic are required" },
52+
{ status: 400 },
53+
);
54+
}
55+
if (body.targetSiteId === authorLx.id) {
56+
return NextResponse.json(
57+
{ ok: false, error: "target must differ from author" },
58+
{ status: 400 },
59+
);
60+
}
61+
62+
// Upfront credit check on the author's account — same UX rationale
63+
// as own-blog generation. Worker re-checks atomically via
64+
// consume_credit before doing any LLM work.
65+
const { data: profile } = await supabase
66+
.from("profiles")
67+
.select("credits_balance")
68+
.eq("id", user.id)
69+
.maybeSingle();
70+
const balance = (profile?.credits_balance as number | null | undefined) ?? 0;
71+
if (balance < 1) {
72+
return NextResponse.json(
73+
{
74+
ok: false,
75+
error: "Out of credits. Buy more to generate guest posts.",
76+
credits_balance: balance,
77+
},
78+
{ status: 402 },
79+
);
80+
}
81+
82+
await enqueueGuestPostGenerate(authorLx.id, body.targetSiteId, body.topic);
83+
return NextResponse.json({ ok: true });
84+
}

‎lib/lx/articleGen.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ type LinkCandidate = {
6868
distance: number;
6969
};
7070

71-
const INLINE_IMAGE_COUNT = 3;
71+
export const INLINE_IMAGE_COUNT = 3;
7272
const MAX_PRIOR_ARTICLE_LINKS = 3;
7373

74-
const ArticleSchema = z.object({
74+
export const ArticleSchema = z.object({
7575
title: z.string().min(10).max(180),
7676
slug: z
7777
.string()
@@ -120,7 +120,7 @@ export function slugify(input: string, max = 80): string {
120120
.replace(/-+$/g, "");
121121
}
122122

123-
function buildSystemPrompt(): string {
123+
export function buildSystemPrompt(): string {
124124
return [
125125
"You are writing a long-form SEO blog post in the practical, operator-focused style of ThreatCrush and CoinPayPortal: pragmatic B2B/technical voice, strong opening pain point, the topic reframed as an architecture/workflow problem (not a definition), table of contents, H2/H3 sections, practical rules in blockquotes, bullets/tables, implementation advice, soft product CTA near the end.",
126126
"",
@@ -301,7 +301,7 @@ async function claimKeyword(
301301
return !!data;
302302
}
303303

304-
async function uniqueSlug(
304+
export async function uniqueSlug(
305305
supabase: SupabaseClient<any>,
306306
siteId: string,
307307
base: string,
@@ -397,7 +397,7 @@ const SHARED_ART_DIRECTION = [
397397
"Strictly NO text, NO typography, NO UI mockups, NO logos, NO charts with labels, NO people, NO faces, NO stock-photo office scenes, NO generic abstract gradient blobs.",
398398
];
399399

400-
async function generateInlineImage(
400+
export async function generateInlineImage(
401401
openai: OpenAI,
402402
promptText: string,
403403
nicheHint: string | null,
@@ -423,7 +423,7 @@ async function generateInlineImage(
423423
return Buffer.from(b64, "base64");
424424
}
425425

426-
async function generateImage(
426+
export async function generateImage(
427427
openai: OpenAI,
428428
title: string,
429429
nicheHint: string | null,
@@ -451,7 +451,7 @@ async function generateImage(
451451
return Buffer.from(b64, "base64");
452452
}
453453

454-
async function uploadImage(
454+
export async function uploadImage(
455455
supabase: SupabaseClient<any>,
456456
siteId: string,
457457
slug: string,
@@ -949,7 +949,7 @@ async function failKeyword(
949949
// gated by consume_credit but then failed before producing an article.
950950
// Best-effort: if the balance read or update fails, we log and move on
951951
// rather than retry-loop. The cost is one wasted credit, not corruption.
952-
async function refundCredit(
952+
export async function refundCredit(
953953
supabase: SupabaseClient<any>,
954954
ownerId: string,
955955
): Promise<void> {

0 commit comments

Comments
 (0)