feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155

Merged
whitlocktech merged 5 commits from feature/teams-phase5-discussion into edge 2026-08-18 18:36:52 +00:00
13 changed files with 1972 additions and 6 deletions
Showing only changes of commit fff14848f1 - Show all commits

View File

@@ -1134,6 +1134,69 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
INDEX idx_tfu_sweep (deleted_at) INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of
-- any kind before this**: `moderation`, `mod_notes` and `appeals` 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. It stops being survivable the moment a Team forum
-- lets players write to each other, and stops twice over when `uploads` mode lets
-- them put files on the operator's disk under a signed liability acknowledgement.
--
-- The gap has a specific shape worth naming: leaders moderate their own Team's
-- forum, and a Team's leaders are exactly the people who will not report their own
-- Team. So this table's whole point is a path that routes AROUND a Team's own
-- leadership — **reports go to site staff and to nobody else.** There is
-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a
-- leader-visible report about a leader is not a report.
--
-- Not a `team_*` table, and not named for the forum: `target_type` is a plain
-- VARCHAR so wiki pages, news comments and profile fields become new values
-- rather than new tables. Team forum content is only the first consumer.
--
-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key
-- as (target_type, target_id, reporter_user_id, status), and that spelling has a
-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide
-- with each other too. A reporter reports a post, staff dismiss it, the behaviour
-- recurs, they report it again — and the second dismissal is an UPDATE into a
-- (…, 'dismissed') tuple that already exists, so working the queue would start
-- throwing duplicate-key errors after the first repeat reporter.
--
-- The generated marker is the same trick `team_forum_grants.active_marker` uses:
-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats
-- NULLs as distinct, so any number of closed reports coexist while at most one
-- open one can. That is what §5.6's prose actually asks for — "one open report per
-- (target, reporter)".
--
-- NULL reporters (deleted accounts) are distinct for the same reason, which is
-- also wanted: nothing should collapse two dead accounts' reports into one.
--
-- `handled_note` is not in the design doc and earns its place: 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 one was dismissed.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_username VARCHAR(32) NULL, -- snapshot, same reason
handled_note VARCHAR(500) NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED,
CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker),
INDEX idx_cr_queue (status, created_at),
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that -- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN -- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a -- performing one applies it immediately. Rows are kept after a decision — "a
@@ -1233,6 +1296,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- so the system behaves exactly as today until an admin opts in. -- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather
-- than left absent so the value an operator sees on the settings screen is the
-- value in force — an empty field that silently behaves as 15 is a field nobody
-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs.
INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;

View File

@@ -345,6 +345,26 @@
"requireAuth" "requireAuth"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/search", "path": "/api/v1/admin/moderation/search",
@@ -1687,6 +1707,39 @@
"requireAuth" "requireAuth"
] ]
}, },
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads", "path": "/api/v1/player/teams/:slug/forum/threads",
@@ -1729,6 +1782,17 @@
"validate" "validate"
] ]
}, },
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads", "path": "/api/v1/player/teams/:slug/forum/uploads",

View File

@@ -145,6 +145,14 @@
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/recent" "path": "/api/v1/admin/moderation/recent"
}, },
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/admin/moderation/search" "path": "/api/v1/admin/moderation/search"
@@ -677,6 +685,18 @@
"method": "GET", "method": "GET",
"path": "/api/v1/player/teams/:slug/access" "path": "/api/v1/player/teams/:slug/access"
}, },
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads" "path": "/api/v1/player/teams/:slug/forum/threads"
@@ -693,6 +713,10 @@
"method": "POST", "method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate" "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
}, },
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads" "path": "/api/v1/player/teams/:slug/forum/uploads"

View File

@@ -0,0 +1,154 @@
// 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,
}

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,
}

View File

