// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ────────── // // Two rules shape everything in this file, and both are easier to break than to // notice broken: // // 1. **A report is not a moderation action.** Filing one changes nothing about // the content — it opens a queue item. That keeps it clear of §5.3's // leader/staff moderation ledger, which records things that actually // happened. If reporting hid a post, reporting would BE moderation, and the // first person to work that out would have found a way to hide anything. // // 2. **Reports go to site staff and to nobody else.** The gap §5.6 exists to // close has a specific shape: leaders moderate their own Team's forum, and a // Team's leaders are exactly the people who will not report their own Team. // A leader-visible queue would route a complaint about a leader back to that // leader. The org lead settled this on 2026-08-18 — reports are **site // administration only**, with no leader-facing view at all, not even a // read-only one scoped to their own Team. // // The reporter's ACCESS is the caller's business, not this file's: the player // route resolves the forum first, so anyone reaching `file()` is someone who can // already see the thing they are reporting. What this file does check is that the // target is really in the Team the caller reached it through — otherwise a // participant in one Team could file reports carrying another Team's id, and the // queue's per-Team filter would quietly be lying. const reportsDb = require('./contentReports.db') const forumDb = require('../teams/teamForum.db') const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload'] const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'] const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed'] // A body excerpt for the queue, not a rendered post. Staff triage on what was // written, and `body_html` is stored already sanitised — but the queue is a list, // so it gets text and a length cap rather than markup. const EXCERPT_CHARS = 300 const excerpt = (html) => String(html || '') .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, EXCERPT_CHARS) /** * Does this target exist, and is it in this Team? * * Returns the team id the target really belongs to, or null. The caller compares * it with the Team the request came through — a mismatch is a 404 for the same * §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else * on the site is itself a disclosure. */ async function targetTeamId(targetType, targetId) { if (targetType === 'team_forum_thread') { const thread = await forumDb.threadById(targetId) return thread ? thread.team_id : null } if (targetType === 'team_forum_post') { const post = await forumDb.postById(targetId) if (!post) return null const thread = await forumDb.threadById(post.thread_id) return thread ? thread.team_id : null } if (targetType === 'team_forum_upload') { const upload = await forumDb.uploadById(targetId) return upload ? upload.team_id : null } return null } /** * File a report. * * A duplicate answers 409 rather than pretending to succeed. Silently accepting * it would be friendlier for one tap and dishonest for the second: a member who * reports twice because nothing seemed to happen deserves to be told the first * one is already in the queue. */ async function file({ team, actor, targetType, targetId, reason, detail }) { if (!TARGET_TYPES.includes(targetType)) { return { ok: false, status: 400, error: 'Unknown report target' } } if (!REASONS.includes(reason)) { return { ok: false, status: 400, error: 'Unknown report reason' } } const owner = await targetTeamId(targetType, targetId) if (owner == null || owner !== team.id) { return { ok: false, status: 404, error: 'Not found' } } const id = await reportsDb.insert({ targetType, targetId, teamId: team.id, reporterUserId: actor.id, reporterUsername: actor.username, reason, detail, }) if (id == null) { return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' } } return { ok: true, reportId: id } } /** * The staff queue, with each row's target attached. * * Enrichment is three batched reads keyed by target type, not one read per row. * The alternative N+1s a page of a hundred into three hundred queries, which is * how a queue becomes a thing staff avoid opening. * * A target that has since been hard-deleted comes back as `null`, and the report * still lists. That is deliberate: "somebody reported this and by the time we * looked it was gone" is a fact a moderator needs, and dropping the row would * hide the pattern of a member deleting their own content the moment it is * reported. */ async function queue({ status, teamId, limit, offset } = {}) { const rows = await reportsDb.list({ status, teamId, limit, offset }) if (!rows.length) return [] const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id)) const [threads, posts, uploads] = await Promise.all([ reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]), reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]), reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]), ]) const byId = (list) => new Map(list.map((row) => [Number(row.id), row])) const threadMap = byId(threads) const postMap = byId(posts) const uploadMap = byId(uploads) return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) })) } /** * The reported content, resolved. * * **Every miss returns `null`, never `undefined`.** They look interchangeable in * JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so * a hard-deleted target would reach the client as an ABSENT `target` key rather * than as an explicit null, and the queue's own contract says nullable. A client * distinguishing "gone" from "not resolved yet" would get it wrong. */ function describeTarget(report, { threadMap, postMap, uploadMap }) { const id = Number(report.target_id) if (report.target_type === 'team_forum_thread') { const t = threadMap.get(id) if (!t) return null return { kind: 'thread', threadId: t.id, title: t.title, type: t.type, status: t.status, author: t.created_username, } } if (report.target_type === 'team_forum_post') { const p = postMap.get(id) if (!p) return null return { kind: 'post', postId: p.id, threadId: p.thread_id, threadTitle: p.thread_title, author: p.author_username, status: p.status, excerpt: excerpt(p.body_html), createdAt: p.created_at, } } if (report.target_type === 'team_forum_upload') { const u = uploadMap.get(id) if (!u) return null // §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting. // This is the payoff for §5.5.4's attribution table being load-bearing rather // than bookkeeping. return { kind: 'upload', uploadId: u.id, postId: u.post_id, uploader: u.uploader_username, filename: u.filename, url: `/uploads/${u.filename}`, mimetype: u.mimetype, byteSize: u.byte_size, createdAt: u.created_at, deleted: u.deleted_at != null, } } return null } function publicReport(row) { return { id: row.id, targetType: row.target_type, targetId: Number(row.target_id), teamId: row.team_id, reporter: row.reporter_username || '[deleted account]', reporterDeleted: row.reporter_user_id == null, reason: row.reason, detail: row.detail, status: row.status, handledBy: row.handled_username, handledNote: row.handled_note, handledAt: row.handled_at, createdAt: row.created_at, } } /** Move a report along the queue. Staff-only by its route. */ async function handle({ id, actor, status, note }) { if (!STATUSES.includes(status)) { return { ok: false, status: 400, error: 'Unknown report status' } } const report = await reportsDb.byId(id) if (!report) return { ok: false, status: 404, error: 'Report not found' } await reportsDb.handle(id, { status, handledBy: actor.id, handledUsername: actor.username, note, }) return { ok: true, report: publicReport(await reportsDb.byId(id)) } } module.exports = { TARGET_TYPES, REASONS, STATUSES, EXCERPT_CHARS, file, queue, handle, openCount: reportsDb.openCount, publicReport, targetTeamId, }