From 28b23e0078bac4543d7f7eb57ac186c5e93ef002 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:20:18 -0400 Subject: [PATCH] submissions(#23): fetch-pending + resolved-set facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network layer for the admin Submissions tab. Because submissions are authored by an ephemeral key, admins can't NIP-09-delete them (relays only honor same-author deletions), so "handled" state lives in an admin-authored RESOLVED-SET. - fetchPendingSubmissions({ adminHex, editorHexes }) — lists kind-30083 to the admin, subtracts the UNION of every editor's resolved-set (so any moderator's action clears it for all), dedupes by claimed applicant (newest wins). - markSubmissionResolved({ submission, status, actingHex }) — read-modify-write the acting moderator's resolved-set (kind-30000, d=bitvid:admin:submissions- resolved), appending the submission's event id + status + applicant, republished via the active signer. tests/submission-facade.test.mjs (4): pending excludes resolved + dedupes by applicant (newest); blank-admin/no-relay guards; resolved-set append keeps prior entries and records status+applicant; no-op guards. Mock client. 4/4; lint clean. Co-Authored-By: Claude Opus 4.8 --- js/submissions/submissionFacade.js | 204 +++++++++++++++++++++++++++++ tests/submission-facade.test.mjs | 133 +++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 js/submissions/submissionFacade.js create mode 100644 tests/submission-facade.test.mjs diff --git a/js/submissions/submissionFacade.js b/js/submissions/submissionFacade.js new file mode 100644 index 00000000..60f19e36 --- /dev/null +++ b/js/submissions/submissionFacade.js @@ -0,0 +1,204 @@ +// js/submissions/submissionFacade.js +// +// Network wiring for structured submissions (#23). Submissions are authored by a +// throwaway EPHEMERAL key, so an admin can't NIP-09-delete them (relays only +// honor same-author deletions). "Handled" state therefore lives in an +// admin-authored RESOLVED-SET: an addressable kind-30000 list (one per acting +// moderator, d = bitvid:admin:submissions-resolved) whose `e` tags are the +// resolved submission event ids. Pending = submissions to the admin minus the +// union of every editor's resolved-set — so any moderator's action clears it for +// all. All entry points take an injectable client for testing. + +import { nostrClient } from "../nostrClientFacade.js"; +import { + SUBMISSION_KIND, + parseSubmissionEvent, +} from "./submissionService.js"; +import { devLogger } from "../utils/logger.js"; + +export const RESOLVED_LIST_KIND = 30000; +export const SUBMISSIONS_RESOLVED_DTAG = "bitvid:admin:submissions-resolved"; +const LIST_LIMIT = 500; + +function normHex(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function getManagerAndRelays(client) { + const manager = + typeof client?.getSubscriptionManager === "function" + ? client.getSubscriptionManager() + : null; + const relays = Array.isArray(client?.relays) ? client.relays : []; + return { manager, relays }; +} + +function newestByCreatedAt(events) { + let newest = null; + for (const event of Array.isArray(events) ? events : []) { + if (!newest || (Number(event?.created_at) || 0) > (Number(newest.created_at) || 0)) { + newest = event; + } + } + return newest; +} + +/** + * Fetch the PENDING submissions addressed to an admin: parsed, deduped by + * applicant (newest wins), and excluding any already in an editor's resolved-set. + * @param {{ adminHex: string, editorHexes?: string[], client?: any }} opts + * @returns {Promise} pending submissions, newest-first + */ +export async function fetchPendingSubmissions({ + adminHex, + editorHexes = [], + client = nostrClient, +} = {}) { + const admin = normHex(adminHex); + if (!admin) { + return []; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return []; + } + + let subEvents = []; + try { + subEvents = await manager.list({ + relays, + filters: [{ kinds: [SUBMISSION_KIND], "#p": [admin], limit: LIST_LIMIT }], + }); + } catch (error) { + devLogger.warn("[submissions] Failed to list submissions:", error); + return []; + } + + const resolvedIds = await fetchResolvedIds({ + editorHexes: [admin, ...editorHexes], + manager, + relays, + }); + + const parsed = (Array.isArray(subEvents) ? subEvents : []) + .map(parseSubmissionEvent) + .filter((s) => s && s.eventId && !resolvedIds.has(s.eventId)) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + + // Dedupe by claimed applicant (newest already first); fall back to id. + const byApplicant = new Map(); + for (const submission of parsed) { + const key = submission.applicant || submission.id; + if (!byApplicant.has(key)) { + byApplicant.set(key, submission); + } + } + return [...byApplicant.values()]; +} + +async function fetchResolvedIds({ editorHexes, manager, relays }) { + const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))]; + const ids = new Set(); + if (!authors.length) { + return ids; + } + let events = []; + try { + events = await manager.list({ + relays, + filters: [ + { + kinds: [RESOLVED_LIST_KIND], + authors, + "#d": [SUBMISSIONS_RESOLVED_DTAG], + limit: 200, + }, + ], + }); + } catch (error) { + devLogger.warn("[submissions] Failed to list resolved-sets:", error); + return ids; + } + for (const event of Array.isArray(events) ? events : []) { + for (const tag of event?.tags || []) { + if (Array.isArray(tag) && tag[0] === "e" && tag[1]) { + ids.add(tag[1]); + } + } + } + return ids; +} + +/** + * Mark a submission handled by adding its event id to the ACTING moderator's + * resolved-set (read-modify-write the addressable list, then republish). Records + * the status (approved/denied) and applicant in the tag for a light audit trail. + * @param {{ submission: Object, status?: "approved"|"denied", actingHex: string, client?: any }} opts + * @returns {Promise} the signed resolved-set event + */ +export async function markSubmissionResolved({ + submission, + status = "approved", + actingHex, + client = nostrClient, +} = {}) { + const acting = normHex(actingHex); + const eventId = + typeof submission?.eventId === "string" ? submission.eventId : ""; + if (!acting || !eventId) { + return null; + } + + const { manager, relays } = getManagerAndRelays(client); + let current = null; + if (manager && typeof manager.list === "function" && relays.length) { + try { + const events = await manager.list({ + relays, + filters: [ + { + kinds: [RESOLVED_LIST_KIND], + authors: [acting], + "#d": [SUBMISSIONS_RESOLVED_DTAG], + limit: 5, + }, + ], + }); + current = newestByCreatedAt(events); + } catch (error) { + // best effort — start a fresh list + } + } + + const tags = [["d", SUBMISSIONS_RESOLVED_DTAG]]; + const seen = new Set(); + for (const tag of current?.tags || []) { + if (Array.isArray(tag) && tag[0] === "e" && tag[1] && !seen.has(tag[1])) { + seen.add(tag[1]); + tags.push(["e", tag[1], tag[2] || "", tag[3] || ""]); + } + } + if (!seen.has(eventId)) { + tags.push(["e", eventId, status, submission.applicant || ""]); + } + tags.push(["client", "bitvid"]); + + const event = { + kind: RESOLVED_LIST_KIND, + pubkey: acting, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; + const result = await client.signAndPublishEvent(event, { + context: "submission-resolved", + }); + return result?.signedEvent || null; +} + +export default { + RESOLVED_LIST_KIND, + SUBMISSIONS_RESOLVED_DTAG, + fetchPendingSubmissions, + markSubmissionResolved, +}; diff --git a/tests/submission-facade.test.mjs b/tests/submission-facade.test.mjs new file mode 100644 index 00000000..1622513a --- /dev/null +++ b/tests/submission-facade.test.mjs @@ -0,0 +1,133 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchPendingSubmissions, + markSubmissionResolved, + RESOLVED_LIST_KIND, + SUBMISSIONS_RESOLVED_DTAG, +} from "../js/submissions/submissionFacade.js"; +import { buildSubmissionEvent, SUBMISSION_KIND } from "../js/nostrEventSchemas.js"; + +const ADMIN = "a".repeat(64); +const MOD = "b".repeat(64); +const ephem = (n) => String(n).repeat(64).slice(0, 64); + +function submissionEvent({ id, applicant, created_at, author, content = "hi" }) { + const event = buildSubmissionEvent({ + pubkey: author || ephem("e"), + created_at, + dTagValue: id, + submissionType: "application", + applicantNpub: applicant, + recipientPubkey: ADMIN, + content, + }); + event.id = `evt-${id}`; + return event; +} + +function resolvedSet({ author, eventIds, created_at = 1000 }) { + return { + id: `resolved-${author.slice(0, 4)}`, + kind: RESOLVED_LIST_KIND, + pubkey: author, + created_at, + tags: [ + ["d", SUBMISSIONS_RESOLVED_DTAG], + ...eventIds.map((eid) => ["e", eid, "approved", ""]), + ], + }; +} + +function mockClient({ events = [], onPublish } = {}) { + return { + relays: ["wss://mock.relay"], + getSubscriptionManager: () => ({ + list: async ({ filters }) => { + const f = filters[0] || {}; + return events.filter((ev) => { + if (f.kinds && !f.kinds.includes(ev.kind)) return false; + if (f.authors && !f.authors.includes(ev.pubkey)) return false; + if (f["#p"]) { + const p = ev.tags.find((t) => t[0] === "p")?.[1]; + if (!f["#p"].includes(p)) return false; + } + if (f["#d"]) { + const d = ev.tags.find((t) => t[0] === "d")?.[1]; + if (!f["#d"].includes(d)) return false; + } + return true; + }); + }, + }), + signAndPublishEvent: async (event, options) => { + if (onPublish) onPublish(event, options); + return { signedEvent: { ...event, id: "signed-id", sig: "sig" } }; + }, + }; +} + +test("fetchPendingSubmissions returns pending, excludes resolved, dedupes by applicant", async () => { + const events = [ + submissionEvent({ id: "s1", applicant: "npub-alice", created_at: 100 }), + // alice re-applied later — newest should win + submissionEvent({ id: "s2", applicant: "npub-alice", created_at: 200 }), + submissionEvent({ id: "s3", applicant: "npub-bob", created_at: 150 }), + // carol's was resolved by a moderator + submissionEvent({ id: "s4", applicant: "npub-carol", created_at: 300 }), + // resolved-set from a moderator marks carol's evt-s4 handled + resolvedSet({ author: MOD, eventIds: ["evt-s4"] }), + ]; + + const pending = await fetchPendingSubmissions({ + adminHex: ADMIN, + editorHexes: [MOD], + client: mockClient({ events }), + }); + + const applicants = pending.map((s) => s.applicant).sort(); + assert.deepEqual(applicants, ["npub-alice", "npub-bob"], "carol resolved, alice deduped"); + const alice = pending.find((s) => s.applicant === "npub-alice"); + assert.equal(alice.id, "s2", "newest application for alice wins"); +}); + +test("fetchPendingSubmissions returns [] for a blank admin or no relays", async () => { + assert.deepEqual(await fetchPendingSubmissions({ adminHex: "", client: mockClient({}) }), []); + const noRelays = { ...mockClient({}), relays: [] }; + assert.deepEqual(await fetchPendingSubmissions({ adminHex: ADMIN, client: noRelays }), []); +}); + +test("markSubmissionResolved appends to the acting moderator's resolved-set", async () => { + // MOD already resolved evt-old; now resolves evt-s3. + const events = [resolvedSet({ author: MOD, eventIds: ["evt-old"] })]; + let published = null; + const client = mockClient({ events, onPublish: (e) => (published = e) }); + + await markSubmissionResolved({ + submission: { eventId: "evt-s3", applicant: "npub-bob" }, + status: "denied", + actingHex: MOD, + client, + }); + + assert.equal(published.kind, RESOLVED_LIST_KIND); + assert.equal(published.pubkey, MOD); + assert.equal(published.tags.find((t) => t[0] === "d")?.[1], SUBMISSIONS_RESOLVED_DTAG); + const eTags = published.tags.filter((t) => t[0] === "e").map((t) => t[1]); + assert.deepEqual(eTags.sort(), ["evt-old", "evt-s3"], "keeps prior + adds new"); + const newTag = published.tags.find((t) => t[0] === "e" && t[1] === "evt-s3"); + assert.equal(newTag[2], "denied", "records status"); + assert.equal(newTag[3], "npub-bob", "records applicant"); +}); + +test("markSubmissionResolved is a no-op without an acting pubkey or event id", async () => { + assert.equal( + await markSubmissionResolved({ submission: { eventId: "x" }, actingHex: "", client: mockClient({}) }), + null, + ); + assert.equal( + await markSubmissionResolved({ submission: {}, actingHex: MOD, client: mockClient({}) }), + null, + ); +});