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);
+});