From b66996ee6cff04d3605187e94772e415c1aad814 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 00:44:21 +0000 Subject: [PATCH] feat(review): accept selected rows, MCP list_submissions + accept_submissions (0.4.1) Per-row select boxes on /admin/submissions turn Accept all into Accept selected. The bulk approve moves to src/lib/accept-all.ts so POST /api/review and the new MCP tools (OpenAccess submissions:review) share it. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/app/admin/submissions/page.tsx | 5 ++++ src/app/api/review/route.ts | 24 +++++------------ src/app/llms.txt/route.ts | 2 +- src/components/AcceptAllButton.tsx | 34 +++++++++++++++++++------ src/lib/accept-all.ts | 26 +++++++++++++++++++ src/lib/mcp/tools.ts | 41 ++++++++++++++++++++++++++++++ test/review-mcp.test.mjs | 15 +++++++++++ 8 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 src/lib/accept-all.ts create mode 100644 test/review-mcp.test.mjs diff --git a/package.json b/package.json index 544113f..16fa780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "p0dcasters", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", "scripts": { diff --git a/src/app/admin/submissions/page.tsx b/src/app/admin/submissions/page.tsx index ed22ee7..61490f7 100644 --- a/src/app/admin/submissions/page.tsx +++ b/src/app/admin/submissions/page.tsx @@ -53,6 +53,11 @@ export default async function Submissions() { const show = parse(s.podcast); return (
+ {waiting.length > 1 && ( + + )} {show ? ( <>
diff --git a/src/app/api/review/route.ts b/src/app/api/review/route.ts index 4bb07c0..e812ae1 100644 --- a/src/app/api/review/route.ts +++ b/src/app/api/review/route.ts @@ -2,7 +2,8 @@ import { currentUser } from "@/lib/auth/session"; import { bearerPrincipal, SCOPE_REVIEW } from "@/lib/openaccess"; import { reviewerOf } from "@/lib/review"; import { revalidatePath } from "next/cache"; -import { decideSubmission, submissionsByStatus, type SubmissionRow } from "@/lib/submit"; +import { submissionsByStatus, type SubmissionRow } from "@/lib/submit"; +import { acceptSubmissions } from "@/lib/accept-all"; import { publicRow } from "@/lib/review-row"; export const dynamic = "force-dynamic"; @@ -54,22 +55,9 @@ export async function POST(req: Request) { 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; + const ids = Array.isArray(body.ids) ? body.ids.filter((x): x is string => typeof x === "string") : undefined; + const out = await acceptSubmissions(by, { ids, reason }); + const { listed, results } = out; if (listed) { try { for (const r of results) if (r.slug) revalidatePath(`/podcast/${r.slug}`); @@ -78,5 +66,5 @@ export async function POST(req: Request) { /* nothing to revalidate outside a request scope */ } } - return Response.json({ ok: failed === 0, listed, existing, failed, results }, { headers }); + return Response.json(out, { headers }); } diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts index bf0adb4..efcaaea 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, POST /api/review/{id} and POST /api/review {decision: approve} (accept all waiting, or only ids). + submissions:review opens GET /api/review, POST /api/review/{id} and POST /api/review {decision: approve} (accept all waiting, or only ids); over MCP, list_submissions and accept_submissions with the same token. - [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/components/AcceptAllButton.tsx b/src/components/AcceptAllButton.tsx index 046e450..0354f89 100644 --- a/src/components/AcceptAllButton.tsx +++ b/src/components/AcceptAllButton.tsx @@ -1,30 +1,48 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -/** Approve every submission shown in the queue, after one confirm. */ +/** The ids ticked with the per-row "select" boxes on the page. */ +function picked(): string[] { + return Array.from(document.querySelectorAll("input[data-accept-pick]:checked")).map((el) => el.value); +} + +/** Approve every submission shown in the queue, or only the ticked ones, 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); + const [selected, setSelected] = useState([]); + + useEffect(() => { + const onChange = (e: Event) => { + if ((e.target as HTMLElement | null)?.matches?.("input[data-accept-pick]")) setSelected(picked()); + }; + document.addEventListener("change", onChange); + return () => document.removeEventListener("change", onChange); + }, []); + + const chosen = selected.length ? selected : ids; + const count = chosen.length; - async function acceptAll() { - if (!window.confirm(`List all ${count} waiting submissions now?`)) return; + async function accept() { + const what = selected.length ? `the ${count} selected submission${count === 1 ? "" : "s"}` : `all ${count} waiting submissions`; + if (!window.confirm(`List ${what} 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 }), + body: JSON.stringify({ decision: "approve", ids: chosen }), }); 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}` : ""}.`); + setSelected([]); router.refresh(); } } catch { @@ -36,8 +54,8 @@ export default function AcceptAllButton({ ids }: { ids: string[] }) { return (
- {note && {note}}
diff --git a/src/lib/accept-all.ts b/src/lib/accept-all.ts new file mode 100644 index 0000000..a19e386 --- /dev/null +++ b/src/lib/accept-all.ts @@ -0,0 +1,26 @@ +import { decideSubmission, submissionsByStatus } from "@/lib/submit"; + +/** + * Accept every submission waiting for review at once (or only `ids`), for the + * Accept all button, POST /api/review and the MCP tool. 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 type AcceptResult = { id: string; ok: boolean; status?: string; slug?: string | null; title?: string | null; error?: string }; + +export async function acceptSubmissions(by: string, opts: { ids?: string[]; reason?: string | null } = {}) { + const ids = opts.ids ?? (await submissionsByStatus("review")).map((r) => r.id); + const results: AcceptResult[] = []; + for (const id of ids) { + try { + const out = await decideSubmission(id, "approve", by, opts.reason ?? null); + 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; + return { ok: failed === 0, listed, existing, failed, results }; +} diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 71b477b..acdfed8 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -8,6 +8,11 @@ import { findOrCreateUser } from "@/lib/auth/session"; import { claimProfile, editorOf, rememberPrincipal, renderShowProfile, saveOverrides } from "@/lib/openprofile/store"; import { profilePage, profileUrl } from "@/lib/openprofile/generate"; import { newsletterInfo, previewIssue } from "@/lib/newsletter-store"; +import { reviewerOf } from "@/lib/review"; +import { acceptSubmissions } from "@/lib/accept-all"; +import { SCOPE_REVIEW } from "@/lib/openaccess"; +import { submissionsByStatus } from "@/lib/submit"; +import { publicRow } from "@/lib/review-row"; /** * What an agent can do with the directory. @@ -368,6 +373,42 @@ TOOLS.push( }, ); +TOOLS.push( + { + name: "list_submissions", + title: "The review queue", + description: `The submitted feeds waiting for a person to list or reject them. For reviewers: needs an OpenAccess bearer with scope ${SCOPE_REVIEW} in the Authorization header.`, + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true, openWorldHint: false }, + async run(_args, ctx) { + const by = reviewerOf(null, await principalOf(ctx as ToolContext)); + if (!by) throw toolError(`Present an OpenAccess token with scope ${SCOPE_REVIEW}.`); + const rows = await submissionsByStatus("review"); + return { count: rows.length, submissions: rows.map(publicRow) }; + }, + }, + { + name: "accept_submissions", + title: "Accept all waiting submissions", + description: `List every submission waiting for review at once, or only the given ids (from list_submissions). A feed already in the directory comes back as existing; one failure does not stop the rest. Returns counts listed / existing / failed and a result per id. Needs an OpenAccess bearer with scope ${SCOPE_REVIEW}.`, + inputSchema: { + type: "object", + properties: { + ids: { type: "array", items: { type: "string" }, description: "Only these submissions. Omit for all waiting." }, + reason: { type: "string", description: "A note for the review log." }, + }, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + async run(args, ctx) { + const by = reviewerOf(null, await principalOf(ctx as ToolContext)); + if (!by) throw toolError(`Present an OpenAccess token with scope ${SCOPE_REVIEW}.`); + const ids = Array.isArray(args?.ids) ? args.ids.filter((x: unknown): x is string => typeof x === "string") : undefined; + const reason = typeof args?.reason === "string" ? args.reason.trim().slice(0, 500) || null : null; + return acceptSubmissions(by, { ids, reason }); + }, + }, +); + export const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])); /** A tool as tools/list describes it: everything but run. */ diff --git a/test/review-mcp.test.mjs b/test/review-mcp.test.mjs new file mode 100644 index 0000000..1869008 --- /dev/null +++ b/test/review-mcp.test.mjs @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TOOLS_BY_NAME } from '../src/lib/mcp/tools.ts'; + +const anon = { header: () => null }; + +test('the review tools are listed and refuse a caller without a submissions:review token', async () => { + for (const name of ['list_submissions', 'accept_submissions']) { + const tool = TOOLS_BY_NAME.get(name); + assert.ok(tool, `${name} is registered`); + assert.match(tool.description, /submissions:review/); + await assert.rejects(() => tool.run({}, anon), /submissions:review/); + } + assert.equal(TOOLS_BY_NAME.get('accept_submissions').annotations.readOnlyHint, false); +});