Skip to content

Commit 0a1deb0

Browse files
ralyodioclaude
andcommitted
feat(autoblog): regenerate-queue action
When seeds / niche / target audiences change, the existing queue stays out-of-sync — the cron dedup against all-status keywords means fresh research can never re-insert anything until the old queue is cleared. Add a "Regenerate queue" button on the autoblog dashboard that: - Hard-deletes queued + failed keywords for the site. - Leaves published keywords intact so their articles stay deduped (no duplicate articles on topics already covered). - Enqueues a fresh research run. UI confirms with the exact counts and the ~$0.22 DataForSEO spend so the destructive nature is clear. RLS needed a new DELETE policy on lx_keyword for site owners — the original migration only granted SELECT, so the route's anon-key session can't delete without it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 646927e commit 0a1deb0

4 files changed

Lines changed: 127 additions & 1 deletion

File tree

‎app/(app)/projects/[id]/autoblog/actions.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ async function call(path: string): Promise<{ ok: boolean; error?: string }> {
1717
export function DashboardActions({
1818
paused,
1919
projectId,
20+
queuedCount,
21+
failedCount,
2022
}: {
2123
paused: boolean;
2224
projectId: string;
25+
queuedCount: number;
26+
failedCount: number;
2327
}) {
2428
const router = useRouter();
2529
const [pending, startTransition] = useTransition();
@@ -67,6 +71,30 @@ export function DashboardActions({
6771
router.refresh();
6872
}
6973

74+
async function regenerateQueue() {
75+
const total = queuedCount + failedCount;
76+
const summary =
77+
total > 0
78+
? `Delete ${queuedCount} queued + ${failedCount} failed keyword${total === 1 ? "" : "s"} and spend ~$0.22 on fresh research?`
79+
: "Re-run keyword research with your current niche/audiences?";
80+
if (!window.confirm(`${summary}\n\nPublished keywords stay deduped so we don't regenerate the same articles.`)) {
81+
return;
82+
}
83+
setBusy("regenerate");
84+
setNotice(null);
85+
setError(null);
86+
const r = await call(
87+
`/api/lx/keywords/regenerate?projectId=${encodeURIComponent(projectId)}`,
88+
);
89+
setBusy(null);
90+
if (r.ok) {
91+
setNotice("Queue cleared — fresh research queued.");
92+
router.refresh();
93+
} else {
94+
setError(r.error ?? "Could not regenerate queue.");
95+
}
96+
}
97+
7098
function togglePause() {
7199
setNotice(null);
72100
setError(null);
@@ -104,6 +132,15 @@ export function DashboardActions({
104132
>
105133
{busy === "keywords" ? "Generating…" : "Generate keywords"}
106134
</button>
135+
<button
136+
type="button"
137+
className="btn"
138+
disabled={busy !== null}
139+
onClick={regenerateQueue}
140+
title="Clear queued + failed keywords and re-run research with your current niche/audiences/seeds."
141+
>
142+
{busy === "regenerate" ? "Regenerating…" : "Regenerate queue"}
143+
</button>
107144
<button
108145
type="button"
109146
className="btn"

‎app/(app)/projects/[id]/autoblog/page.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export default async function AutoblogDashboardPage({
8585
const readinessPromise = checkAutoblogReadiness();
8686
const [
8787
{ count: queuedKeywords },
88+
{ count: failedKeywords },
8889
{ count: publishedThisMonth },
8990
{ count: failedArticles },
9091
{ data: upcoming },
@@ -97,6 +98,11 @@ export default async function AutoblogDashboardPage({
9798
.select("id", { count: "exact", head: true })
9899
.eq("site_id", site.id)
99100
.eq("status", "queued"),
101+
supabase
102+
.from("lx_keyword")
103+
.select("id", { count: "exact", head: true })
104+
.eq("site_id", site.id)
105+
.eq("status", "failed"),
100106
supabase
101107
.from("lx_article")
102108
.select("id", { count: "exact", head: true })
@@ -322,7 +328,12 @@ export default async function AutoblogDashboardPage({
322328
</dl>
323329
</section>
324330

325-
<DashboardActions paused={site.status === "paused"} projectId={projectId} />
331+
<DashboardActions
332+
paused={site.status === "paused"}
333+
projectId={projectId}
334+
queuedCount={queuedKeywords ?? 0}
335+
failedCount={failedKeywords ?? 0}
336+
/>
326337

327338
{/* Upcoming */}
328339
<section>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { enqueueKeywordResearch } from "@/lib/lx/workerClient";
4+
import { getProjectById, getCurrentSite } from "@/lib/lx/currentSite";
5+
6+
export const runtime = "nodejs";
7+
8+
// Hard-resets the upcoming queue. Used when the user's seeds, niche,
9+
// modifiers, or long-tail list have changed and the existing queue is
10+
// no longer aligned with what they want to publish.
11+
//
12+
// Deletes queued + failed keywords (failed ones were never produced —
13+
// they're not contractually "history"). Leaves published keywords intact
14+
// so research's dedup still skips topics the site has already covered.
15+
// Then enqueues a fresh research run.
16+
export async function POST(req: NextRequest) {
17+
const supabase = await createClient();
18+
const {
19+
data: { user },
20+
} = await supabase.auth.getUser();
21+
if (!user) {
22+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
23+
}
24+
25+
const projectIdParam = req.nextUrl.searchParams.get("projectId");
26+
let lxSiteId: string | null = null;
27+
if (projectIdParam) {
28+
const project = await getProjectById(projectIdParam, {
29+
siteColumns: "id, niche, target_audiences",
30+
});
31+
const lxSite = project?.lx_site as { id?: string } | null;
32+
lxSiteId = lxSite?.id ?? null;
33+
} else {
34+
const site = (await getCurrentSite("id")) as
35+
| { lx_site_id: string | null }
36+
| null;
37+
lxSiteId = site?.lx_site_id ?? null;
38+
}
39+
if (!lxSiteId) {
40+
return NextResponse.json(
41+
{ ok: false, error: "autoblog not configured for this project" },
42+
{ status: 400 },
43+
);
44+
}
45+
46+
// Delete queued + failed in one call. The RLS policy on lx_keyword is
47+
// SELECT-only for owners; deletes go through the service-role client
48+
// implicit in supabase.from() under the server route + cookie auth
49+
// (the policy denies anon, so we rely on the route's own auth check
50+
// above to gate access).
51+
const { error: delErr, count } = await supabase
52+
.from("lx_keyword")
53+
.delete({ count: "exact" })
54+
.eq("site_id", lxSiteId)
55+
.in("status", ["queued", "failed"]);
56+
if (delErr) {
57+
return NextResponse.json(
58+
{ ok: false, error: `delete failed: ${delErr.message}` },
59+
{ status: 500 },
60+
);
61+
}
62+
63+
await enqueueKeywordResearch(lxSiteId);
64+
65+
return NextResponse.json({ ok: true, deleted: count ?? 0 });
66+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Allow owners to delete their own queued/failed keywords so the
2+
-- "Regenerate queue" action can clear the upcoming queue under RLS
3+
-- (the route uses the user's anon-key session, not service-role).
4+
-- The original migration only granted SELECT on lx_keyword to owners.
5+
create policy "lx_keyword owner delete"
6+
on public.lx_keyword for delete
7+
using (
8+
exists (
9+
select 1 from public.lx_site s
10+
where s.id = site_id and s.user_id = auth.uid()
11+
)
12+
);

0 commit comments

Comments
 (0)