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:
2026-08-18 12:51:42 -05:00
parent ae0d27cf27
commit fff14848f1
13 changed files with 1972 additions and 6 deletions

View File

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