From 92b8dbb778587021df53c5202c9613770370439c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 00:41:35 +0000 Subject: [PATCH] feat(review): Accept all on /admin/submissions, POST /api/review, p0d review accept-all (0.4.0) Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- public/cli/p0d.mjs | 40 +++++++++++++++++++++- src/app/admin/submissions/page.tsx | 2 ++ src/app/api/review/route.ts | 55 +++++++++++++++++++++++++++++- src/app/llms.txt/route.ts | 2 +- src/app/skill.md/route.ts | 2 +- src/components/AcceptAllButton.tsx | 45 ++++++++++++++++++++++++ test/cli.test.mjs | 25 ++++++++++++++ 8 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 src/components/AcceptAllButton.tsx diff --git a/package.json b/package.json index 28c30ee..544113f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p0dcasters", - "version": "0.3.2", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/public/cli/p0d.mjs b/public/cli/p0d.mjs index b659984..a6bcc32 100644 --- a/public/cli/p0d.mjs +++ b/public/cli/p0d.mjs @@ -17,7 +17,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; -export const VERSION = '0.2.0'; +export const VERSION = '0.3.0'; const DEFAULT_API = 'https://p0dcasters.com'; /** @@ -82,6 +82,15 @@ export const COMMANDS = [ options: ['--html', '--json'], examples: ['p0d newsletter', 'p0d newsletter preview', 'p0d newsletter preview --html > issue.html'], }, + { + name: 'review', + usage: 'review [accept-all]', + summary: 'The submission queue, for reviewers; accept all of it', + detail: + 'Lists the submissions waiting for a person. `review accept-all` lists every one of them at once (asks first unless --yes); a feed already listed comes back as already listed. Needs an OpenAccess token with scope submissions:review in OPENACCESS_TOKEN or P0D_TOKEN.', + options: ['--yes', '--json'], + examples: ['p0d review', 'p0d review accept-all --yes'], + }, { name: 'opml', usage: 'opml [category]', @@ -287,6 +296,35 @@ export async function run(argv, io = {}) { o.out(`page ${body.page}`); return 0; } + case 'review': { + const token = o.env.OPENACCESS_TOKEN || o.env.P0D_TOKEN; + if (!token) return fail(o, 'review: set OPENACCESS_TOKEN or P0D_TOKEN (scope submissions:review)'); + const auth = { authorization: `Bearer ${token}` }; + if (rest[0] && rest[0] !== 'accept-all') return fail(o, `review: unknown argument ${rest[0]}; try p0d review accept-all`); + const queue = await request('/api/review?status=review', { headers: auth }); + const waiting = queue.submissions ?? []; + if (!rest[0]) { + if (flags.json) return json(o, queue); + if (!waiting.length) o.out('Nothing waiting.'); + for (const s of waiting) o.out(`${s.id} ${s.title ?? s.input ?? ''} ${s.feedUrl ?? s.feed_url ?? ''}`); + return 0; + } + if (!waiting.length) { + if (flags.json) return json(o, { ok: true, listed: 0, existing: 0, failed: 0, results: [] }); + o.out('Nothing waiting.'); + return 0; + } + if (!flags.yes) return fail(o, `review accept-all: ${waiting.length} waiting; run again with --yes to list them all`); + const body = await request('/api/review', { + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ decision: 'approve', ids: waiting.map((s) => s.id) }), + }); + if (flags.json) return json(o, body); + o.out(`listed ${body.listed}, already listed ${body.existing}${body.failed ? `, failed ${body.failed}` : ''}`); + for (const r of body.results ?? []) if (!r.ok) o.out(`failed ${r.id} — ${r.error}`); + return body.failed ? 1 : 0; + } case 'opml': { const path = rest[0] ? `/opml?category=${encodeURIComponent(rest[0])}` : '/opml'; const res = await o.fetch(`${base}${path}`, { headers: { 'user-agent': `p0d/${VERSION}` } }); diff --git a/src/app/admin/submissions/page.tsx b/src/app/admin/submissions/page.tsx index 5feade8..ed22ee7 100644 --- a/src/app/admin/submissions/page.tsx +++ b/src/app/admin/submissions/page.tsx @@ -8,6 +8,7 @@ import { recentDecisions, submissionsByStatus } from "@/lib/submit"; import Art from "@/components/Art"; import TimeAgo from "@/components/TimeAgo"; import ReviewButtons from "@/components/ReviewButtons"; +import AcceptAllButton from "@/components/AcceptAllButton"; import { safeImage } from "@/lib/format"; export const dynamic = "force-dynamic"; @@ -46,6 +47,7 @@ export default async function Submissions() { ? "Nothing waiting." : `${waiting.length} waiting. Approving lists the show at once; rejecting keeps the feed out until someone submits it again.`}

