feat(moderation): member-raised abuse reports, to site staff only
TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the `moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or Discord-sanction-shaped, and nothing anywhere let a member say "this is a problem". That was survivable while every piece of content on the site came from staff; phase 5 lets players write to each other, so it stops being. The gap has a specific shape: leaders moderate their own Team's forum, and a Team's leaders are exactly the people who will not report their own Team. So the whole point of this queue is a path that routes AROUND a Team's own leadership. Org lead settled it on 2026-08-18: **reports are site administration only** — there is no leader-facing view of this queue, not even a read-only one scoped to their own Team. §5.6's "a leader may also see and act on reports for their own Team" is not implemented and is not deferred. `content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki page or a news comment becomes a value rather than a table — and the queue is mounted beside appeals under /admin/moderation rather than under Teams, because a staffer working a queue should have one place to work. **§5.6's literal unique key has a defect and this does not copy it.** Written as (target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide with each other too: reporter reports a post, staff dismiss it, the behaviour recurs, they report again — and the second dismissal is an UPDATE into a tuple that already exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The key is on a generated `open_marker` instead, the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open report per (target, reporter)". Two other departures from the doc, both small and both flagged in the docs PR: `handled_note`, because a queue whose resolution reason lives only in an activity_log line is one where the next staffer to see a repeat report cannot find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted Team does not leave a queue full of reports about content that no longer exists. Also here: a report is filed against a target the model verifies really belongs to the Team the request came through, or the queue's per-Team filter would quietly be lying; the queue resolves every row's target in three batched reads rather than N+1, which is §5.6's fourth rule (uploader, size and sniffed type without hunting) actually paying for §5.5.4's attribution table; a target that has since been hard-deleted comes back null and the report still lists, because "somebody reported this and by the time we looked it was gone" is a fact a moderator needs; and every transition writes activity_log, `dismissed` included — a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. `teams_forum_edit_window_minutes` gains its range validation on the admin settings PUT and is seeded at 15, so the value on the settings screen is the value in force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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