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:
@@ -612,6 +612,20 @@ async function updateSettings(req, res) {
|
||||
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
||||
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
|
||||
}
|
||||
if (forumSettings.EDIT_WINDOW_KEY in updates) {
|
||||
// The post edit window (phase 5). An ordinary key with a range, validated
|
||||
// here rather than left to the model's read-side clamp: a read that silently
|
||||
// coerces a nonsense value back to the default is right for a hand-edited
|
||||
// row and wrong for an admin who just typed one, who should be told.
|
||||
const raw = updates[forumSettings.EDIT_WINDOW_KEY]
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 0 || n > forumSettings.EDIT_WINDOW_MAX) {
|
||||
return res.status(400).json({
|
||||
message: `teams_forum_edit_window_minutes must be a whole number of minutes between 0 and ${forumSettings.EDIT_WINDOW_MAX}`,
|
||||
})
|
||||
}
|
||||
updates[forumSettings.EDIT_WINDOW_KEY] = String(n)
|
||||
}
|
||||
{
|
||||
// The stale-acknowledgement lock: a reworded notice freezes the forum
|
||||
// settings until it is re-given, and does NOT turn uploads off (§5.5.5).
|
||||
|
||||
@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
@@ -295,6 +296,68 @@ async function getUserAppeals(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
|
||||
//
|
||||
// Mounted here rather than under Teams, and that placement is the design: a
|
||||
// staffer working a queue should have one place to work, and a report about a
|
||||
// forum post is the same job as a report about anything else. `target_type` is a
|
||||
// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives
|
||||
// as a value in this same queue and not as a second screen.
|
||||
//
|
||||
// **This is the only view of the queue that exists.** Team leaders have no
|
||||
// report-facing surface at all, because the gap §5.6 closes is that a Team's
|
||||
// leaders are exactly the people who will not report their own Team. Org lead,
|
||||
// 2026-08-18: reports are site administration only.
|
||||
|
||||
async function getContentReports(req, res) {
|
||||
try {
|
||||
const { limit, offset } = pageParams(req)
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : undefined
|
||||
if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) {
|
||||
return res.status(400).json({ message: 'Unknown report status' })
|
||||
}
|
||||
const teamId = Number(req.query.teamId) || undefined
|
||||
return res.json({
|
||||
reports: await contentReports.queue({ status, teamId, limit, offset }),
|
||||
openCount: await contentReports.openCount(),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('getContentReports failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a report along the queue.
|
||||
*
|
||||
* Every transition writes `activity_log`, including `dismissed` — especially
|
||||
* `dismissed`. A queue where acting is audited and declining to act is not is one
|
||||
* where the cheapest way to make a report disappear leaves no trace, and the
|
||||
* reports most worth auditing are exactly the ones somebody wanted gone.
|
||||
*/
|
||||
async function handleContentReport(req, res) {
|
||||
try {
|
||||
const result = await contentReports.handle({
|
||||
id: Number(req.params.id),
|
||||
actor: req.user,
|
||||
status: req.body.status,
|
||||
note: req.body.note,
|
||||
})
|
||||
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.report.handle',
|
||||
detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}`
|
||||
+ `${req.body.note ? `: "${req.body.note}"` : ''}`,
|
||||
})
|
||||
return res.json(result.report)
|
||||
} catch (err) {
|
||||
log.error('handleContentReport failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSummary,
|
||||
getRecent,
|
||||
@@ -311,4 +374,6 @@ module.exports = {
|
||||
claimAppeal,
|
||||
resolveAppeal,
|
||||
getUserAppeals,
|
||||
getContentReports,
|
||||
handleContentReport,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Admin · Moderation — the moderation dashboard and the appeals queue.
|
||||
// Admin · Moderation — the moderation dashboard, the appeals queue and the
|
||||
// member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
|
||||
@@ -16,6 +17,7 @@ const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const moderation = require('./moderation.controller')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
@@ -171,4 +173,34 @@ moderationRouter.get(
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
|
||||
// Beside appeals rather than under Teams: a staffer working a queue should have
|
||||
// one place to work. There is no leader-facing counterpart to these two routes
|
||||
// and there is not meant to be — see the controller.
|
||||
moderationRouter.get(
|
||||
'/reports',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'The member-raised content report queue'
|
||||
// #swagger.description = 'Defaults to the open work (`open` + `reviewing`); filter with ?status=<open|reviewing|actioned|dismissed|all> and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a post’s excerpt and author, a thread’s title, or an upload’s uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: "somebody reported this and by the time we looked it was gone" is a fact worth seeing.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The queue', content: { "application/json": { schema: { type: 'object', properties: { reports: { type: 'array', items: { $ref: "#/components/schemas/ContentReport" } }, openCount: { type: 'integer' } } } } } } */
|
||||
moderation.getContentReports,
|
||||
)
|
||||
moderationRouter.post(
|
||||
'/reports/:id/handle',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Claim, action or dismiss a content report'
|
||||
// #swagger.description = 'Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. 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.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['open','reviewing','actioned','dismissed'] }, note: { type: 'string', maxLength: 500 } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The updated report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Report not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('status').isIn(contentReports.STATUSES),
|
||||
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
moderation.handleContentReport,
|
||||
)
|
||||
|
||||
module.exports = moderationRouter
|
||||
|
||||
Reference in New Issue
Block a user