diff --git a/server/db/schema.sql b/server/db/schema.sql index db13dec..b28e013 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1134,6 +1134,69 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads ( INDEX idx_tfu_sweep (deleted_at) ) 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 -- publish untrusted game-sourced strings creates a pending row here; an ADMIN -- 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. 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 category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; diff --git a/server/routes.guards.json b/server/routes.guards.json index 5651a8f..2d8b6dc 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -345,6 +345,26 @@ "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", "path": "/api/v1/admin/moderation/search", @@ -1687,6 +1707,39 @@ "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", "path": "/api/v1/player/teams/:slug/forum/threads", @@ -1729,6 +1782,17 @@ "validate" ] }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/posts", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "POST", "path": "/api/v1/player/teams/:slug/forum/uploads", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 917214d..b6be23f 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -145,6 +145,14 @@ "method": "GET", "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", "path": "/api/v1/admin/moderation/search" @@ -677,6 +685,18 @@ "method": "GET", "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", "path": "/api/v1/player/teams/:slug/forum/threads" @@ -693,6 +713,10 @@ "method": "POST", "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate" }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/posts" + }, { "method": "POST", "path": "/api/v1/player/teams/:slug/forum/uploads" diff --git a/server/src/model/reports/contentReports.db.js b/server/src/model/reports/contentReports.db.js new file mode 100644 index 0000000..8194714 --- /dev/null +++ b/server/src/model/reports/contentReports.db.js @@ -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, +} diff --git a/server/src/model/reports/contentReports.model.js b/server/src/model/reports/contentReports.model.js new file mode 100644 index 0000000..47bd817 --- /dev/null +++ b/server/src/model/reports/contentReports.model.js @@ -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, +} diff --git a/server/src/model/teams/teamForum.db.js b/server/src/model/teams/teamForum.db.js index 6fa7c07..d4d7d95 100644 --- a/server/src/model/teams/teamForum.db.js +++ b/server/src/model/teams/teamForum.db.js @@ -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. */ async function sweepableUploads(retentionDays) { return query( diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index cec162f..c91fc7e 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -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). diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js index b4f1f5d..711556a 100644 --- a/server/src/router/v1/admin/moderation.controller.js +++ b/server/src/router/v1/admin/moderation.controller.js @@ -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, } diff --git a/server/src/router/v1/admin/moderation.router.js b/server/src/router/v1/admin/moderation.router.js index ee659bd..f697daa 100644 --- a/server/src/router/v1/admin/moderation.router.js +++ b/server/src/router/v1/admin/moderation.router.js @@ -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= 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 diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js index 6d1cee6..204c248 100644 --- a/server/src/router/v1/player/teamForum.controller.js +++ b/server/src/router/v1/player/teamForum.controller.js @@ -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, } diff --git a/server/src/router/v1/player/teamForum.router.js b/server/src/router/v1/player/teamForum.router.js index 794dcc5..ffc1f40 100644 --- a/server/src/router/v1/player/teamForum.router.js +++ b/server/src/router/v1/player/teamForum.router.js @@ -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( diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 6b0f874..8c0e56c 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -2050,6 +2050,152 @@ ] } }, + "/api/v1/admin/moderation/reports": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "The member-raised content report queue", + "description": "Defaults to the open work (`open` + `reviewing`); filter with ?status= 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.", + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The queue", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentReport" + } + }, + "openCount": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/reports/{id}/handle": { + "post": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Claim, action or dismiss a content report", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Report id." + } + ], + "responses": { + "200": { + "description": "The updated report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContentReport" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Report not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "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 + } + } + } + } + } + } + } + }, "/api/v1/admin/moderation/search": { "get": { "tags": [ @@ -10252,6 +10398,356 @@ ] } }, + "/api/v1/player/teams/{slug}/forum/posts/{id}": { + "patch": { + "tags": [ + "Player · Teams" + ], + "summary": "Edit a post", + "description": "The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The post id." + } + ], + "responses": { + "200": { + "description": "Edited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "postId": { + "type": "integer" + }, + "threadId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your post, or the edit window has closed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Forum off, no such post, or no access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/player/teams/{slug}/forum/posts/{id}/moderate": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Hide, unhide, delete or restore a post", + "description": "Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so \"everything moderated in this Team\" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The post id." + } + ], + "responses": { + "200": { + "description": "Applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + }, + "postId": { + "type": "integer" + }, + "threadId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "An action that applies to a thread, not a post", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not a leader of this Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "hide", + "unhide", + "delete", + "restore" + ] + }, + "reason": { + "type": "string", + "maxLength": 255 + } + } + } + } + } + } + } + }, + "/api/v1/player/teams/{slug}/forum/report": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Report a thread, post or upload to site staff", + "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.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "Raised", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "reportId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Forum off, no access, or the target is not in this Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "You already have an open report on this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "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 + } + } + } + } + } + } + } + }, "/api/v1/player/teams/{slug}/forum/threads": { "get": { "tags": [ @@ -10314,8 +10810,8 @@ "tags": [ "Player · Teams" ], - "summary": "Post an announcement", - "description": "Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", + "summary": "Open a thread — an announcement or a discussion", + "description": "Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", "parameters": [ { "name": "slug", @@ -10353,7 +10849,7 @@ "description": "Unauthorized" }, "403": { - "description": "Not a leader of this Team", + "description": "Only a leader may post an announcement", "content": { "application/json": { "schema": { @@ -10391,8 +10887,10 @@ "type": { "type": "string", "enum": [ - "announcement" - ] + "announcement", + "discussion" + ], + "default": "announcement" }, "title": { "type": "string", @@ -10593,6 +11091,116 @@ } } }, + "/api/v1/player/teams/{slug}/forum/threads/{id}/posts": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Reply to a discussion thread", + "description": "Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The thread id." + } + ], + "responses": { + "200": { + "description": "Posted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "threadId": { + "type": "integer" + }, + "postId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Announcements do not take replies", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "409": { + "description": "The thread is locked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + } + } + } + } + } + } + } + }, "/api/v1/player/teams/{slug}/forum/uploads": { "post": { "tags": [ @@ -15799,6 +16407,570 @@ } } }, + "ContentReport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 41 + } + } + }, + "targetType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "team_forum_post" + }, + "description": { + "type": "string", + "example": "team_forum_thread | team_forum_post | team_forum_upload" + } + } + }, + "targetId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 812 + } + } + }, + "teamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 7 + }, + "description": { + "type": "string", + "example": "Denormalised so the queue can filter by Team." + } + } + }, + "reporter": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + }, + "description": { + "type": "string", + "example": "Username snapshot; \"[deleted account]\" once the account is gone." + } + } + }, + "reporterDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "spam", + "abuse", + "sexual", + "illegal", + "impersonation", + "other" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "abuse" + } + } + }, + "detail": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "maxLength": { + "type": "number", + "example": 500 + }, + "example": { + "type": "string", + "example": "Personal attacks in the third paragraph." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "open", + "reviewing", + "actioned", + "dismissed" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "open" + } + } + }, + "handledBy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "moderator1" + } + } + }, + "handledNote": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Post hidden, author warned." + } + } + }, + "handledAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "target": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "thread", + "post", + "upload" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "post" + } + } + }, + "threadId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 19 + } + } + }, + "threadTitle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Raid night" + } + } + }, + "postId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 812 + } + } + }, + "uploadId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "announcement", + "discussion" + ], + "items": { + "type": "string" + } + } + } + }, + "author": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "someone" + } + } + }, + "uploader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "excerpt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Plain-text excerpt of the post body, capped at 300 characters." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "visible", + "hidden", + "deleted" + ], + "items": { + "type": "string" + } + } + } + }, + "filename": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "url": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "/uploads/a1b2c3.png" + } + } + }, + "mimetype": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "image/png" + }, + "description": { + "type": "string", + "example": "The sniffed type, never the client's header." + } + } + }, + "byteSize": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 184320 + } + } + }, + "deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + }, "AppealQueueItem": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 47731ee..6ec430f 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -607,6 +607,56 @@ const doc = { 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: { allOf: [{ $ref: '#/components/schemas/Appeal' }], description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',