feat(moderation): member-raised abuse reports, to site staff only

TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the
`moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or
Discord-sanction-shaped, and nothing anywhere let a member say "this is a
problem". That was survivable while every piece of content on the site came from
staff; phase 5 lets players write to each other, so it stops being.

The gap 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. So the
whole point of this queue is a path that routes AROUND a Team's own leadership.
Org lead settled it on 2026-08-18: **reports are site administration only** —
there is no leader-facing view of this queue, not even a read-only one scoped to
their own Team. §5.6's "a leader may also see and act on reports for their own
Team" is not implemented and is not deferred.

`content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki
page or a news comment becomes a value rather than a table — and the queue is
mounted beside appeals under /admin/moderation rather than under Teams, because a
staffer working a queue should have one place to work.

**§5.6's literal unique key has a defect and this does not copy it.** Written as
(target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide
with each other too: reporter reports a post, staff dismiss it, the behaviour
recurs, they report again — and the second dismissal is an UPDATE into a tuple
that already exists, so working the queue starts throwing duplicate-key errors on
the first repeat reporter. The key is on a generated `open_marker` instead, the
same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once
closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open
report per (target, reporter)".

Two other departures from the doc, both small and both flagged in the docs PR:
`handled_note`, because a queue whose resolution reason lives only in an
activity_log line is one where the next staffer to see a repeat report cannot
find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted
Team does not leave a queue full of reports about content that no longer exists.

Also here: a report is filed against a target the model verifies really belongs to
the Team the request came through, or the queue's per-Team filter would quietly be
lying; the queue resolves every row's target in three batched reads rather than
N+1, which is §5.6's fourth rule (uploader, size and sniffed type without
hunting) actually paying for §5.5.4's attribution table; a target that has since
been hard-deleted comes back null and the report still lists, because "somebody
reported this and by the time we looked it was gone" is a fact a moderator needs;
and every transition writes activity_log, `dismissed` included — a queue where
acting is audited and declining to act is not is one where the cheapest way to
make a report vanish leaves no trace.

`teams_forum_edit_window_minutes` gains its range validation on the admin settings
PUT and is seeded at 15, so the value on the settings screen is the value in
force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:51:42 -05:00
parent ae0d27cf27
commit fff14848f1
13 changed files with 1972 additions and 6 deletions

View File

@@ -0,0 +1,231 @@
// ── 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 }) }))
}
function describeTarget(report, { threadMap, postMap, uploadMap }) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
return t && {
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)
return p && {
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)
// §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 u && {
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,
}