feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
154
server/src/model/reports/contentReports.db.js
Normal file
154
server/src/model/reports/contentReports.db.js
Normal 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,
|
||||||
|
}
|
||||||
231
server/src/model/reports/contentReports.model.js
Normal file
231
server/src/model/reports/contentReports.model.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
|
module.exports = moderationRouter
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 ────────────────────────────────────────────────────────────────
|
// ── uploads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
forumRouter.post(
|
forumRouter.post(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user