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.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
5 changes: 5 additions & 0 deletions src/app/admin/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export default async function Submissions() {
const show = parse(s.podcast);
return (
<article className="review-item" key={s.id} id={`s-${s.id}`}>
{waiting.length > 1 && (
<label className="small muted review-pick">
<input type="checkbox" data-accept-pick value={s.id} /> select
</label>
)}
{show ? (
<>
<div className="card submit-preview">
Expand Down
24 changes: 6 additions & 18 deletions src/app/api/review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`);
Expand All @@ -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 });
}
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, 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
Expand Down
34 changes: 26 additions & 8 deletions src/components/AcceptAllButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>("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<string | null>(null);
const [selected, setSelected] = useState<string[]>([]);

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 {
Expand All @@ -36,8 +54,8 @@ export default function AcceptAllButton({ ids }: { ids: string[] }) {

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 type="button" className="btn primary small" disabled={busy || count === 0} onClick={accept}>
{busy ? "Working…" : selected.length ? `Accept selected (${count})` : `Accept all (${count})`}
</button>
{note && <span className="small muted" style={{ marginLeft: 10 }}>{note}</span>}
</div>
Expand Down
26 changes: 26 additions & 0 deletions src/lib/accept-all.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
41 changes: 41 additions & 0 deletions src/lib/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions test/review-mcp.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading