Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "p0dcasters",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
40 changes: 39 additions & 1 deletion public/cli/p0d.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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]',
Expand Down Expand Up @@ -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}` } });
Expand Down
2 changes: 2 additions & 0 deletions src/app/admin/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.`}
</p>
{waiting.length > 1 && <AcceptAllButton ids={waiting.map((s) => s.id)} />}

{waiting.map((s) => {
const show = parse(s.podcast);
Expand Down
55 changes: 54 additions & 1 deletion src/app/api/review/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
2 changes: 1 addition & 1 deletion src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/skill.md/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions src/components/AcceptAllButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="actions" style={{ margin: "0 0 18px" }}>
<button type="button" className="btn primary small" disabled={busy || count === 0} onClick={acceptAll}>
{busy ? "Working…" : `Accept all (${count})`}
</button>
{note && <span className="small muted" style={{ marginLeft: 10 }}>{note}</span>}
</div>
);
}
25 changes: 25 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading