diff --git a/apps/api/scripts/prune-spam.ts b/apps/api/scripts/prune-spam.ts index dbacc9e..e7cfc31 100644 --- a/apps/api/scripts/prune-spam.ts +++ b/apps/api/scripts/prune-spam.ts @@ -1,23 +1,30 @@ /** * prune-spam.ts — Re-runnable spam-prune operator script. * - * Reads spam verdicts from the `spam-detection` branch of the data repo, - * aggregates them per the spec rule, and removes confident-spam people from - * the `published` branch with cascaded deletes of their associated records. + * Reads machine spam verdicts from the private spam-detection repo and staff + * `human-*` votes from the data repo's served branch, aggregates them per the + * spec rule (a human vote is final), and removes spam people from the + * `published` branch with cascaded deletes of their associated records. * * Spec: specs/behaviors/spam-exclusion.md * * Usage: * npm run -w apps/api script:prune-spam -- \ - * --data-repo=/path/to/codeforphilly-data \ - * [--evaluations-ref=spam-detection] \ + * --data-repo=/path/to/codeforphilly-data.git \ + * --evaluations-repo=/path/to/codeforphilly-spam-detection \ + * [--evaluations-ref=HEAD] \ + * [--human-votes-ref=published] \ * [--branch=published] \ * [--threshold=0.8] \ * [--dry-run] [--verbose] * * --data-repo Path to a local bare clone of the data repo. * Falls back to $CFP_DATA_REPO_PATH. - * --evaluations-ref Ref to read person-evaluations from (default: spam-detection). + * --evaluations-repo Repo (bare or not) holding machine person-evaluations — + * the private codeforphilly-spam-detection clone. Defaults + * to --data-repo for single-repo setups. + * --evaluations-ref Ref in --evaluations-repo to read (default: HEAD). + * --human-votes-ref Ref in --data-repo carrying staff votes (default: --branch). * --branch Branch to prune (default: published). * --threshold Spam confidence threshold (default: 0.8). * --dry-run Report without writing. @@ -43,7 +50,11 @@ import type { interface CliArgs { readonly dataRepo: string; + /** Repo holding the machine evaluations (the private spam-detection clone). Defaults to dataRepo. */ + readonly evaluationsRepo: string; readonly evaluationsRef: string; + /** Ref in dataRepo carrying staff `human-*` votes. Defaults to --branch. */ + readonly humanVotesRef: string; readonly branch: string; readonly threshold: number; readonly dryRun: boolean; @@ -73,16 +84,23 @@ function parseArgs(argv: readonly string[]): CliArgs { const threshold = typeof thresholdRaw === 'string' ? Number.parseFloat(thresholdRaw) : 0.8; + const branch = + typeof opts['branch'] === 'string' && opts['branch'] !== '' ? opts['branch'] : 'published'; return { dataRepo: resolve(dataRepoRaw), + evaluationsRepo: + typeof opts['evaluations-repo'] === 'string' && opts['evaluations-repo'] !== '' + ? resolve(opts['evaluations-repo']) + : resolve(dataRepoRaw), evaluationsRef: typeof opts['evaluations-ref'] === 'string' && opts['evaluations-ref'] !== '' ? opts['evaluations-ref'] - : 'spam-detection', - branch: - typeof opts['branch'] === 'string' && opts['branch'] !== '' - ? opts['branch'] - : 'published', + : 'HEAD', + humanVotesRef: + typeof opts['human-votes-ref'] === 'string' && opts['human-votes-ref'] !== '' + ? opts['human-votes-ref'] + : branch, + branch, threshold: Number.isFinite(threshold) ? threshold : 0.8, dryRun: opts['dry-run'] === true, verbose: opts['verbose'] === true, @@ -94,22 +112,31 @@ function parseArgs(argv: readonly string[]): CliArgs { // --------------------------------------------------------------------------- interface PersonVerdict { - /** Whether any evaluator gave spam confidence >= threshold. */ + /** Whether any machine evaluator gave spam confidence >= threshold. */ hasConfidentSpam: boolean; - /** Whether any evaluator gave a legit verdict at any confidence. */ + /** Whether any machine evaluator gave a legit verdict at any confidence. */ hasAnyLegit: boolean; + /** + * The latest `human-*` verdict, if any. A human vote is final: `spam` + * prunes regardless of machine verdicts or project membership; `legit` + * keeps. Per specs/behaviors/spam-exclusion.md. + */ + humanVerdict: string | null; + humanEvaluatedAt: string | null; } /** - * Parse verdict and confidence from TOML content using line-regex - * (tolerant, avoids pulling in a full TOML parser just for two fields). + * Parse verdict, confidence, and evaluatedAt from TOML content using + * line-regex (tolerant, avoids pulling in a full TOML parser for three fields). */ function parseEvaluationRecord(tomlContent: string): { verdict: string | null; confidence: number | null; + evaluatedAt: string | null; } { let verdict: string | null = null; let confidence: number | null = null; + let evaluatedAt: string | null = null; for (const line of tomlContent.split('\n')) { const trimmed = line.trim(); @@ -122,12 +149,17 @@ function parseEvaluationRecord(tomlContent: string): { if (confidenceMatch) { const parsed = Number.parseFloat(confidenceMatch[1] ?? ''); if (Number.isFinite(parsed)) confidence = parsed; + continue; } + const atMatch = trimmed.match(/^evaluatedAt\s*=\s*"([^"]+)"/); + if (atMatch) evaluatedAt = atMatch[1] ?? null; } - return { verdict, confidence }; + return { verdict, confidence, evaluatedAt }; } +const HUMAN_EVALUATOR_PREFIX = 'human-'; + /** * Read all person-evaluations from the given ref via `git cat-file` bulk read. * Does NOT go through gitsheets — there are ~54k records and we want a @@ -138,8 +170,9 @@ async function aggregateVerdicts( evaluationsRef: string, threshold: number, log: (msg: string) => void, + verdictMap: Map = new Map(), ): Promise> { - log(`[prune-spam] listing person-evaluations under ref=${evaluationsRef}`); + log(`[prune-spam] listing person-evaluations in ${repo} under ref=${evaluationsRef}`); // List all blobs under person-evaluations/ in the evaluations ref. const lsOutput = await exec( @@ -152,7 +185,7 @@ async function aggregateVerdicts( log(`[prune-spam] found ${lines.length} evaluation records`); if (lines.length === 0) { - return new Map(); + return verdictMap; } // Build a batch-check-mailbox input: one object hash per line. @@ -174,8 +207,6 @@ async function aggregateVerdicts( // Use child_process.spawn for streaming instead of execFile (fits in memory for this size). const { spawn } = await import('node:child_process'); - const verdictMap = new Map(); - await new Promise((resolvePromise, reject) => { const catFile = spawn('git', ['cat-file', '--batch'], { cwd: repo }); @@ -248,20 +279,25 @@ async function aggregateVerdicts( const pathParts = currentExpected.path.split('/'); // path is like: person-evaluations//.toml const personSlug = pathParts[1]; + const evaluator = (pathParts[2] ?? '').replace(/\.toml$/, ''); if (personSlug) { - const { verdict, confidence } = parseEvaluationRecord(tomlContent); - if (verdict !== null && confidence !== null) { - let entry = verdictMap.get(personSlug); - if (!entry) { - entry = { hasConfidentSpam: false, hasAnyLegit: false }; - verdictMap.set(personSlug, entry); - } - if (verdict === 'spam' && confidence >= threshold) { - (entry as { hasConfidentSpam: boolean }).hasConfidentSpam = true; - } - if (verdict === 'legit') { - (entry as { hasAnyLegit: boolean }).hasAnyLegit = true; + const { verdict, confidence, evaluatedAt } = parseEvaluationRecord(tomlContent); + let entry = verdictMap.get(personSlug); + if (!entry) { + entry = { hasConfidentSpam: false, hasAnyLegit: false, humanVerdict: null, humanEvaluatedAt: null }; + verdictMap.set(personSlug, entry); + } + if (evaluator.startsWith(HUMAN_EVALUATOR_PREFIX)) { + // Latest human vote wins; ties resolve to whichever is read last. + if (verdict !== null && (entry.humanEvaluatedAt === null || (evaluatedAt ?? '') >= entry.humanEvaluatedAt)) { + entry.humanVerdict = verdict; + entry.humanEvaluatedAt = evaluatedAt ?? ''; } + } else if (verdict !== null && confidence !== null) { + // Heuristic records carry `score`, not `confidence`, and never + // reach here — they must be LLM-confirmed before they can prune. + if (verdict === 'spam' && confidence >= threshold) entry.hasConfidentSpam = true; + if (verdict === 'legit') entry.hasAnyLegit = true; } } @@ -292,17 +328,30 @@ async function aggregateVerdicts( } /** - * Compute the set of person slugs to prune: - * prune iff hasConfidentSpam AND NOT hasAnyLegit. + * Compute the set of person slugs to prune. A human vote is final: `spam` + * prunes, `legit` keeps. Otherwise prune iff hasConfidentSpam AND NOT + * hasAnyLegit. Returns the human-spam subset too, since those bypass the + * project-membership protection. */ -function computePruneSet(verdictMap: Map): Set { +function computePruneSet(verdictMap: Map): { + pruneSet: Set; + humanSpam: Set; +} { const pruneSet = new Set(); + const humanSpam = new Set(); for (const [slug, v] of verdictMap) { + if (v.humanVerdict !== null) { + if (v.humanVerdict === 'spam') { + pruneSet.add(slug); + humanSpam.add(slug); + } + continue; + } if (v.hasConfidentSpam && !v.hasAnyLegit) { pruneSet.add(slug); } } - return pruneSet; + return { pruneSet, humanSpam }; } /** Minimal read surface shared by the live store and an open transaction. */ @@ -326,6 +375,7 @@ interface CandidatePerson { async function partitionCandidates( q: Queryable, candidateSlugs: Set, + humanSpam: Set = new Set(), ): Promise<{ prune: CandidatePerson[]; protectedByMembership: number }> { const candidates: CandidatePerson[] = []; for await (const person of q.people.query()) { @@ -343,7 +393,9 @@ async function partitionCandidates( if (typeof pid === 'string') memberPersonIds.add(pid); } - const prune = candidates.filter((c) => !memberPersonIds.has(c.id)); + // Membership protects against machine verdicts only — a human looked at the + // profile, so a human `spam` vote is not overridden by real-looking content. + const prune = candidates.filter((c) => humanSpam.has(c.slug) || !memberPersonIds.has(c.id)); return { prune, protectedByMembership: candidates.length - prune.length }; } @@ -406,17 +458,22 @@ async function pruneSpam(args: CliArgs): Promise { // ------------------------------------------------------------------------- // 1. Read verdicts from evaluations ref (efficient git read) // ------------------------------------------------------------------------- - log(`[prune-spam] reading verdicts from ref=${args.evaluationsRef}, threshold=${args.threshold}`); + log( + `[prune-spam] reading machine verdicts from ${args.evaluationsRepo}@${args.evaluationsRef}, ` + + `human votes from ${args.dataRepo}@${args.humanVotesRef}, threshold=${args.threshold}`, + ); const verdictMap = await aggregateVerdicts( - args.dataRepo, + args.evaluationsRepo, args.evaluationsRef, args.threshold, log, ); + await aggregateVerdicts(args.dataRepo, args.humanVotesRef, args.threshold, log, verdictMap); - const pruneSet = computePruneSet(verdictMap); + const { pruneSet, humanSpam } = computePruneSet(verdictMap); log( - `[prune-spam] evaluated=${verdictMap.size} persons, pruneSet=${pruneSet.size} (confident spam with no legit)`, + `[prune-spam] evaluated=${verdictMap.size} persons, pruneSet=${pruneSet.size} ` + + `(${humanSpam.size} by human vote; the rest confident machine spam with no legit)`, ); // ------------------------------------------------------------------------- @@ -443,6 +500,7 @@ async function pruneSpam(args: CliArgs): Promise { const { prune, protectedByMembership } = await partitionCandidates( store as unknown as Queryable, pruneSet, + humanSpam, ); console.log( `[prune-spam] dry-run: would prune ${prune.length} (of ${pruneSet.size} verdict-flagged slugs); ${protectedByMembership} protected by project membership`, @@ -492,6 +550,7 @@ async function pruneSpam(args: CliArgs): Promise { const { prune, protectedByMembership: protectedCount } = await partitionCandidates( tx as unknown as Queryable, pruneSet, + humanSpam, ); protectedByMembership = protectedCount; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 99ea97a..cfd6e26 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -60,6 +60,7 @@ import { projectMembershipRoutes } from './routes/projects-members.js'; import { previewRoutes } from './routes/preview.js'; import { attachmentRoutes } from './routes/attachments.js'; import { chatRoutes } from './routes/chat.js'; +import { moderationRoutes } from './routes/moderation.js'; import { samlRoutes } from './routes/saml.js'; import { internalRoutes } from './routes/internal.js'; @@ -201,6 +202,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { tagsWrite: new TagWriteService(state), githubAccount, accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount), + moderation: new ModerationService(state, fastify.store.private, (personId) => { + // Newest sign-in from session metadata; the auth plugin decorates it + // after this one registers, so resolve lazily per call. + let latest: string | null = null; + for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) { + if (!latest || m.issuedAt > latest) latest = m.issuedAt; + } + return latest; + }), + moderationWrite: new ModerationWriteService(state), }); } diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts new file mode 100644 index 0000000..ddf8d62 --- /dev/null +++ b/apps/api/src/routes/moderation.ts @@ -0,0 +1,140 @@ +/** + * Moderation API — specs/api/moderation.md. + * + * GET /api/admin/members — staff roster, newest signup first + * GET /api/admin/members/:slug — one member's footprint + human votes + * POST /api/admin/members/:slug/vote — record the caller's spam/legit verdict + * + * Every route is staff/admin only and answers 404 to anyone else: like the + * other staff surfaces, the endpoints' existence is not a signal. + */ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { ok, paginated } from '../lib/response.js'; +import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; +import { getCallerSession } from '../services/permissions.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; +import type { VoteFilter } from '../services/moderation.js'; + +function requireStaffOr404(request: FastifyRequest): void { + const level = request.session.accountLevel; + if (level !== 'staff' && level !== 'administrator') { + throw new ApiNotFoundError('Not found'); + } +} + +export async function moderationRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/api/admin/members', + { + schema: { + tags: ['moderation'], + summary: 'Staff roster of members, newest signup first', + querystring: { + type: 'object', + properties: { + q: { type: 'string' }, + vote: { type: 'string', enum: ['none', 'spam', 'legit'] }, + joinedAfter: { type: 'string' }, + joinedBefore: { type: 'string' }, + includeDeactivated: { type: 'boolean' }, + sort: { type: 'string' }, + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 200 }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + requireStaffOr404(request); + const q = request.query as Record; + const result = await fastify.services.moderation.listMembers({ + q: q['q'] as string | undefined, + vote: q['vote'] as VoteFilter | undefined, + joinedAfter: q['joinedAfter'] as string | undefined, + joinedBefore: q['joinedBefore'] as string | undefined, + includeDeactivated: q['includeDeactivated'] as boolean | undefined, + sort: q['sort'] as string | undefined, + page: q['page'] as number | undefined, + perPage: q['perPage'] as number | undefined, + }); + if ('error' in result) { + throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' }); + } + return paginated(result.items, { + page: result.page, + perPage: result.perPage, + totalItems: result.totalItems, + totalPages: Math.max(1, Math.ceil(result.totalItems / result.perPage)), + }); + }, + ); + + fastify.get( + '/api/admin/members/:slug', + { + schema: { + tags: ['moderation'], + summary: "One member's footprint on the site and every human vote", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, + async (request) => { + requireStaffOr404(request); + const { slug } = request.params as { slug: string }; + const caller = getCallerSession(request); + const person = await fastify.services.people.get(slug, caller); + const footprint = fastify.services.moderation.footprint(slug); + if (!person || !footprint) throw new ApiNotFoundError(`Person '${slug}' not found`); + return ok({ + person, + footprint, + votes: fastify.services.moderation.humanVotes(slug), + }); + }, + ); + + fastify.post( + '/api/admin/members/:slug/vote', + { + schema: { + tags: ['moderation'], + summary: "Record the caller's spam / not-spam verdict on a member", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + properties: { + verdict: { type: 'string', enum: ['spam', 'legit'] }, + reasoning: { type: 'string', maxLength: 1000 }, + }, + required: ['verdict'], + additionalProperties: false, + }, + }, + }, + async (request) => { + requireStaffOr404(request); + const { slug } = request.params as { slug: string }; + const body = request.body as { verdict: 'spam' | 'legit'; reasoning?: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'moderation.vote', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + summary: `${body.verdict}${body.reasoning ? `: ${body.reasoning}` : ''}`, + }), + async (tx) => fastify.services.moderationWrite.castVote(tx, slug, request.session, body), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const caller = getCallerSession(request); + const person = await fastify.services.people.get(result.value.person.slug, caller); + return ok({ + person, + vote: result.value.vote, + latestVote: fastify.services.moderation.latestHumanVote(slug), + }); + }, + ); +} diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts new file mode 100644 index 0000000..fc8de4c --- /dev/null +++ b/apps/api/src/services/moderation.ts @@ -0,0 +1,412 @@ +/** + * Moderation: the staff members roster, per-member footprint, and human spam + * votes. Per specs/api/moderation.md and specs/screens/admin-members.md. + * + * Reads come entirely from in-memory state plus the private store (email) + * and session metadata (last sign-in). The only write is `castVote`, which + * records a `human-` person-evaluation and applies the lifecycle + * side effect from specs/behaviors/person-lifecycle.md. + */ +import { + PersonSchema, + PersonEvaluationSchema, + HUMAN_EVALUATOR_PREFIX, + humanEvaluatorFor, + isHumanEvaluator, + type Person, + type PersonEvaluation, +} from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { PrivateStore } from '../store/private/interface.js'; +import type { DualStoreTx } from '../store/store.js'; +import { StateApply } from '../store/state-apply.js'; +import type { SessionContext } from '../auth/middleware.js'; +import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; + +export type VoteVerdict = 'spam' | 'legit'; +export type VoteFilter = 'none' | VoteVerdict; + +export interface MemberListOptions { + readonly q?: string; + readonly vote?: VoteFilter; + readonly joinedAfter?: string; + readonly joinedBefore?: string; + readonly includeDeactivated?: boolean; + readonly sort?: string; + readonly page?: number; + readonly perPage?: number; +} + +export interface VoterRef { + readonly slug: string; + readonly fullName: string; +} + +export interface VoteView { + readonly verdict: 'spam' | 'legit' | 'uncertain'; + readonly reasoning: string | null; + readonly voter: VoterRef; + readonly evaluatedAt: string; +} + +export interface FootprintCounts { + readonly memberships: number; + readonly updates: number; + readonly buzz: number; + readonly blogPosts: number; + readonly helpWantedInterest: number; + readonly tags: number; +} + +export interface MemberRow { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly createdAt: string; + readonly deletedAt: string | null; + readonly email: string | null; + readonly hasGitHubLink: boolean; + readonly lastLoginAt: string | null; + readonly bioExcerpt: string; + readonly footprint: FootprintCounts; + readonly latestVote: VoteView | null; +} + +export interface ProjectRef { + readonly slug: string; + readonly title: string; +} + +export interface MemberFootprint { + readonly memberships: Array<{ project: ProjectRef; role: string; joinedAt: string }>; + readonly updates: Array<{ project: ProjectRef; number: number; title: string; postedAt: string }>; + readonly buzz: Array<{ project: ProjectRef; slug: string; title: string; postedAt: string }>; + readonly blogPosts: Array<{ slug: string; title: string; postedAt: string }>; + readonly helpWantedInterest: Array<{ project: ProjectRef; role: { title: string }; createdAt: string }>; + readonly tags: Array<{ handle: string; type: string }>; +} + +export interface MemberListResult { + readonly items: MemberRow[]; + readonly totalItems: number; + readonly page: number; + readonly perPage: number; +} + +/** Newest sign-in for a person, from session metadata; injected to avoid a plugin dependency. */ +export type LastLoginLookup = (personId: string) => string | null; + +const SORT_KEYS = new Set(['createdAt', 'fullName', 'lastLoginAt']); + +function parseSort(sort: string | undefined): { key: string; desc: boolean } | null { + const raw = sort && sort.trim() !== '' ? sort.trim() : '-createdAt'; + const desc = raw.startsWith('-'); + const key = desc ? raw.slice(1) : raw; + if (!SORT_KEYS.has(key)) return null; + return { key, desc }; +} + +/** Strip the markdown a bio typically carries and cut to ~160 chars. */ +export function bioExcerpt(bio: string | null | undefined, max = 160): string { + if (!bio) return ''; + const text = bio + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*_>#~]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export class ModerationService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + readonly #lastLogin: LastLoginLookup; + + constructor(state: InMemoryState, privateStore: PrivateStore, lastLogin: LastLoginLookup) { + this.#state = state; + this.#privateStore = privateStore; + this.#lastLogin = lastLogin; + } + + /** Every human vote on a person, newest first. */ + humanVotes(personSlug: string): VoteView[] { + const keys = this.#state.evaluationsByPerson.get(personSlug); + if (!keys) return []; + const votes: VoteView[] = []; + for (const key of keys) { + const rec = this.#state.personEvaluations.get(key); + if (!rec || !isHumanEvaluator(rec.evaluator)) continue; + votes.push(this.#voteView(rec)); + } + return votes.sort((a, b) => b.evaluatedAt.localeCompare(a.evaluatedAt)); + } + + latestHumanVote(personSlug: string): VoteView | null { + return this.humanVotes(personSlug)[0] ?? null; + } + + async listMembers(opts: MemberListOptions): Promise { + const sort = parseSort(opts.sort); + if (!sort) return { error: 'invalid_sort' }; + + const includeDeactivated = opts.includeDeactivated ?? true; + const q = opts.q?.trim().toLowerCase() ?? ''; + const emailSearch = q.includes('@'); + + let people = [...this.#state.people.values()]; + if (!includeDeactivated) people = people.filter((p) => !p.deletedAt); + if (opts.joinedAfter) people = people.filter((p) => p.createdAt >= opts.joinedAfter!); + if (opts.joinedBefore) people = people.filter((p) => p.createdAt <= opts.joinedBefore!); + if (opts.vote) { + people = people.filter((p) => { + const latest = this.latestHumanVote(p.slug); + if (opts.vote === 'none') return latest === null; + return latest?.verdict === opts.vote; + }); + } + + const authored = this.#authoredCounts(); + const lastLogins = new Map(); + const emails = new Map(); + const emailOf = async (p: Person): Promise => { + if (!emails.has(p.id)) emails.set(p.id, (await this.#privateStore.getProfile(p.id))?.email ?? null); + return emails.get(p.id) ?? null; + }; + + if (q) { + const matched: Person[] = []; + for (const p of people) { + const hay = `${p.fullName} ${p.slug} ${p.bio ?? ''}`.toLowerCase(); + if (hay.includes(q)) { + matched.push(p); + continue; + } + if (emailSearch) { + const email = await emailOf(p); + if (email && email.toLowerCase().includes(q)) matched.push(p); + } + } + people = matched; + } + + const lastLoginOf = (p: Person): string | null => { + if (!lastLogins.has(p.id)) lastLogins.set(p.id, this.#lastLogin(p.id)); + return lastLogins.get(p.id) ?? null; + }; + + people.sort((a, b) => { + let cmp: number; + if (sort.key === 'fullName') cmp = a.fullName.localeCompare(b.fullName); + else if (sort.key === 'lastLoginAt') cmp = (lastLoginOf(a) ?? '').localeCompare(lastLoginOf(b) ?? ''); + else cmp = a.createdAt.localeCompare(b.createdAt); + if (cmp === 0) cmp = a.slug.localeCompare(b.slug); + return sort.desc ? -cmp : cmp; + }); + + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(200, Math.max(1, opts.perPage ?? 50)); + const slice = people.slice((page - 1) * perPage, page * perPage); + + const items: MemberRow[] = []; + for (const p of slice) { + items.push({ + id: p.id, + slug: p.slug, + fullName: p.fullName, + avatarUrl: p.avatarKey ? `/api/attachments/${p.avatarKey}` : null, + createdAt: p.createdAt, + deletedAt: p.deletedAt ?? null, + email: await emailOf(p), + hasGitHubLink: typeof p.githubUserId === 'number', + lastLoginAt: lastLoginOf(p), + bioExcerpt: bioExcerpt(p.bio), + footprint: { + memberships: this.#state.membershipsByPerson.get(p.id)?.size ?? 0, + updates: authored.updates.get(p.id) ?? 0, + buzz: authored.buzz.get(p.id) ?? 0, + blogPosts: authored.blogPosts.get(p.id) ?? 0, + helpWantedInterest: authored.interest.get(p.id) ?? 0, + tags: this.#state.tagAssignmentsByTaggable.get(p.id)?.size ?? 0, + }, + latestVote: this.latestHumanVote(p.slug), + }); + } + + return { items, totalItems: people.length, page, perPage }; + } + + /** Full footprint for one member (deactivated included — this is moderation). */ + footprint(slug: string): MemberFootprint | null { + const id = this.#state.personIdBySlug.get(slug); + if (!id) return null; + const s = this.#state; + const projectRef = (projectId: string): ProjectRef | null => { + const project = s.projects.get(projectId); + return project ? { slug: project.slug, title: project.title } : null; + }; + + const memberships = [...(s.membershipsByPerson.get(id) ?? [])] + .map((mid) => s.projectMemberships.get(mid)) + .flatMap((m) => { + const project = m ? projectRef(m.projectId) : null; + return m && project ? [{ project, role: m.role ?? (m.isMaintainer ? 'maintainer' : 'member'), joinedAt: m.joinedAt }] : []; + }); + + const updates = [...s.projectUpdates.values()] + .filter((u) => u.authorId === id) + .flatMap((u) => { + const project = projectRef(u.projectId); + // Updates have no title; the excerpt of the body stands in for one. + return project ? [{ project, number: u.number, title: bioExcerpt(u.body, 80), postedAt: u.createdAt }] : []; + }) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const buzz = [...s.projectBuzz.values()] + .filter((b) => b.postedById === id) + .flatMap((b) => { + const project = projectRef(b.projectId); + return project ? [{ project, slug: b.slug, title: b.headline, postedAt: b.publishedAt }] : []; + }) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const blogPosts = [...s.blogPosts.values()] + .filter((bp) => bp.authorId === id) + .map((bp) => ({ slug: bp.slug, title: bp.title, postedAt: bp.postedAt })) + .sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const helpWantedInterest = [...s.helpWantedInterest.values()] + .filter((i) => i.personId === id) + .flatMap((i) => { + const role = s.helpWantedRoles.get(i.roleId); + const project = role ? projectRef(role.projectId) : null; + return role && project ? [{ project, role: { title: role.title }, createdAt: i.createdAt }] : []; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const tags = [...(s.tagAssignmentsByTaggable.get(id) ?? [])] + .map((taId) => s.tagAssignments.get(taId)) + .flatMap((ta) => { + const tag = ta ? s.tags.get(ta.tagId) : undefined; + return tag ? [{ handle: `${tag.namespace}.${tag.slug}`, type: tag.namespace }] : []; + }); + + return { memberships, updates, buzz, blogPosts, helpWantedInterest, tags }; + } + + #voteView(rec: PersonEvaluation): VoteView { + const voterSlug = rec.evaluator.slice(HUMAN_EVALUATOR_PREFIX.length); + const voterId = this.#state.personIdBySlug.get(voterSlug); + const voter = voterId ? this.#state.people.get(voterId) : undefined; + return { + verdict: rec.verdict, + reasoning: rec.reasoning ?? null, + voter: { slug: voterSlug, fullName: voter?.fullName ?? voterSlug }, + evaluatedAt: rec.evaluatedAt, + }; + } + + #authoredCounts(): { + updates: Map; + buzz: Map; + blogPosts: Map; + interest: Map; + } { + const bump = (m: Map, k: string | null | undefined): void => { + if (k) m.set(k, (m.get(k) ?? 0) + 1); + }; + const updates = new Map(); + const buzz = new Map(); + const blogPosts = new Map(); + const interest = new Map(); + for (const u of this.#state.projectUpdates.values()) bump(updates, u.authorId); + for (const b of this.#state.projectBuzz.values()) bump(buzz, b.postedById); + for (const bp of this.#state.blogPosts.values()) bump(blogPosts, bp.authorId); + for (const i of this.#state.helpWantedInterest.values()) bump(interest, i.personId); + return { updates, buzz, blogPosts, interest }; + } +} + +export interface CastVoteInput { + readonly verdict: VoteVerdict; + readonly reasoning?: string; +} + +export class ModerationWriteService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + /** + * Record the caller's verdict as `person-evaluations//human-` + * and apply the lifecycle side effect: spam → deactivate; legit → reactivate + * only when the previous latest human vote was spam (a self-deactivation is + * never undone by a vote). One record per voter; re-voting replaces it. + */ + async castVote( + tx: DualStoreTx, + slug: string, + session: SessionContext, + input: CastVoteInput, + ): Promise<{ person: Person; vote: PersonEvaluation; stateApply: StateApply }> { + const voter = session.person; + if (!voter) throw new ApiNotFoundError(`Person '${slug}' not found`); + + const id = this.#state.personIdBySlug.get(slug); + const existing = id ? this.#state.people.get(id) : undefined; + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + if (existing.id === voter.id) { + throw new ApiValidationError('You cannot vote on your own account', { slug: 'self' }); + } + if (input.reasoning !== undefined && input.reasoning.length > 1000) { + throw new ApiValidationError('Reasoning is too long', { reasoning: 'max 1000 characters' }); + } + + const previousLatest = latestHumanVerdict(this.#state, slug); + const now = nowIso(); + const vote: PersonEvaluation = PersonEvaluationSchema.parse({ + personSlug: slug, + evaluator: humanEvaluatorFor(voter.slug), + verdict: input.verdict, + confidence: 1, + flags: ['manual-override'], + ...(input.reasoning && input.reasoning.trim() !== '' ? { reasoning: input.reasoning.trim() } : {}), + evaluatedAt: now, + }); + + await tx.public['person-evaluations'].upsert(vote); + const stateApply = new StateApply().upsertPersonEvaluation(vote); + + let person = existing; + if (input.verdict === 'spam' && !existing.deletedAt) { + person = PersonSchema.parse({ ...existing, deletedAt: now, updatedAt: now }); + } else if (input.verdict === 'legit' && existing.deletedAt && previousLatest === 'spam') { + person = PersonSchema.parse({ ...existing, deletedAt: null, updatedAt: now }); + } + if (person !== existing) { + await tx.public.people.upsert(person); + stateApply.upsertPerson(person); + } + + return { person, vote, stateApply }; + } +} + +function latestHumanVerdict(state: InMemoryState, personSlug: string): PersonEvaluation['verdict'] | null { + const keys = state.evaluationsByPerson.get(personSlug); + if (!keys) return null; + let latest: PersonEvaluation | null = null; + for (const key of keys) { + const rec = state.personEvaluations.get(key); + if (!rec || !isHumanEvaluator(rec.evaluator)) continue; + if (!latest || rec.evaluatedAt > latest.evaluatedAt) latest = rec; + } + return latest?.verdict ?? null; +} diff --git a/apps/api/src/store/memory/loader.ts b/apps/api/src/store/memory/loader.ts index 156c87e..d7e0ef0 100644 --- a/apps/api/src/store/memory/loader.ts +++ b/apps/api/src/store/memory/loader.ts @@ -12,6 +12,7 @@ import { indexHelpWantedRole, indexMembership, indexPerson, + indexPersonEvaluation, indexProject, indexProjectBuzz, indexProjectUpdate, @@ -44,6 +45,8 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise; + + /** + * Human spam votes from `person-evaluations` on the served branch, keyed + * `${personSlug}/${evaluator}`. Machine verdicts never reach the runtime + * (specs/behaviors/spam-exclusion.md → "What the runtime sees"). + */ + personEvaluations: Map; + /** personSlug → Set */ + evaluationsByPerson: Map>; } /** Compose the slug-history map key. Kept here so call sites stay consistent. */ @@ -155,6 +165,8 @@ export function createEmptyState(): InMemoryState { interestByRoleAndPerson: new Map(), interestByRole: new Map(), slugHistory: new Map(), + personEvaluations: new Map(), + evaluationsByPerson: new Map(), }; } @@ -300,3 +312,15 @@ export function indexHelpWantedInterest(state: InMemoryState, expr: HelpWantedIn const key = `${expr.roleId}:${expr.personId}`; state.interestByRoleAndPerson.set(key, expr.id); } + +/** Add or replace one person-evaluation record (one per person + evaluator). */ +export function indexPersonEvaluation(state: InMemoryState, record: PersonEvaluation): void { + const key = `${record.personSlug}/${record.evaluator}`; + state.personEvaluations.set(key, record); + let set = state.evaluationsByPerson.get(record.personSlug); + if (!set) { + set = new Set(); + state.evaluationsByPerson.set(record.personSlug, set); + } + set.add(key); +} diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index 3d752a7..fd3471e 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -8,6 +8,7 @@ import { HelpWantedInterestExpressionSchema, HelpWantedRoleSchema, PersonSchema, + PersonEvaluationSchema, ProjectBuzzSchema, ProjectMembershipSchema, ProjectSchema, @@ -22,6 +23,7 @@ import type { HelpWantedInterestExpression, HelpWantedRole, Person, + PersonEvaluation, ProjectBuzz, ProjectMembership, ProjectUpdate, @@ -117,6 +119,7 @@ type PublicValidators = { readonly 'tag-assignments': StandardSchemaV1; readonly 'slug-history': StandardSchemaV1; readonly revocations: StandardSchemaV1; + readonly 'person-evaluations': StandardSchemaV1; } & ValidatorMap; export type PublicStore = Store; @@ -167,6 +170,7 @@ export async function openPublicStore( 'tag-assignments': asValidator(TagAssignmentSchema), 'slug-history': asValidator(SlugHistorySchema), revocations: asValidator(RevocationSchema), + 'person-evaluations': asValidator(PersonEvaluationSchema), }; const store = (await openStore(repo, { validators })) as PublicStore; diff --git a/apps/api/src/store/state-apply.ts b/apps/api/src/store/state-apply.ts index 3bfcf12..03f0cdc 100644 --- a/apps/api/src/store/state-apply.ts +++ b/apps/api/src/store/state-apply.ts @@ -12,6 +12,7 @@ import type { HelpWantedInterestExpression, HelpWantedRole, Person, + PersonEvaluation, Project, ProjectBuzz, ProjectMembership, @@ -27,6 +28,7 @@ import { indexHelpWantedRole, indexMembership, indexPerson, + indexPersonEvaluation, indexProject, indexProjectBuzz, indexProjectUpdate, @@ -267,6 +269,11 @@ export class StateApply { return this; } + upsertPersonEvaluation(record: PersonEvaluation): this { + this.#ops.push((state) => indexPersonEvaluation(state, record)); + return this; + } + apply(state: InMemoryState, fts: FtsEngine): void { for (const op of this.#ops) { op(state, fts); diff --git a/apps/api/tests/helpers/test-full-repo.ts b/apps/api/tests/helpers/test-full-repo.ts index 7b7de4f..d834f0b 100644 --- a/apps/api/tests/helpers/test-full-repo.ts +++ b/apps/api/tests/helpers/test-full-repo.ts @@ -29,6 +29,7 @@ const SHEET_CONFIGS: Record = { 'tag-assignments': `[gitsheet]\nroot = 'tag-assignments'\npath = '\${{ tagId }}/\${{ taggableType }}/\${{ taggableId }}'\n`, 'slug-history': `[gitsheet]\nroot = 'slug-history'\npath = '\${{ entityType }}/\${{ oldSlug }}'\n`, 'revocations': `[gitsheet]\nroot = 'revocations'\npath = '\${{ jti }}'\n`, + 'person-evaluations': `[gitsheet]\nroot = 'person-evaluations'\npath = '\${{ personSlug }}/\${{ evaluator }}'\n`, }; export interface FullTestRepo { diff --git a/apps/api/tests/import-laddr.test.ts b/apps/api/tests/import-laddr.test.ts index 3b461e2..965b456 100644 --- a/apps/api/tests/import-laddr.test.ts +++ b/apps/api/tests/import-laddr.test.ts @@ -750,6 +750,7 @@ async function makeRepo(): Promise<{ path: string; cleanup: () => Promise ], ['slug-history', "root = 'slug-history'\npath = '${{ entityType }}/${{ slug }}'\n"], ['revocations', "root = 'revocations'\npath = '${{ jti }}'\n"], + ['person-evaluations', "root = 'person-evaluations'\npath = '${{ personSlug }}/${{ evaluator }}'\n"], ]; for (const [name, body] of sheets) { await writeFile(join(seedDir, '.gitsheets', `${name}.toml`), `[gitsheet]\n${body}`); diff --git a/apps/api/tests/internal-reload.test.ts b/apps/api/tests/internal-reload.test.ts index 85e754d..b14328c 100644 --- a/apps/api/tests/internal-reload.test.ts +++ b/apps/api/tests/internal-reload.test.ts @@ -54,6 +54,7 @@ const SHEET_CONFIGS: Record = { 'tag-assignments': `[gitsheet]\nroot = 'tag-assignments'\npath = '\${{ tagId }}/\${{ taggableType }}/\${{ taggableId }}'\n`, 'slug-history': `[gitsheet]\nroot = 'slug-history'\npath = '\${{ entityType }}/\${{ oldSlug }}'\n`, 'revocations': `[gitsheet]\nroot = 'revocations'\npath = '\${{ jti }}'\n`, + 'person-evaluations': `[gitsheet]\nroot = 'person-evaluations'\npath = '\${{ personSlug }}/\${{ evaluator }}'\n`, }; interface Rig { diff --git a/apps/api/tests/moderation.test.ts b/apps/api/tests/moderation.test.ts new file mode 100644 index 0000000..111151f --- /dev/null +++ b/apps/api/tests/moderation.test.ts @@ -0,0 +1,224 @@ +/** + * Moderation API — specs/api/moderation.md, specs/behaviors/person-lifecycle.md. + * + * Covers: + * - every endpoint 404s for anonymous and ordinary users (existence is not a signal) + * - the roster lists members newest first with staff-visible email and vote state + * - a spam vote records `human-` and deactivates in the same transaction + * - a legit vote after a spam vote reactivates; after a self-deactivation it does not + * - re-voting replaces the caller's record; self-votes are rejected + * - the `vote` filter and the detail endpoint's footprint + vote history + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { execFile } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +const STAFF_ID = '01951a3c-0000-7000-8000-0000000000a1'; +const ADMIN_ID = '01951a3c-0000-7000-8000-0000000000a2'; +const USER_ID = '01951a3c-0000-7000-8000-0000000000b1'; +const NEW_ID = '01951a3c-0000-7000-8000-0000000000b2'; +const SELFOFF_ID = '01951a3c-0000-7000-8000-0000000000b3'; + +async function seedPerson( + repoDir: string, + opts: { slug: string; id: string; accountLevel?: string; createdAt: string; deletedAt?: string; bio?: string; githubUserId?: number }, +): Promise { + const lines = [ + `id = "${opts.id}"`, + `slug = "${opts.slug}"`, + `fullName = "Test ${opts.slug}"`, + `accountLevel = "${opts.accountLevel ?? 'user'}"`, + opts.bio ? `bio = "${opts.bio}"` : '', + typeof opts.githubUserId === 'number' ? `githubUserId = ${opts.githubUserId}` : '', + opts.deletedAt ? `deletedAt = "${opts.deletedAt}"` : '', + `createdAt = "${opts.createdAt}"`, + `updatedAt = "${opts.createdAt}"`, + ].filter(Boolean); + await seedRawToml(repoDir, `people/${opts.slug}.toml`, lines.join('\n'), `seed person ${opts.slug}`); +} + +describe('moderation API', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + let staffCookie: string; + let adminCookie: string; + let userCookie: string; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, { slug: 'staffer', id: STAFF_ID, accountLevel: 'staff', createdAt: '2026-01-01T00:00:00Z' }); + await seedPerson(dataRepo.path, { slug: 'boss', id: ADMIN_ID, accountLevel: 'administrator', createdAt: '2026-01-02T00:00:00Z' }); + await seedPerson(dataRepo.path, { slug: 'regular', id: USER_ID, createdAt: '2026-02-01T00:00:00Z', githubUserId: 42 }); + await seedPerson(dataRepo.path, { slug: 'newest', id: NEW_ID, createdAt: '2026-09-01T00:00:00Z', bio: 'Buy **cheap** [pills](https://x.example) now' }); + await seedPerson(dataRepo.path, { slug: 'selfoff', id: SELFOFF_ID, createdAt: '2026-03-01T00:00:00Z', deletedAt: '2026-04-01T00:00:00Z' }); + + const profiles = [ + { personId: NEW_ID, email: 'newest@example.org' }, + { personId: USER_ID, email: 'regular@example.org' }, + ].map((p) => + JSON.stringify({ + ...p, + emailRefreshedAt: '2026-05-01T00:00:00.000Z', + newsletter: { optedIn: false, optedInAt: null, optedOutAt: null, unsubscribeToken: null }, + updatedAt: '2026-05-01T00:00:00.000Z', + }), + ); + await writeFile(join(privateStore.path, 'profiles.jsonl'), profiles.join('\n') + '\n'); + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + + staffCookie = (await mintSessionFor(STAFF_ID, 'staff', JWT_KEY)).accessToken; + adminCookie = (await mintSessionFor(ADMIN_ID, 'administrator', JWT_KEY)).accessToken; + userCookie = (await mintSessionFor(USER_ID, 'user', JWT_KEY)).accessToken; + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + const asStaff = (cookie: string) => ({ cookies: { cfp_session: cookie } }); + + it('404s for anonymous and ordinary users on all three endpoints', async () => { + for (const cookies of [{}, asStaff(userCookie)]) { + const list = await app.inject({ method: 'GET', url: '/api/admin/members', ...cookies }); + expect(list.statusCode).toBe(404); + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...cookies }); + expect(detail.statusCode).toBe(404); + const vote = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'spam' }, + ...cookies, + }); + expect(vote.statusCode).toBe(404); + } + }); + + it('lists members newest first with staff-visible fields and no vote yet', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members', ...asStaff(staffCookie) }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: Array>; metadata: { totalItems: number } }>(); + expect(body.metadata.totalItems).toBe(5); + expect(body.data[0]?.['slug']).toBe('newest'); + const newest = body.data[0]!; + expect(newest['email']).toBe('newest@example.org'); + expect(newest['latestVote']).toBeNull(); + expect(newest['bioExcerpt']).toBe('Buy cheap pills now'); + const regular = body.data.find((r) => r['slug'] === 'regular')!; + expect(regular['hasGitHubLink']).toBe(true); + // Deactivated members are included by default — moderation needs to see what it hid. + expect(body.data.some((r) => r['slug'] === 'selfoff')).toBe(true); + }); + + it('spam vote records human- and deactivates in the same transaction', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'spam', reasoning: 'pharma bio' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { person: { deletedAt: string | null }; vote: { evaluator: string; confidence: number }; latestVote: { verdict: string; voter: { slug: string } } } }>(); + expect(body.data.vote.evaluator).toBe('human-staffer'); + expect(body.data.vote.confidence).toBe(1); + expect(body.data.person.deletedAt).not.toBeNull(); + expect(body.data.latestVote.verdict).toBe('spam'); + expect(body.data.latestVote.voter.slug).toBe('staffer'); + + // Hidden from the public detail endpoint now. + const pub = await app.inject({ method: 'GET', url: '/api/people/newest' }); + expect(pub.statusCode).toBe(404); + + // The record landed on the data repo as a committed file, authored by the voter. + const tree = await execFileAsync('git', ['ls-tree', '-r', '--name-only', 'HEAD', 'person-evaluations/'], { + cwd: dataRepo.path, + }); + expect(tree.stdout.trim().split('\n')).toEqual(['person-evaluations/newest/human-staffer.toml']); + const author = await execFileAsync('git', ['log', '-1', '--format=%an <%ae>'], { cwd: dataRepo.path }); + expect(author.stdout.trim()).toBe('Test staffer '); + }); + + it('vote filter and detail endpoint expose the vote history and footprint', async () => { + const spamOnly = await app.inject({ method: 'GET', url: '/api/admin/members?vote=spam', ...asStaff(adminCookie) }); + expect(spamOnly.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).toEqual(['newest']); + + const none = await app.inject({ method: 'GET', url: '/api/admin/members?vote=none', ...asStaff(adminCookie) }); + expect(none.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).not.toContain('newest'); + + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...asStaff(adminCookie) }); + expect(detail.statusCode).toBe(200); + const d = detail.json<{ data: { person: { slug: string; email: string | null }; footprint: Record; votes: Array<{ verdict: string; reasoning: string | null }> } }>().data; + expect(d.person.slug).toBe('newest'); + expect(d.person.email).toBe('newest@example.org'); + expect(d.footprint.memberships).toEqual([]); + expect(d.votes).toHaveLength(1); + expect(d.votes[0]).toMatchObject({ verdict: 'spam', reasoning: 'pharma bio' }); + }); + + it('a legit vote after a spam vote reactivates; re-voting replaces the record', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/newest/vote', + payload: { verdict: 'legit' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { person: { deletedAt: string | null } } }>().data.person.deletedAt).toBeNull(); + + const detail = await app.inject({ method: 'GET', url: '/api/admin/members/newest', ...asStaff(staffCookie) }); + const votes = detail.json<{ data: { votes: Array<{ verdict: string; voter: { slug: string } }> } }>().data.votes; + expect(votes).toHaveLength(1); + expect(votes[0]).toMatchObject({ verdict: 'legit', voter: { slug: 'staffer' } }); + }); + + it('a legit vote never undoes a self-deactivation', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/selfoff/vote', + payload: { verdict: 'legit' }, + ...asStaff(adminCookie), + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { person: { deletedAt: string | null } } }>().data.person.deletedAt).not.toBeNull(); + }); + + it('rejects voting on your own account', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/staffer/vote', + payload: { verdict: 'legit' }, + ...asStaff(staffCookie), + }); + expect(res.statusCode).toBe(422); + }); + + it('rejects an unknown sort key', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members?sort=bogus', ...asStaff(staffCookie) }); + expect(res.statusCode).toBe(422); + }); +}); diff --git a/apps/api/tests/reload-swap.test.ts b/apps/api/tests/reload-swap.test.ts index b018837..761d2d4 100644 --- a/apps/api/tests/reload-swap.test.ts +++ b/apps/api/tests/reload-swap.test.ts @@ -30,6 +30,7 @@ import { createEmptyState, indexBlogPost, indexHelpWantedInterest, + indexPersonEvaluation, indexHelpWantedRole, indexMembership, indexPerson, @@ -159,6 +160,14 @@ function buildState(base: number, slugs: { project: string; buzz: string; oldSlu indexHelpWantedRole(state, role); indexHelpWantedInterest(state, makeInterest(base + 10, role.id, person.id)); indexSlugHistory(state, makeSlugHistory(base + 11, project.id, slugs.oldSlug, slugs.project)); + indexPersonEvaluation(state, { + personSlug: person.slug, + evaluator: 'human-voter' + base, + verdict: 'legit', + confidence: 1, + flags: [], + evaluatedAt: '2026-05-01T00:00:00Z', + }); return state; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e794b09..886063a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -35,6 +35,7 @@ import { AccountClaimByPassword } from '@/pages/AccountClaimByPassword'; import { AccountClaimRequestStaffReview } from '@/pages/AccountClaimRequestStaffReview'; import { AccountClaimLegacy } from '@/pages/AccountClaimLegacy'; import { StaffAccountClaimQueue } from '@/pages/StaffAccountClaimQueue'; +import { AdminMembers } from '@/pages/AdminMembers'; const router = createBrowserRouter([ { @@ -74,6 +75,8 @@ const router = createBrowserRouter([ { path: '/account-claim/request-staff-review', element: }, { path: '/account/claim-legacy', element: }, { path: '/staff/account-claim', element: }, + { path: '/admin/members', element: }, + { path: '/admin/members/:slug', element: }, { path: '*', element: }, ], }, diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 2a303e3..f76d939 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -127,6 +127,9 @@ function AuthControls({ mobile = false }: { mobile?: boolean }) { Manage tags + + Members roster + Recent staff actions diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 74174d2..175a875 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -580,6 +580,70 @@ export interface UpdateTagInput { mergeInto?: string; } +// --- Moderation (specs/api/moderation.md) --------------------------------- + +export type VoteVerdict = 'spam' | 'legit'; + +export interface VoteView { + readonly verdict: 'spam' | 'legit' | 'uncertain'; + readonly reasoning: string | null; + readonly voter: { readonly slug: string; readonly fullName: string }; + readonly evaluatedAt: string; +} + +export interface MemberRow { + readonly id: string; + readonly slug: string; + readonly fullName: string; + readonly avatarUrl: string | null; + readonly createdAt: string; + readonly deletedAt: string | null; + readonly email: string | null; + readonly hasGitHubLink: boolean; + readonly lastLoginAt: string | null; + readonly bioExcerpt: string; + readonly footprint: { + readonly memberships: number; + readonly updates: number; + readonly buzz: number; + readonly blogPosts: number; + readonly helpWantedInterest: number; + readonly tags: number; + }; + readonly latestVote: VoteView | null; +} + +export interface MemberListParams { + q?: string; + vote?: 'none' | VoteVerdict; + joinedAfter?: string; + joinedBefore?: string; + includeDeactivated?: boolean; + sort?: string; + page?: number; + perPage?: number; +} + +interface ProjectRef { + readonly slug: string; + readonly title: string; +} + +export interface MemberFootprint { + readonly memberships: Array<{ project: ProjectRef; role: string; joinedAt: string }>; + readonly updates: Array<{ project: ProjectRef; number: number; title: string; postedAt: string }>; + readonly buzz: Array<{ project: ProjectRef; slug: string; title: string; postedAt: string }>; + readonly blogPosts: Array<{ slug: string; title: string; postedAt: string }>; + readonly helpWantedInterest: Array<{ project: ProjectRef; role: { title: string }; createdAt: string }>; + readonly tags: Array<{ handle: string; type: string }>; +} + +export interface MemberDetail { + readonly person: PersonDetail; + readonly footprint: MemberFootprint; + readonly votes: VoteView[]; +} + export const api = { preview: (source: string): Promise> => request(`/api/_preview`, { @@ -831,6 +895,21 @@ export const api = { body: JSON.stringify({ claimedSlug, evidence }), }), }, + admin: { + members: (params: MemberListParams = {}): Promise> => + request(`/api/admin/members${buildQuery(params)}`), + member: (slug: string): Promise> => + request(`/api/admin/members/${encodeURIComponent(slug)}`), + vote: ( + slug: string, + verdict: VoteVerdict, + reasoning?: string, + ): Promise> => + request(`/api/admin/members/${encodeURIComponent(slug)}/vote`, { + method: 'POST', + body: JSON.stringify(reasoning ? { verdict, reasoning } : { verdict }), + }), + }, staffAccountClaim: { queue: (): Promise> => request(`/api/staff/account-claim/queue`), diff --git a/apps/web/src/pages/AdminMembers.tsx b/apps/web/src/pages/AdminMembers.tsx new file mode 100644 index 0000000..bd97655 --- /dev/null +++ b/apps/web/src/pages/AdminMembers.tsx @@ -0,0 +1,414 @@ +/** + * /admin/members — staff roster with footprint and human spam votes. + * Per specs/screens/admin-members.md. + */ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useAuth } from '@/hooks/useAuth'; +import { api, ApiError, type MemberListParams, type MemberRow, type VoteVerdict, type VoteView } from '@/lib/api'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; + +const PER_PAGE = 50; + +function isStaff(level: string | undefined): boolean { + return level === 'staff' || level === 'administrator'; +} + +function VoteBadge({ vote, hiddenByVote }: { vote: VoteView; hiddenByVote: boolean }) { + const spam = vote.verdict === 'spam'; + return ( + + {spam ? 'Spam' : 'Not spam'} · {vote.voter.fullName} · {formatRelativeTime(vote.evaluatedAt)} + {hiddenByVote ? ' · hidden' : ''} + + ); +} + +function VoteButtons({ + slug, + disabled, + onDone, +}: { + slug: string; + disabled: boolean; + onDone: () => Promise; +}) { + const [confirming, setConfirming] = useState(null); + const [reasoning, setReasoning] = useState(''); + const mutation = useMutation({ + mutationFn: ({ verdict, why }: { verdict: VoteVerdict; why: string }) => + api.admin.vote(slug, verdict, why.trim() || undefined), + onSuccess: async (_res, vars) => { + toast.success(vars.verdict === 'spam' ? 'Marked as spam and hidden' : 'Marked as not spam'); + setConfirming(null); + setReasoning(''); + await onDone(); + }, + onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Vote failed'), + }); + + if (confirming) { + const submit = (e: FormEvent) => { + e.preventDefault(); + mutation.mutate({ verdict: confirming, why: reasoning }); + }; + return ( +
+ +