mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
submissions(#23): fetch-pending + resolved-set facade
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 <[email protected]>
This commit is contained in:
1 parent
a18230eb70
commit
28b23e0078
2 files changed
+337
No files matched your search
@@ -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<Array>} 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<Object|null>} 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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user