Files
website/server/src/model/reports/contentReports.db.js
wtclaude fff14848f1 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>
2026-08-18 12:51:42 -05:00

155 lines
5.2 KiB
JavaScript

// SQL for `content_reports` (TEAMS.md §5.6).
//
// Not under model/teams/ even though Team forum content is its only consumer
// today: the table is deliberately generic — `target_type` is a VARCHAR so that a
// wiki page or a news comment becomes a new value rather than a new table — and
// filing it under a feature it will outgrow is how the next consumer ends up
// building its own.
//
// Nothing here decides who may read a report. That is the route's job, and there
// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling
// that reports are site administration only).
const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
const OPEN_STATUSES = ['open', 'reviewing']
/**
* File a report.
*
* The duplicate is caught by the unique key rather than by a SELECT first, which
* is the difference between "usually not a duplicate" and "never a duplicate":
* two taps of a report button race, and only the index settles it. ER_DUP_ENTRY
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
)
return res.insertId
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null
throw err
}
}
async function byId(id) {
const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* The queue.
*
* `status` defaults to the two OPEN statuses rather than to everything: a staffer
* opening the queue wants the work, not the archive. 'all' is the explicit escape
* hatch and every single status is selectable, so nothing is unreachable.
*/
async function list({ status, teamId, limit = 100, offset = 0 } = {}) {
const where = []
const args = []
if (status && status !== 'all') {
where.push('status = ?')
args.push(status)
} else if (!status) {
where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`)
args.push(...OPEN_STATUSES)
}
if (teamId) {
where.push('team_id = ?')
args.push(teamId)
}
args.push(limit, offset)
return query(
`SELECT ${COLUMNS} FROM content_reports
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
args,
)
}
/** How many are waiting, for the dashboard badge. */
async function openCount() {
const rows = await query(
`SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`,
OPEN_STATUSES,
)
return Number(rows[0]?.n || 0)
}
/**
* Record a staffer's decision.
*
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
)
return res.affectedRows > 0
}
// ── target enrichment ──────────────────────────────────────────────────────
//
// Three batched reads rather than one per row. §5.6's fourth rule — "reports on
// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and
// sniffed type without hunting" — is the reason the queue enriches at all, and a
// queue that N+1s to do it would be the version that gets turned off.
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function postsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status,
p.created_at, t.team_id, t.title AS thread_title
FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id
WHERE p.id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function uploadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename,
mimetype, byte_size, created_at, deleted_at
FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
module.exports = {
OPEN_STATUSES,
insert,
byId,
list,
openCount,
handle,
threadsByIds,
postsByIds,
uploadsByIds,
}