@@ -224,6 +224,22 @@ async function softDeleteUploadsForPost(postId, deletedBy) {
) )
} }
/**
* The other half of the pair: a restored post gets its images back.
*
* Without this, `delete` then `restore` returns the words and loses the pictures —
* and loses them SILENTLY, because the soft-deleted rows survive the retention
* window before the sweep takes the bytes, so the post looks fine until the night
* it does not. Beyond that window the row itself is gone and this is a no-op;
* nothing can be done about that and nothing should pretend otherwise.
*/
async function restoreUploadsForPost(postId) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
[postId],
)
}
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */ /** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
async function sweepableUploads(retentionDays) { async function sweepableUploads(retentionDays) {
return query( return query(

View File

@@ -612,6 +612,20 @@ async function updateSettings(req, res) {
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge) const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
if (!gate.ok) return res.status(gate.status).json({ message: gate.error }) 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 // 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). // settings until it is re-given, and does NOT turn uploads off (§5.5.5).

View File

@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
const modNotes = require('../../../model/modNotes/modNotes.model') const modNotes = require('../../../model/modNotes/modNotes.model')
const modNotesDb = require('../../../model/modNotes/modNotes.db') const modNotesDb = require('../../../model/modNotes/modNotes.db')
const appeals = require('../../../model/appeals/appeals.model') const appeals = require('../../../model/appeals/appeals.model')
const contentReports = require('../../../model/reports/contentReports.model')
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure') const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
const botInternalClient = require('../../../utils/botInternalClient') const botInternalClient = require('../../../utils/botInternalClient')
const activity = require('../../../model/activity/activity.model') 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 = { module.exports = {
getSummary, getSummary,
getRecent, getRecent,
@@ -311,4 +374,6 @@ module.exports = {
claimAppeal, claimAppeal,
resolveAppeal, resolveAppeal,
getUserAppeals, getUserAppeals,
getContentReports,
handleContentReport,
} }

View File

@@ -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 // Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's // `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 { body, param } = require('express-validator')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const { requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
@@ -171,4 +173,34 @@ moderationRouter.get(
moderation.getUserAppeals, 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 posts excerpt and author, a threads title, or an uploads 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 module.exports = moderationRouter

View File

@@ -24,6 +24,7 @@ const grants = require('../../../model/teams/teamGrants.model')
const forum = require('../../../model/teams/teamForum.model') const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model') const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model') const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('teams') 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) ─────────────────────────────────────────────────────── // ── uploads (§5.5.4) ───────────────────────────────────────────────────────
/** /**
@@ -409,4 +449,5 @@ module.exports = {
revokeGrant, revokeGrant,
createUpload, createUpload,
deleteUpload, deleteUpload,
createReport,
} }

View File

@@ -16,6 +16,7 @@ const express = require('express')
const { body, param } = require('express-validator') const { body, param } = require('express-validator')
const ctrl = require('./teamForum.controller') const ctrl = require('./teamForum.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit') const { makeLimiter } = require('../../../middleware/rateLimit')
const { upload } = require('../admin/imageUpload') const { upload } = require('../admin/imageUpload')
@@ -40,6 +41,17 @@ const grantLimiter = makeLimiter({
message: 'Too many grant changes. Please slow down.', 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, // 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. // and this is the per-IP flood guard in front of it.
const uploadLimiter = makeLimiter({ const uploadLimiter = makeLimiter({
@@ -219,6 +231,28 @@ forumRouter.delete(
ctrl.revokeGrant, 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 Teams 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 Teams 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 ──────────────────────────────────────────────────────────────── // ── uploads ────────────────────────────────────────────────────────────────
forumRouter.post( forumRouter.post(

File diff suppressed because it is too large Load Diff

View File

@@ -607,6 +607,56 @@ const doc = {
submitter_username: { type: 'string', nullable: true, example: 'newplayer' }, submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
}, },
}, },
ContentReport: {
type: 'object',
description: 'A member-raised report about a piece of content (TEAMS.md §5.6). '
+ 'Generic by design: `targetType` is a string rather than an enum in the schema '
+ 'because a wiki page or a news comment is meant to become a new value here, not a new queue. '
+ 'Reports reach SITE STAFF only — there is no leader-facing view of this queue, '
+ 'because a Team\'s leaders are exactly the people who will not report their own Team.',
properties: {
id: { type: 'integer', example: 41 },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
targetId: { type: 'integer', example: 812 },
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
reporterDeleted: { type: 'boolean', example: false },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
handledAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
target: {
type: 'object',
nullable: true,
description: 'The reported content, already resolved so triage never means hunting. '
+ 'NULL when the target has since been hard-deleted — the report still lists, because '
+ '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. '
+ 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).',
properties: {
kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' },
threadId: { type: 'integer', nullable: true, example: 19 },
threadTitle: { type: 'string', nullable: true, example: 'Raid night' },
postId: { type: 'integer', nullable: true, example: 812 },
uploadId: { type: 'integer', nullable: true },
title: { type: 'string', nullable: true },
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
author: { type: 'string', nullable: true, example: 'someone' },
uploader: { type: 'string', nullable: true },
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
filename: { type: 'string', nullable: true },
url: { type: 'string', nullable: true, example: '/uploads/a1b2c3.png' },
mimetype: { type: 'string', nullable: true, example: 'image/png', description: 'The sniffed type, never the client\'s header.' },
byteSize: { type: 'integer', nullable: true, example: 184320 },
deleted: { type: 'boolean', nullable: true },
createdAt: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
AppealQueueItem: { AppealQueueItem: {
allOf: [{ $ref: '#/components/schemas/Appeal' }], allOf: [{ $ref: '#/components/schemas/Appeal' }],
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.', description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',