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:
@@ -24,6 +24,7 @@ const grants = require('../../../model/teams/teamGrants.model')
|
||||
const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const uploads = require('../../../model/teams/teamForumUploads.model')
|
||||
const reports = require('../../../model/reports/contentReports.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
@@ -359,6 +360,45 @@ async function revokeGrant(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* File a report about a thread, a post or an upload.
|
||||
*
|
||||
* **This is the one write in this file that does nothing to the content.** A
|
||||
* report opens a queue item and changes no status, no flag and no counter — which
|
||||
* is what keeps it out of §5.3's moderation ledger, and what stops "report" from
|
||||
* becoming a way for any participant to hide anything.
|
||||
*
|
||||
* It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders
|
||||
* moderate their own Team and a Team's leaders are exactly the people who will
|
||||
* not report their own Team, so a leader-visible queue would hand a complaint
|
||||
* about a leader straight back to them. There is deliberately no leader-facing
|
||||
* view anywhere in this phase (org lead, 2026-08-18).
|
||||
*
|
||||
* The route sits behind the same `resolveForum` guard as everything else, so a
|
||||
* reporter is by construction someone who can already see what they are
|
||||
* reporting — and the model additionally checks the target really belongs to the
|
||||
* Team the request came through, or the queue's per-Team filter would be lying.
|
||||
*/
|
||||
async function createReport(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
return send(res, await reports.file({
|
||||
team: ctx.team,
|
||||
actor: req.user,
|
||||
targetType: req.body.targetType,
|
||||
targetId: Number(req.body.targetId),
|
||||
reason: req.body.reason,
|
||||
detail: req.body.detail,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create report')
|
||||
}
|
||||
}
|
||||
|
||||
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -409,4 +449,5 @@ module.exports = {
|
||||
revokeGrant,
|
||||
createUpload,
|
||||
deleteUpload,
|
||||
createReport,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teamForum.controller')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { makeLimiter } = require('../../../middleware/rateLimit')
|
||||
const { upload } = require('../admin/imageUpload')
|
||||
@@ -40,6 +41,17 @@ const grantLimiter = makeLimiter({
|
||||
message: 'Too many grant changes. Please slow down.',
|
||||
})
|
||||
|
||||
// Tightest of the three, and §5.6's third rule is why: a report costs the
|
||||
// reporter nothing and costs a staffer attention, so the queue is the one surface
|
||||
// here that can be used as a harassment tool. The unique key already stops
|
||||
// duplicate open reports on one target; this stops a spread of them.
|
||||
const reportLimiter = makeLimiter({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 10,
|
||||
label: 'team-forum-report',
|
||||
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
|
||||
})
|
||||
|
||||
// Bytes, not requests: the per-account daily quota lives in the uploads model,
|
||||
// and this is the per-IP flood guard in front of it.
|
||||
const uploadLimiter = makeLimiter({
|
||||
@@ -219,6 +231,28 @@ forumRouter.delete(
|
||||
ctrl.revokeGrant,
|
||||
)
|
||||
|
||||
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/report',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Report a thread, post or upload to site staff'
|
||||
// #swagger.description = 'The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Team’s moderation ledger and stops "report" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Team’s leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['targetType','targetId','reason'], properties: { targetType: { type: 'string', enum: ['team_forum_thread','team_forum_post','team_forum_upload'] }, targetId: { type: 'integer' }, reason: { type: 'string', enum: ['spam','abuse','sexual','illegal','impersonation','other'] }, detail: { type: 'string', maxLength: 500 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no access, or the target is not in this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
reportLimiter,
|
||||
body('targetType').isIn(contentReports.TARGET_TYPES),
|
||||
body('targetId').isInt({ min: 1 }).toInt(),
|
||||
body('reason').isIn(contentReports.REASONS),
|
||||
body('detail').optional().isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
ctrl.createReport,
|
||||
)
|
||||
|
||||
// ── uploads ────────────────────────────────────────────────────────────────
|
||||
|
||||
forumRouter.post(
|
||||
|
||||
Reference in New Issue
Block a user