+ {waiting.length > 1 && s.id)} />} {waiting.map((s) => { const show = parse(s.podcast); diff --git a/src/app/api/review/route.ts b/src/app/api/review/route.ts index 50650fd..4bb07c0 100644 --- a/src/app/api/review/route.ts +++ b/src/app/api/review/route.ts @@ -1,7 +1,8 @@ import { currentUser } from "@/lib/auth/session"; import { bearerPrincipal, SCOPE_REVIEW } from "@/lib/openaccess"; import { reviewerOf } from "@/lib/review"; -import { submissionsByStatus, type SubmissionRow } from "@/lib/submit"; +import { revalidatePath } from "next/cache"; +import { decideSubmission, submissionsByStatus, type SubmissionRow } from "@/lib/submit"; import { publicRow } from "@/lib/review-row"; export const dynamic = "force-dynamic"; @@ -27,3 +28,55 @@ export async function GET(req: Request) { const rows = await submissionsByStatus(status as SubmissionRow["status"]); return Response.json({ ok: true, status, submissions: rows.map(publicRow) }, { headers }); } + +/** + * POST /api/review { decision: "approve", ids?: string[], reason? } — accept + * every submission waiting for review at once (or only `ids`). Each one goes + * through the same decision as a single approve, so a feed that is already + * listed comes back as `existing` and a failure on one does not stop the rest. + */ +export async function POST(req: Request) { + const [user, principal] = await Promise.all([currentUser(), bearerPrincipal(req)]); + const by = reviewerOf(user, principal); + if (!by) { + return Response.json( + { ok: false, error: `sign in as an admin, or present an OpenAccess token with scope ${SCOPE_REVIEW}` }, + { status: 401, headers }, + ); + } + let body: { decision?: unknown; ids?: unknown; reason?: unknown }; + try { + body = await req.json(); + } catch { + return Response.json({ ok: false, error: "bad request" }, { status: 400, headers }); + } + if (body.decision !== "approve") { + return Response.json({ ok: false, error: 'decision must be "approve" (reject one at a time)' }, { status: 400, headers }); + } + const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) || null : null; + const ids = Array.isArray(body.ids) + ? body.ids.filter((x): x is string => typeof x === "string") + : (await submissionsByStatus("review")).map((r) => r.id); + + const results: { id: string; ok: boolean; status?: string; slug?: string | null; title?: string | null; error?: string }[] = []; + for (const id of ids) { + try { + const out = await decideSubmission(id, "approve", by, reason); + results.push(out.ok ? { id, ok: true, status: out.status, slug: out.slug, title: out.title } : { id, ok: false, error: out.error }); + } catch (e) { + results.push({ id, ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + const listed = results.filter((r) => r.ok && r.status === "listed").length; + const existing = results.filter((r) => r.ok && r.status === "existing").length; + const failed = results.filter((r) => !r.ok).length; + if (listed) { + try { + for (const r of results) if (r.slug) revalidatePath(`/podcast/${r.slug}`); + revalidatePath("/"); + } catch { + /* nothing to revalidate outside a request scope */ + } + } + return Response.json({ ok: failed === 0, listed, existing, failed, results }, { headers }); +} diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts index 4d19270..bf0adb4 100644 --- a/src/app/llms.txt/route.ts +++ b/src/app/llms.txt/route.ts @@ -81,7 +81,7 @@ p0dcasters is operated by Profullstack. Contact: hello@p0dcasters.com review (and counted in queued), not accepted, until a person lists it. Twenty requests an hour per address. An OpenAccess bearer token (hub https://openaccess.logicsrc.com, scope podcasts:submit) records its principal as the submitter; scope - submissions:review opens GET /api/review and POST /api/review/{id}. + submissions:review opens GET /api/review, POST /api/review/{id} and POST /api/review {decision: approve} (accept all waiting, or only ids). - [MCP](https://p0dcasters.com/mcp): search, get_podcast, list_episodes, list_shows, browse, directory_stats, submit_feed, get_openprofile, update_openprofile, claim_openprofile, get_newsletter and newsletter_preview at diff --git a/src/app/skill.md/route.ts b/src/app/skill.md/route.ts index 8bde9e6..1b0dcc1 100644 --- a/src/app/skill.md/route.ts +++ b/src/app/skill.md/route.ts @@ -57,7 +57,7 @@ plain HTTP below, described in \`/openapi.json\`. existing}], rejected: [{url, error, message}], queued, total, submissionId, statusUrl, review?}\`. An OpenAccess bearer token (hub https://openaccess.logicsrc.com, scope \`podcasts:submit\`) records its principal as the submitter; \`submissions:review\` - opens \`GET /api/review\` and \`POST /api/review/{id} {decision: approve|reject}\`. + opens \`GET /api/review\`, \`POST /api/review/{id} {decision: approve|reject}\` and \`POST /api/review {decision: approve}\` (accept every waiting submission, or only \`ids\`). ## Limits worth knowing diff --git a/src/components/AcceptAllButton.tsx b/src/components/AcceptAllButton.tsx new file mode 100644 index 0000000..046e450 --- /dev/null +++ b/src/components/AcceptAllButton.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +/** Approve every submission shown in the queue, after one confirm. */ +export default function AcceptAllButton({ ids }: { ids: string[] }) { + const count = ids.length; + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(null); + + async function acceptAll() { + if (!window.confirm(`List all ${count} waiting submissions now?`)) return; + setBusy(true); + setNote(null); + try { + const res = await fetch("/api/review", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision: "approve", ids }), + }); + const body = await res.json().catch(() => ({})); + if (res.status === 401 || body.listed === undefined) { + setNote(body.error || `HTTP ${res.status}`); + } else { + setNote(`Listed ${body.listed}, already listed ${body.existing}${body.failed ? `, failed ${body.failed}` : ""}.`); + router.refresh(); + } + } catch { + setNote("We could not reach the server."); + } finally { + setBusy(false); + } + } + + return ( +
+ + {note && {note}} +
+ ); +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f3b7d62..4955f28 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -96,3 +96,28 @@ test('update and remove refuse outside an installed copy', async () => { const r = io(); assert.equal(await run(['remove', '--yes'], { ...r, argv1: '/nowhere/p0d' }), 1); }); + +test('review accept-all lists every waiting submission, only with --yes', async () => { + const o = io(); + o.env.P0D_TOKEN = 'tok'; + o.fetch = async (url, init = {}) => { + o.calls.push({ url, init }); + if ((init.method ?? 'GET') === 'GET') { + return new Response(JSON.stringify({ ok: true, submissions: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }] }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: true, listed: 1, existing: 1, failed: 0, results: [] }), { status: 200 }); + }; + assert.equal(await run(['review', 'accept-all'], o), 1); + assert.equal(o.calls.length, 1); + assert.equal(await run(['review', 'accept-all', '--yes'], o), 0); + const post = o.calls.at(-1); + assert.equal(post.init.method, 'POST'); + assert.equal(post.init.headers.authorization, 'Bearer tok'); + assert.deepEqual(JSON.parse(post.init.body), { decision: 'approve', ids: ['a', 'b'] }); + assert.match(o.out_.at(-1), /listed 1, already listed 1/); +}); + +test('review needs a token', async () => { + const o = io(); + assert.equal(await run(['review'], o), 1); +});