From ae0d27cf276c0214b9f672b27c4bd6881e7d09e7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 10:43:09 -0500 Subject: [PATCH 1/5] feat(teams): discussion threads, replies, the edit window and post moderation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5's server half — TEAMS.md §5.1's "5b". The schema for all of it landed in phase 4, so this adds no ALTER: every column it needed (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the ledger's `target_type='post'`) was already there waiting. * `teams_forum_edit_window_minutes` (0…1440, default 15) joins the forum's settings. It fails closed to ZERO rather than to its default, which is the opposite of what it looks like it should do: the risk an edit window bounds is an author rewriting a post out from under a reader quoting it or a moderator about to act on a report, so the safe answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement freezes this key too — it is a forum setting. * Thread creation splits its authority BY TYPE, which is what phase 4's comment said would happen here rather than widening the leader gate. An announcement stays leader-authored; a discussion is open to every participant, and "participant" includes a granted non-member with no game identity — path 3 doing its job. `type` still defaults to `announcement`, so a phase-4 client keeps meaning what it meant. * Replies refuse three ways with deliberately different codes: 404 for absent or hidden, 400 for an announcement (which takes no replies by TYPE, not by being closed), and 409 for locked — well-formed request, refusing state. Locked refuses staff too; they hold `unlock`, and unlock/post/relock reaches the same place leaving three ledger rows that say so. * The edit window is evaluated on the server twice, on purpose. The read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control; the write re-derives it from `created_at` before allowing anything. A time-bounded permission must not take its clock from the party it bounds. Staff are not time-bounded, and a staff edit of someone else's words writes `activity_log` while a member fixing their own typo does not (§5.3). * Post moderation shares the thread ledger via `target_type='post'`, so "everything moderated in this Team" stays one query. `pin`/`lock` are refused by name rather than as unknown actions — they describe a thread's place in a list and its openness to replies, neither of which a post has. Counters are RECOMPUTED after each action rather than nudged, because hide → unhide → hide is a cycle a delta gets wrong the first time a step is retried. Two fixes to phase 4 code this work reached: `softDeleteUploadsForPost` bound its two arguments in the wrong order (never fired — nothing called it until post deletion did), and it had no inverse, so `delete` → `restore` would have returned a post's words and silently lost its pictures a retention window later. Co-Authored-By: Claude --- server/src/model/teams/teamForum.db.js | 42 +++ server/src/model/teams/teamForum.model.js | 282 ++++++++++++++++-- .../model/teams/teamForumSettings.model.js | 61 +++- .../router/v1/player/teamForum.controller.js | 142 ++++++++- .../src/router/v1/player/teamForum.router.js | 67 ++++- 5 files changed, 542 insertions(+), 52 deletions(-) diff --git a/server/src/model/teams/teamForum.db.js b/server/src/model/teams/teamForum.db.js index 0647f30..6fa7c07 100644 --- a/server/src/model/teams/teamForum.db.js +++ b/server/src/model/teams/teamForum.db.js @@ -104,6 +104,45 @@ async function setPostStatus(id, status) { return res.affectedRows > 0 } +/** + * Rewrite a post's body, stamping who edited it and when. + * + * `edited_at` is set unconditionally, including when a staffer edits — the column + * answers "has this been changed since it was written", which a reader needs to + * know regardless of whose hand did it. `edited_by` is the second half of that + * answer and is why the two are separate columns rather than a boolean. + */ +async function updatePostBody(id, bodyHtml, editedBy) { + const res = await query( + 'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?', + [bodyHtml, editedBy, id], + ) + return res.affectedRows > 0 +} + +/** + * Recompute a thread's denormalised counters from the posts that are actually + * visible. + * + * Called after every post moderation rather than incrementing and decrementing, + * because hide → unhide → delete → restore is a sequence in which a counter kept + * by deltas drifts the first time any step is retried or raced. The read is one + * indexed aggregate over one thread; correctness is worth more than the write it + * saves. `last_post_at` falls back to NULL for an emptied thread, which is what + * `threadsByTeam`'s COALESCE onto `created_at` already expects. + */ +async function recountThread(threadId) { + await query( + `UPDATE team_forum_threads t + SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p + WHERE p.thread_id = t.id AND p.status = 'visible'), + t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p + WHERE p.thread_id = t.id AND p.status = 'visible') + WHERE t.id = ?`, + [threadId], + ) +} + // ── the moderation ledger (append-only) ──────────────────────────────────── async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) { @@ -222,6 +261,8 @@ module.exports = { postById, insertPost, setPostStatus, + updatePostBody, + recountThread, insertModeration, moderationForTeam, insertUpload, @@ -230,6 +271,7 @@ module.exports = { listUploads, softDeleteUpload, softDeleteUploadsForPost, + restoreUploadsForPost, sweepableUploads, orphanedUploads, deleteUploadRows, diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js index 2f8316f..a5d8506 100644 --- a/server/src/model/teams/teamForum.model.js +++ b/server/src/model/teams/teamForum.model.js @@ -1,11 +1,11 @@ -// ── The forum, phase 4 ("5a": access + announcements) ────────────────────── +// ── The forum: access + announcements (5a), discussion + moderation (5b) ─── // -// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a ships the whole access -// model and a single announcements stream per Team; 5b opens discussion threads, -// replies and editing. The schema for all of it landed together, so 5b enables -// paths here rather than migrating data — which is why `type` is a parameter -// below and not a constant, and why `locked` is honoured on a thread nothing can -// reply to yet. +// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access +// model and a single announcements stream per Team; 5b (phase 5) opens discussion +// threads, replies, editing and post-level moderation. The schema for all of it +// landed together, so this phase added no ALTER — every column it needed +// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the +// ledger's `target_type='post'`) was already there waiting. // // **Every function here takes an already-resolved access decision.** Nothing in // this file reads `team_members` or `team_forum_grants`; the caller asks @@ -22,11 +22,26 @@ const forumDb = require('./teamForum.db') const forumSettings = require('./teamForumSettings.model') const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml') -// Announcements are leader-authored and replies are disabled; 5b's discussion -// threads are member-authored and take replies. Both types exist in the enum from -// day one — this is the list of what 5a will CREATE. +// Announcements are leader-authored and take no replies; discussion threads are +// member-authored and do. Both have been in the enum since 5a — what phase 5 +// changed is that both are now CREATABLE, and by different people. +// +// **The authority split lives in the controller, not here.** This list says what +// kinds of thread exist; who may make one is a question about the caller, which +// this file deliberately never asks (see the header on access decisions). +const CREATABLE_TYPES = ['announcement', 'discussion'] + +// Kept as an export because it names a real fact — the one type 5a could create — +// and because removing a name from a module's surface to save a line is how a +// consumer outside this repo breaks. It is not used to decide anything. const CREATABLE_TYPES_5A = ['announcement'] +// Which thread types accept replies. An announcement's `locked` stays false even +// though nothing may reply to it: replies are refused because the TYPE takes none, +// not because the thread was closed, and conflating the two would make "unlock" +// look like it would open replies on an announcement. +const REPLYABLE_TYPES = ['discussion'] + const DELETED_AUTHOR = '[deleted account]' /** @@ -48,6 +63,17 @@ const THREAD_ACTIONS = { restore: { status: 'visible' }, } +// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock` +// describe a thread's place in a list and its openness to replies, neither of +// which a post has. Naming them here as "not applicable" rather than as "unknown" +// is what lets `moderatePost` tell a caller which mistake they made. +const POST_ACTIONS = { + hide: { status: 'hidden' }, + unhide: { status: 'visible' }, + delete: { status: 'deleted' }, + restore: { status: 'visible' }, +} + function publicThread(row) { return { id: row.id, @@ -65,14 +91,44 @@ function publicThread(row) { } /** - * One post, rendered for one image policy. + * May this viewer edit this post, and until when? + * + * **Computed on the server and handed to the client, never the other way round** — + * the same rule §5.5.3 applies to the image policy, for the same reason. A client + * that decided this would be deciding it against its own clock, and a clock is the + * one input a time-bounded permission must not take from the party it bounds. + * + * Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no + * permission" — `canEdit` is the permission and this is only its expiry. An author + * past their window keeps a past `editableUntil`, so the UI can say *why* the + * control is gone instead of silently dropping it. + */ +function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) { + // A hidden or deleted post is not editable by anybody, staff included. Restoring + // it is a moderation action with a ledger row; quietly rewriting it while it is + // out of sight is the same act with no record. + if (row.status !== 'visible') return { canEdit: false, editableUntil: null } + if (isStaff) return { canEdit: true, editableUntil: null } + if (!userId || row.author_user_id == null || row.author_user_id !== userId) { + return { canEdit: false, editableUntil: null } + } + const until = new Date(row.created_at).getTime() + windowMinutes * 60_000 + return { canEdit: until > now, editableUntil: new Date(until).toISOString() } +} + +/** + * One post, rendered for one image policy and one viewer. * * `body` is what the reader gets and `mode` decides whether it carries images. * The STORED html is never modified — flipping the policy changes this function's * output and nothing on disk, which is the property §5.5.3 exists to give and the * one acceptance criterion 3 measures. + * + * `viewer` is optional so that every 5a caller keeps working unchanged; omitting + * it yields `canEdit: false`, which is the right answer for a caller that has not + * said who is reading. */ -function renderPost(row, mode) { +function renderPost(row, mode, viewer) { return { id: row.id, author: row.author_username || DELETED_AUTHOR, @@ -81,6 +137,8 @@ function renderPost(row, mode) { createdAt: row.created_at, editedAt: row.edited_at, status: row.status, + mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId, + ...editability(row, viewer), } } @@ -97,8 +155,16 @@ async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 return rows.map(publicThread) } -/** One thread with its posts, rendered under the current image policy. */ -async function getThread(teamId, threadId, { canModerate = false } = {}) { +/** + * One thread with its posts, rendered under the current image policy and for one + * viewer. + * + * `viewer` carries who is reading and what the edit window is, so every post comes + * back already knowing whether this caller may edit it. The alternative — shipping + * the window to the client and letting it compare timestamps — is the thing + * `editability` exists not to do. + */ +async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) { const thread = await forumDb.threadById(threadId) // The team check is here rather than in the SQL so a thread id from another // Team reads as "not found" and not as "found, but not yours" — a forum is a @@ -109,25 +175,34 @@ async function getThread(teamId, threadId, { canModerate = false } = {}) { const mode = await forumSettings.imageMode() const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate }) - return { ...publicThread(thread), posts: posts.map((p) => renderPost(p, mode)) } + return { + ...publicThread(thread), + // A reply control is offered when the TYPE takes replies and the thread is + // open. Both halves are reported separately (`type`, `locked`) so the UI can + // say which one is why, but the decision itself is made here — a client that + // recomputed it would be a second place for the rule to live. + canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible', + posts: posts.map((p) => renderPost(p, mode, viewer)), + } } /** - * Post an announcement: a thread and its first post, in one call. + * Open a thread: the thread and its first post, in one call. * - * An announcement is a degenerate thread rather than its own thing (§5.1) — which - * is why this writes the ordinary tables and 5b adds no migration. `locked` is - * left false: replies are refused because the TYPE takes none, not because the - * thread was closed, and conflating the two would make "unlock" look like it - * would open replies on an announcement. + * An announcement is a degenerate thread rather than its own thing (§5.1), which + * is why phase 5 added no migration — a discussion thread is the same two writes + * with a different `type`. The FIRST post is an ordinary post and is moderated, + * edited and reported like any other; nothing here marks it as special, because a + * thread whose opening post could not be moderated would be a hole shaped exactly + * like the one moderation exists to close. */ async function createThread({ team, actor, type, title, body }) { - if (!CREATABLE_TYPES_5A.includes(type)) { - return { ok: false, status: 400, error: 'Only announcements can be posted yet' } + if (!CREATABLE_TYPES.includes(type)) { + return { ok: false, status: 400, error: 'Unknown thread type' } } const cleaned = cleanForumBody(body) if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) { - return { ok: false, status: 400, error: 'An announcement needs a body' } + return { ok: false, status: 400, error: 'A post needs a body' } } const threadId = await forumDb.insertThread({ teamId: team.id, @@ -136,13 +211,102 @@ async function createThread({ team, actor, type, title, body }) { createdBy: actor.id, createdUsername: actor.username, }) - await forumDb.insertPost({ + const postId = await forumDb.insertPost({ threadId, authorUserId: actor.id, authorUsername: actor.username, bodyHtml: cleaned, }) - return { ok: true, threadId } + return { ok: true, threadId, postId } +} + +/** + * Reply to a discussion thread. + * + * Three refusals, and the status codes are chosen to be distinguishable rather + * than uniform. A thread that is not there, or is hidden from this caller, is 404 + * for the §5.5.1 reason. An announcement is 400 — the request is malformed for + * this thread, and no amount of retrying fixes it. A locked thread is **409**: the + * request is fine and the resource's state is what refuses, which is exactly the + * distinction a client needs to tell "you cannot" from "not right now". + * + * **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what + * is gained is that `locked` means the same thing to every reader. A moderator's + * reply appearing in a thread nobody else may answer is the last word by fiat; + * unlock, post, relock is the same outcome with three ledger rows saying so. + */ +async function createPost({ team, threadId, actor, body }) { + const thread = await forumDb.threadById(threadId) + if (!thread || thread.team_id !== team.id || thread.status !== 'visible') { + return { ok: false, status: 404, error: 'Thread not found' } + } + if (!REPLYABLE_TYPES.includes(thread.type)) { + return { ok: false, status: 400, error: 'Announcements do not take replies' } + } + if (thread.locked) { + return { ok: false, status: 409, error: 'This thread is locked' } + } + const cleaned = cleanForumBody(body) + if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) { + return { ok: false, status: 400, error: 'A reply needs a body' } + } + const postId = await forumDb.insertPost({ + threadId, + authorUserId: actor.id, + authorUsername: actor.username, + bodyHtml: cleaned, + }) + return { ok: true, threadId, postId } +} + +/** + * Edit a post: the author inside the window, staff at any time (§5.4). + * + * The window is re-derived HERE from `created_at` and never trusted from the + * request, which is also why `editability` runs on the read path — the read tells + * the client whether to draw the control, and this decides whether the edit + * happens. Two evaluations of one rule, deliberately: the read one is advice and + * this one is enforcement. + * + * A staffer editing someone else's post is reported back as `staffEdit` so the + * controller can write the §5.3 accountability row. A staffer editing their OWN + * post is an ordinary edit and is not: the trail records interventions, and + * everything a staffer ever typed is not an intervention. + */ +async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) { + const post = await forumDb.postById(postId) + if (!post) return { ok: false, status: 404, error: 'Post not found' } + + const thread = await forumDb.threadById(post.thread_id) + if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' } + if (post.status !== 'visible' || thread.status !== 'visible') { + return { ok: false, status: 404, error: 'Post not found' } + } + + const isAuthor = post.author_user_id != null && post.author_user_id === actor.id + if (!isAuthor && !isStaff) { + return { ok: false, status: 403, error: 'You may only edit your own posts' } + } + if (!isStaff) { + if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' } + const { canEdit } = editability(post, { userId: actor.id, windowMinutes }) + if (!canEdit) { + return { + ok: false, + status: 403, + error: windowMinutes > 0 + ? `The ${windowMinutes}-minute edit window for this post has closed` + : 'Posts cannot be edited on this site', + } + } + } + + const cleaned = cleanForumBody(body) + if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) { + return { ok: false, status: 400, error: 'A post needs a body' } + } + await forumDb.updatePostBody(postId, cleaned, actor.id) + return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor } } /** @@ -175,19 +339,85 @@ async function moderateThread({ team, threadId, action, actor, actorRole, reason return { ok: true, action, threadId } } +/** + * Apply a moderation action to a POST, and record which authority did it. + * + * The same ledger as `moderateThread`, with `target_type='post'` — one table, two + * target kinds, because "show me everything that was moderated in this Team" is + * the question the admin view asks and two tables would make it a union. + * + * `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the + * mistake rather than a bare "unknown action": they are real actions applied to + * the wrong kind of object, and a caller who sent one has a bug worth telling + * them about precisely. + * + * **The opening post of a thread is moderatable like any other.** Hiding it leaves + * a thread with a title and its replies and no body, which looks odd and is + * correct — an abusive opener does not have to take a good discussion with it, and + * a moderator who wants the whole thing gone has `hide` on the thread. + */ +async function moderatePost({ team, postId, action, actor, actorRole, reason }) { + const effect = POST_ACTIONS[action] + if (!effect) { + return { + ok: false, + status: 400, + error: THREAD_ACTIONS[action] + ? `"${action}" applies to a thread, not to a post` + : 'Unknown moderation action', + } + } + + const post = await forumDb.postById(postId) + if (!post) return { ok: false, status: 404, error: 'Post not found' } + const thread = await forumDb.threadById(post.thread_id) + if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' } + + await forumDb.setPostStatus(postId, effect.status) + // The counters are recomputed rather than nudged, because these four actions + // form cycles (hide → unhide → hide) that a delta gets wrong the first time one + // is retried. + await forumDb.recountThread(post.thread_id) + + // Images follow their post. Soft on the way out and reversible on the way back + // in, so `delete` → `restore` inside the retention window returns the post + // whole; past it, the sweep has taken the bytes and nothing can. + if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id) + if (action === 'restore') await forumDb.restoreUploadsForPost(postId) + + await forumDb.insertModeration({ + teamId: team.id, + targetType: 'post', + targetId: postId, + action, + actorUserId: actor.id, + actorUsername: actor.username, + actorRole, + reason, + }) + return { ok: true, action, postId, threadId: post.thread_id } +} + /** The ledger for the admin Team page. Staff-only by its route, not by this function. */ async function moderationLedger(teamId, opts) { return forumDb.moderationForTeam(teamId, opts) } module.exports = { + CREATABLE_TYPES, CREATABLE_TYPES_5A, + REPLYABLE_TYPES, THREAD_ACTIONS, + POST_ACTIONS, listThreads, getThread, createThread, + createPost, + editPost, moderateThread, + moderatePost, moderationLedger, publicThread, renderPost, + editability, } diff --git a/server/src/model/teams/teamForumSettings.model.js b/server/src/model/teams/teamForumSettings.model.js index 044c183..059324e 100644 --- a/server/src/model/teams/teamForumSettings.model.js +++ b/server/src/model/teams/teamForumSettings.model.js @@ -1,29 +1,35 @@ -// ── The operator's two forum controls, and the acknowledgement gate ──────── +// ── The operator's forum controls, and the acknowledgement gate ──────────── // -// TEAMS.md §5.5. Three `settings` keys, and the reason they live in their own -// file rather than in settings.model.js is that only one of them is an ordinary -// key: `teams_forum_images` has a server-side precondition, and a precondition -// buried in the generic setMany() loop is one nobody reading that loop would -// know about. +// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason +// they live in their own file rather than in settings.model.js is that only two +// of them are ordinary keys: `teams_forum_images` has a server-side precondition, +// and a precondition buried in the generic setMany() loop is one nobody reading +// that loop would know about. // // teams_forums_enabled '0' | '1' default '0' — off // teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled' // teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given +// teams_forum_edit_window_minutes 0 … 1440 default 15 (phase 5) // -// **Both reads fail closed.** A DB fault reports the forum off and images -// disabled, because the alternative is a transient error opening a feature the -// operator turned off, or rendering third-party images on a site whose operator -// chose not to. The cost of failing closed here is a forum that 404s for a minute; -// the cost of failing open is a policy that is not a policy. +// **Every read fails closed.** A DB fault reports the forum off, images disabled +// and the edit window shut, because the alternative is a transient error opening a +// feature the operator turned off, or rendering third-party images on a site whose +// operator chose not to. The cost of failing closed here is a forum that 404s for a +// minute; the cost of failing open is a policy that is not a policy. const settingsDb = require('../settings/settings.db') const ENABLED_KEY = 'teams_forums_enabled' const IMAGES_KEY = 'teams_forum_images' const ACK_KEY = 'teams_forum_uploads_ack' +const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes' const IMAGE_MODES = ['disabled', 'remote', 'uploads'] +// How long an author may edit their own post. Staff are not bound by it (§5.4). +const EDIT_WINDOW_DEFAULT = 15 +const EDIT_WINDOW_MAX = 1440 // a day; beyond that "window" stops meaning anything + // The version of the §5.5.5 warning text currently in force. Bumping this is what // makes every stored acknowledgement stale — see `ackState` below for what that // then does, which is deliberately NOT "turn uploads off". @@ -52,6 +58,33 @@ async function imageMode() { } } +/** + * How many minutes an author has to edit their own post. + * + * Fails closed to ZERO rather than to the default, and that is the opposite of + * what it looks like it should do. The risk an edit window bounds is an author + * rewriting a post out from under a reader who is quoting it or a moderator who + * is about to act on a report — so the safe answer during a DB fault is "nobody + * may edit for the next minute", not "everyone may edit for fifteen". Staff are + * unaffected either way, because their authority is not time-bounded. + * + * `0` is also a legitimate STORED value, meaning an operator who wants posts + * immutable once written. There is deliberately no distinction between "off" and + * "unreadable" here: both deny, and inventing a third state would only give the + * caller a decision to get wrong. + */ +async function editWindowMinutes() { + try { + const raw = await settingsDb.get(EDIT_WINDOW_KEY) + if (raw == null || raw === '') return EDIT_WINDOW_DEFAULT + const n = Number(raw) + if (!Number.isFinite(n) || n < 0 || n > EDIT_WINDOW_MAX) return EDIT_WINDOW_DEFAULT + return Math.floor(n) + } catch { + return 0 + } +} + /** Are uploads accepted? The one mode where files come to rest on the operator's disk. */ async function uploadsEnabled() { return (await imageMode()) === 'uploads' @@ -127,7 +160,7 @@ async function assertAcknowledged(nextMode, acknowledge) { * key. */ async function assertSettingsWritable(keys, acknowledge) { - const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY) + const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY || k === EDIT_WINDOW_KEY) if (!touchesForum) return { ok: true } const state = await ackState() if (!state.stale) return { ok: true } @@ -148,10 +181,14 @@ module.exports = { ENABLED_KEY, IMAGES_KEY, ACK_KEY, + EDIT_WINDOW_KEY, IMAGE_MODES, ACK_VERSION, + EDIT_WINDOW_DEFAULT, + EDIT_WINDOW_MAX, forumsEnabled, imageMode, + editWindowMinutes, uploadsEnabled, ackState, assertAcknowledged, diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js index d480f2a..6d1cee6 100644 --- a/server/src/router/v1/player/teamForum.controller.js +++ b/server/src/router/v1/player/teamForum.controller.js @@ -59,6 +59,7 @@ async function resolveForum(req) { return { team, access: resolved, + staff, // Staff moderate anywhere; a leader moderates their own Team. `actorRole` // records WHICH of the two was exercised, and leadership wins when both are // true: a leader who is also a moderator acting on their own Team is doing @@ -69,6 +70,21 @@ async function resolveForum(req) { } } +/** + * Who is reading, for the read path's per-post `canEdit`. + * + * A separate read of the edit window rather than one folded into `resolveForum`, + * because only the two routes that render posts need it and `resolveForum` runs + * on every route in this file including the ones that never look at a body. + */ +async function viewerFor(ctx, user) { + return { + userId: user.id, + isStaff: ctx.staff, + windowMinutes: await forumSettings.editWindowMinutes(), + } +} + // ── threads ──────────────────────────────────────────────────────────────── async function listThreads(req, res) { @@ -77,7 +93,15 @@ async function listThreads(req, res) { if (!ctx) return res.status(404).json({ message: 'Not found' }) return res.json({ threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }), - canPost: ctx.canModerate, + // Two capabilities, not one. Phase 4 had a single `canPost` because there + // was a single kind of thread to post; phase 5 opened discussion to every + // participant while announcements stayed with the leaders, so a client that + // read one boolean would have to guess which right it described. + // `canPost` is kept and now means "may open a discussion", which is what a + // 5a client's composer was for — an old client offering the composer to a + // member is a client offering the thing the server now allows. + canPost: true, + canAnnounce: ctx.canModerate, canModerate: ctx.canModerate, imageMode: await forumSettings.imageMode(), }) @@ -90,7 +114,10 @@ async function getThread(req, res) { try { const ctx = await resolveForum(req) if (!ctx) return res.status(404).json({ message: 'Not found' }) - const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { canModerate: ctx.canModerate }) + const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { + canModerate: ctx.canModerate, + viewer: await viewerFor(ctx, req.user), + }) if (!thread) return res.status(404).json({ message: 'Not found' }) return res.json({ ...thread, canModerate: ctx.canModerate }) } catch (err) { @@ -99,23 +126,34 @@ async function getThread(req, res) { } /** - * Post an announcement. 5a: leaders (and staff) only, replies disabled. + * Open a thread. * - * The `canModerate` gate is doing double duty here and that is deliberate for one - * phase only: in 5a the only creatable type is an announcement, whose author must - * be a leader. 5b adds `type: 'discussion'`, which any member may create — at - * which point the check splits by type rather than being widened. + * **The check splits by TYPE, which is what phase 4 said would happen here.** An + * announcement is leader-authored; a discussion is open to every participant — and + * "participant" means anyone `resolveForum` let through, which includes a granted + * non-member with no game identity at all. That is path 3 doing its job: a forum + * guest reads and writes exactly as a member does, because the alternative is a + * second class of reader whose rights have to be tracked somewhere else. + * + * The default type is still `announcement`, unchanged from 5a: a client that + * posts without saying what it is posting is a 5a client, and a 5a client only + * ever posted announcements. Defaulting the other way would silently turn its + * announcements into discussions. */ async function createThread(req, res) { try { const ctx = await resolveForum(req) if (!ctx) return res.status(404).json({ message: 'Not found' }) - if (!ctx.canModerate) return res.status(403).json({ message: 'Only Team leaders may post announcements' }) + + const type = req.body.type || 'announcement' + if (type === 'announcement' && !ctx.canModerate) { + return res.status(403).json({ message: 'Only Team leaders may post announcements' }) + } const result = await forum.createThread({ team: ctx.team, actor: req.user, - type: req.body.type || 'announcement', + type, title: req.body.title, body: req.body.body, }) @@ -125,6 +163,89 @@ async function createThread(req, res) { } } +/** Reply to a discussion thread. Every participant may; the model decides the rest. */ +async function createPost(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + + return send(res, await forum.createPost({ + team: ctx.team, + threadId: Number(req.params.id), + actor: req.user, + body: req.body.body, + })) + } catch (err) { + return fail(res, err, 'create post') + } +} + +/** + * Edit a post. + * + * A staff edit of somebody else's words is an intervention and writes + * `activity_log` (§5.3) — the one asymmetry that keeps the site's + * staff-accountability trail complete without dragging a member fixing their own + * typo into it. The model reports which case this was; the controller never + * re-derives it, because the two would disagree the day one of them changed. + */ +async function editPost(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + + const result = await forum.editPost({ + team: ctx.team, + postId: Number(req.params.id), + actor: req.user, + isStaff: ctx.staff, + windowMinutes: await forumSettings.editWindowMinutes(), + body: req.body.body, + }) + if (result.ok && result.staffEdit) { + await activity.log({ + req, + action: 'team.forum.edit', + detail: `${req.user.username} (#${req.user.id}) edited post #${req.params.id} ` + + `on team "${ctx.team.name}" (#${ctx.team.id})`, + }) + } + return send(res, result) + } catch (err) { + return fail(res, err, 'edit post') + } +} + +/** Hide, unhide, delete or restore one post. Pin and lock belong to threads. */ +async function moderatePost(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' }) + + const result = await forum.moderatePost({ + team: ctx.team, + postId: Number(req.params.id), + action: req.body.action, + actor: req.user, + actorRole: ctx.actorRole, + reason: req.body.reason, + }) + if (result.ok && ctx.actorRole === 'staff') { + await activity.log({ + req, + action: 'team.forum.moderate', + detail: `${req.user.username} (#${req.user.id}) ${req.body.action} post #${req.params.id} ` + + `on team "${ctx.team.name}" (#${ctx.team.id})` + + `${req.body.reason ? `: "${req.body.reason}"` : ''}`, + }) + } + return send(res, result) + } catch (err) { + return fail(res, err, 'moderate post') + } +} + /** * Pin / lock / hide / delete a thread, and its opposites. * @@ -279,7 +400,10 @@ module.exports = { listThreads, getThread, createThread, + createPost, + editPost, moderateThread, + moderatePost, listGrants, createGrant, revokeGrant, diff --git a/server/src/router/v1/player/teamForum.router.js b/server/src/router/v1/player/teamForum.router.js index 19aecd7..794dcc5 100644 --- a/server/src/router/v1/player/teamForum.router.js +++ b/server/src/router/v1/player/teamForum.router.js @@ -64,16 +64,16 @@ forumRouter.get( forumRouter.post( '/:slug/forum/threads', // #swagger.tags = ['Player · Teams'] - // #swagger.summary = 'Post an announcement' - // #swagger.description = 'Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.' + // #swagger.summary = 'Open a thread — an announcement or a discussion' + // #swagger.description = 'Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.' // #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: ['title','body'], properties: { type: { type: 'string', enum: ['announcement'] }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement','discussion'], default: 'announcement' }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */ - /* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Only a leader may post an announcement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ postLimiter, param('slug').isString().trim().isLength({ min: 1, max: 191 }), - body('type').optional().isIn(['announcement']), + body('type').optional().isIn(['announcement', 'discussion']), body('title').isString().trim().isLength({ min: 1, max: 200 }), body('body').isString().isLength({ min: 1, max: 40000 }), validate, @@ -113,6 +113,63 @@ forumRouter.post( ctrl.moderateThread, ) +forumRouter.post( + '/:slug/forum/threads/:id/posts', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Reply to a discussion thread' + // #swagger.description = 'Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' }, postId: { type: 'integer' } } } } } } */ + /* #swagger.responses[400] = { description: 'Announcements do not take replies', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'The thread is locked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + postLimiter, + param('id').isInt({ min: 1 }).toInt(), + body('body').isString().isLength({ min: 1, max: 40000 }), + validate, + ctrl.createPost, +) + +forumRouter.patch( + '/:slug/forum/posts/:id', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Edit a post' + // #swagger.description = 'The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Edited', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */ + /* #swagger.responses[403] = { description: 'Not your post, or the edit window has closed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Forum off, no such post, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + postLimiter, + param('id').isInt({ min: 1 }).toInt(), + body('body').isString().isLength({ min: 1, max: 40000 }), + validate, + ctrl.editPost, +) + +forumRouter.post( + '/:slug/forum/posts/:id/moderate', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Hide, unhide, delete or restore a post' + // #swagger.description = 'Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so "everything moderated in this Team" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */ + /* #swagger.responses[400] = { description: 'An action that applies to a thread, not a post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('action').isIn(['hide', 'unhide', 'delete', 'restore']), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.moderatePost, +) + // ── grants ───────────────────────────────────────────────────────────────── forumRouter.get( -- 2.49.1 From fff14848f18ae19cb990fe9d111b25e24b23f959 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 12:51:42 -0500 Subject: [PATCH 2/5] feat(moderation): member-raised abuse reports, to site staff only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db/schema.sql | 69 + server/routes.guards.json | 64 + server/routes.manifest.json | 24 + server/src/model/reports/contentReports.db.js | 154 +++ .../src/model/reports/contentReports.model.js | 231 ++++ server/src/model/teams/teamForum.db.js | 16 + .../src/router/v1/admin/admin.controller.js | 14 + .../router/v1/admin/moderation.controller.js | 65 + .../src/router/v1/admin/moderation.router.js | 34 +- .../router/v1/player/teamForum.controller.js | 41 + .../src/router/v1/player/teamForum.router.js | 34 + server/swagger/swagger-output.json | 1182 ++++++++++++++++- server/swagger/swagger.js | 50 + 13 files changed, 1972 insertions(+), 6 deletions(-) create mode 100644 server/src/model/reports/contentReports.db.js create mode 100644 server/src/model/reports/contentReports.model.js diff --git a/server/db/schema.sql b/server/db/schema.sql index db13dec..b28e013 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1134,6 +1134,69 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads ( INDEX idx_tfu_sweep (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of +-- any kind before this**: `moderation`, `mod_notes` and `appeals` are all either +-- staff-initiated or Discord-sanction-shaped, and nothing anywhere let a MEMBER +-- say "this is a problem". That was survivable while every piece of content on +-- the site came from staff. It stops being survivable the moment a Team forum +-- lets players write to each other, and stops twice over when `uploads` mode lets +-- them put files on the operator's disk under a signed liability acknowledgement. +-- +-- The gap has a specific shape worth naming: leaders moderate their own Team's +-- forum, and a Team's leaders are exactly the people who will not report their own +-- Team. So this table's whole point is a path that routes AROUND a Team's own +-- leadership — **reports go to site staff and to nobody else.** There is +-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a +-- leader-visible report about a leader is not a report. +-- +-- Not a `team_*` table, and not named for the forum: `target_type` is a plain +-- VARCHAR so wiki pages, news comments and profile fields become new values +-- rather than new tables. Team forum content is only the first consumer. +-- +-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key +-- as (target_type, target_id, reporter_user_id, status), and that spelling has a +-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide +-- with each other too. A reporter reports a post, staff dismiss it, the behaviour +-- recurs, they report it again — and the second dismissal is an UPDATE into a +-- (…, 'dismissed') tuple that already exists, so working the queue would start +-- throwing duplicate-key errors after the first repeat reporter. +-- +-- The generated marker is the same trick `team_forum_grants.active_marker` uses: +-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats +-- NULLs as distinct, so any number of closed reports coexist while at most one +-- open one can. That is what §5.6's prose actually asks for — "one open report per +-- (target, reporter)". +-- +-- NULL reporters (deleted accounts) are distinct for the same reason, which is +-- also wanted: nothing should collapse two dead accounts' reports into one. +-- +-- `handled_note` is not in the design doc and earns its place: a queue whose +-- resolution reason lives only in an activity_log line is one where the next +-- staffer to see a repeat report cannot find out why the last one was dismissed. +CREATE TABLE IF NOT EXISTS content_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload' + target_id BIGINT NOT NULL, + team_id INT NULL, -- denormalised for the queue's filters + reporter_user_id INT NULL, + reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account + reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL, + detail VARCHAR(500) NULL, + status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open', + handled_by INT NULL, + handled_username VARCHAR(32) NULL, -- snapshot, same reason + handled_note VARCHAR(500) NULL, + handled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED, + CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker), + INDEX idx_cr_queue (status, created_at), + INDEX idx_cr_team (team_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- The §2.9 approval queue. A MODERATOR performing one of the three actions that -- publish untrusted game-sourced strings creates a pending row here; an ADMIN -- performing one applies it immediately. Rows are kept after a decision — "a @@ -1233,6 +1296,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL; -- so the system behaves exactly as today until an admin opts in. INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); +-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather +-- than left absent so the value an operator sees on the settings screen is the +-- value in force — an empty field that silently behaves as 15 is a field nobody +-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs. +INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15'); + ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; diff --git a/server/routes.guards.json b/server/routes.guards.json index 5651a8f..2d8b6dc 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -345,6 +345,26 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/reports", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/moderation/reports/:id/handle", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/admin/moderation/search", @@ -1687,6 +1707,39 @@ "requireAuth" ] }, + { + "method": "PATCH", + "path": "/api/v1/player/teams/:slug/forum/posts/:id", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/report", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/player/teams/:slug/forum/threads", @@ -1729,6 +1782,17 @@ "validate" ] }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/posts", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "POST", "path": "/api/v1/player/teams/:slug/forum/uploads", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 917214d..b6be23f 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -145,6 +145,14 @@ "method": "GET", "path": "/api/v1/admin/moderation/recent" }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/reports" + }, + { + "method": "POST", + "path": "/api/v1/admin/moderation/reports/:id/handle" + }, { "method": "GET", "path": "/api/v1/admin/moderation/search" @@ -677,6 +685,18 @@ "method": "GET", "path": "/api/v1/player/teams/:slug/access" }, + { + "method": "PATCH", + "path": "/api/v1/player/teams/:slug/forum/posts/:id" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate" + }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/report" + }, { "method": "GET", "path": "/api/v1/player/teams/:slug/forum/threads" @@ -693,6 +713,10 @@ "method": "POST", "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate" }, + { + "method": "POST", + "path": "/api/v1/player/teams/:slug/forum/threads/:id/posts" + }, { "method": "POST", "path": "/api/v1/player/teams/:slug/forum/uploads" diff --git a/server/src/model/reports/contentReports.db.js b/server/src/model/reports/contentReports.db.js new file mode 100644 index 0000000..8194714 --- /dev/null +++ b/server/src/model/reports/contentReports.db.js @@ -0,0 +1,154 @@ +// SQL for `content_reports` (TEAMS.md §5.6). +// +// Not under model/teams/ even though Team forum content is its only consumer +// today: the table is deliberately generic — `target_type` is a VARCHAR so that a +// wiki page or a news comment becomes a new value rather than a new table — and +// filing it under a feature it will outgrow is how the next consumer ends up +// building its own. +// +// Nothing here decides who may read a report. That is the route's job, and there +// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling +// that reports are site administration only). + +const { query } = require('../../utils/db') + +const COLUMNS = ` + id, target_type, target_id, team_id, reporter_user_id, reporter_username, + reason, detail, status, handled_by, handled_username, handled_note, handled_at, + created_at` + +const OPEN_STATUSES = ['open', 'reviewing'] + +/** + * File a report. + * + * The duplicate is caught by the unique key rather than by a SELECT first, which + * is the difference between "usually not a duplicate" and "never a duplicate": + * two taps of a report button race, and only the index settles it. ER_DUP_ENTRY + * comes back as a clean `null` so the caller can answer 409 without knowing what + * a MySQL error code looks like. + */ +async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) { + try { + const res = await query( + `INSERT INTO content_reports + (target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null], + ) + return res.insertId + } catch (err) { + if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null + throw err + } +} + +async function byId(id) { + const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +/** + * The queue. + * + * `status` defaults to the two OPEN statuses rather than to everything: a staffer + * opening the queue wants the work, not the archive. 'all' is the explicit escape + * hatch and every single status is selectable, so nothing is unreachable. + */ +async function list({ status, teamId, limit = 100, offset = 0 } = {}) { + const where = [] + const args = [] + if (status && status !== 'all') { + where.push('status = ?') + args.push(status) + } else if (!status) { + where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`) + args.push(...OPEN_STATUSES) + } + if (teamId) { + where.push('team_id = ?') + args.push(teamId) + } + args.push(limit, offset) + return query( + `SELECT ${COLUMNS} FROM content_reports + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, + args, + ) +} + +/** How many are waiting, for the dashboard badge. */ +async function openCount() { + const rows = await query( + `SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`, + OPEN_STATUSES, + ) + return Number(rows[0]?.n || 0) +} + +/** + * Record a staffer's decision. + * + * `handled_*` is stamped for every status including `reviewing`, so "who has this" + * is answerable while it is in progress and not only after it is closed — that is + * what stops two staffers working the same report. + */ +async function handle(id, { status, handledBy, handledUsername, note }) { + const res = await query( + `UPDATE content_reports + SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW() + WHERE id = ?`, + [status, handledBy, handledUsername, note ?? null, id], + ) + return res.affectedRows > 0 +} + +// ── target enrichment ────────────────────────────────────────────────────── +// +// Three batched reads rather than one per row. §5.6's fourth rule — "reports on +// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and +// sniffed type without hunting" — is the reason the queue enriches at all, and a +// queue that N+1s to do it would be the version that gets turned off. + +async function threadsByIds(ids) { + if (!ids.length) return [] + return query( + `SELECT id, team_id, title, type, status, created_username FROM team_forum_threads + WHERE id IN (${ids.map(() => '?').join(',')})`, + ids, + ) +} + +async function postsByIds(ids) { + if (!ids.length) return [] + return query( + `SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status, + p.created_at, t.team_id, t.title AS thread_title + FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id + WHERE p.id IN (${ids.map(() => '?').join(',')})`, + ids, + ) +} + +async function uploadsByIds(ids) { + if (!ids.length) return [] + return query( + `SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename, + mimetype, byte_size, created_at, deleted_at + FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`, + ids, + ) +} + +module.exports = { + OPEN_STATUSES, + insert, + byId, + list, + openCount, + handle, + threadsByIds, + postsByIds, + uploadsByIds, +} diff --git a/server/src/model/reports/contentReports.model.js b/server/src/model/reports/contentReports.model.js new file mode 100644 index 0000000..47bd817 --- /dev/null +++ b/server/src/model/reports/contentReports.model.js @@ -0,0 +1,231 @@ +// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ────────── +// +// Two rules shape everything in this file, and both are easier to break than to +// notice broken: +// +// 1. **A report is not a moderation action.** Filing one changes nothing about +// the content — it opens a queue item. That keeps it clear of §5.3's +// leader/staff moderation ledger, which records things that actually +// happened. If reporting hid a post, reporting would BE moderation, and the +// first person to work that out would have found a way to hide anything. +// +// 2. **Reports go to site staff and to nobody else.** The gap §5.6 exists to +// close has a specific shape: leaders moderate their own Team's forum, and a +// Team's leaders are exactly the people who will not report their own Team. +// A leader-visible queue would route a complaint about a leader back to that +// leader. The org lead settled this on 2026-08-18 — reports are **site +// administration only**, with no leader-facing view at all, not even a +// read-only one scoped to their own Team. +// +// The reporter's ACCESS is the caller's business, not this file's: the player +// route resolves the forum first, so anyone reaching `file()` is someone who can +// already see the thing they are reporting. What this file does check is that the +// target is really in the Team the caller reached it through — otherwise a +// participant in one Team could file reports carrying another Team's id, and the +// queue's per-Team filter would quietly be lying. + +const reportsDb = require('./contentReports.db') +const forumDb = require('../teams/teamForum.db') + +const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload'] +const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'] +const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed'] + +// A body excerpt for the queue, not a rendered post. Staff triage on what was +// written, and `body_html` is stored already sanitised — but the queue is a list, +// so it gets text and a length cap rather than markup. +const EXCERPT_CHARS = 300 +const excerpt = (html) => String(html || '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, EXCERPT_CHARS) + +/** + * Does this target exist, and is it in this Team? + * + * Returns the team id the target really belongs to, or null. The caller compares + * it with the Team the request came through — a mismatch is a 404 for the same + * §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else + * on the site is itself a disclosure. + */ +async function targetTeamId(targetType, targetId) { + if (targetType === 'team_forum_thread') { + const thread = await forumDb.threadById(targetId) + return thread ? thread.team_id : null + } + if (targetType === 'team_forum_post') { + const post = await forumDb.postById(targetId) + if (!post) return null + const thread = await forumDb.threadById(post.thread_id) + return thread ? thread.team_id : null + } + if (targetType === 'team_forum_upload') { + const upload = await forumDb.uploadById(targetId) + return upload ? upload.team_id : null + } + return null +} + +/** + * File a report. + * + * A duplicate answers 409 rather than pretending to succeed. Silently accepting + * it would be friendlier for one tap and dishonest for the second: a member who + * reports twice because nothing seemed to happen deserves to be told the first + * one is already in the queue. + */ +async function file({ team, actor, targetType, targetId, reason, detail }) { + if (!TARGET_TYPES.includes(targetType)) { + return { ok: false, status: 400, error: 'Unknown report target' } + } + if (!REASONS.includes(reason)) { + return { ok: false, status: 400, error: 'Unknown report reason' } + } + + const owner = await targetTeamId(targetType, targetId) + if (owner == null || owner !== team.id) { + return { ok: false, status: 404, error: 'Not found' } + } + + const id = await reportsDb.insert({ + targetType, + targetId, + teamId: team.id, + reporterUserId: actor.id, + reporterUsername: actor.username, + reason, + detail, + }) + if (id == null) { + return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' } + } + return { ok: true, reportId: id } +} + +/** + * The staff queue, with each row's target attached. + * + * Enrichment is three batched reads keyed by target type, not one read per row. + * The alternative N+1s a page of a hundred into three hundred queries, which is + * how a queue becomes a thing staff avoid opening. + * + * A target that has since been hard-deleted comes back as `null`, and the report + * still lists. That is deliberate: "somebody reported this and by the time we + * looked it was gone" is a fact a moderator needs, and dropping the row would + * hide the pattern of a member deleting their own content the moment it is + * reported. + */ +async function queue({ status, teamId, limit, offset } = {}) { + const rows = await reportsDb.list({ status, teamId, limit, offset }) + if (!rows.length) return [] + + const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id)) + const [threads, posts, uploads] = await Promise.all([ + reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]), + reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]), + reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]), + ]) + + const byId = (list) => new Map(list.map((row) => [Number(row.id), row])) + const threadMap = byId(threads) + const postMap = byId(posts) + const uploadMap = byId(uploads) + + return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) })) +} + +function describeTarget(report, { threadMap, postMap, uploadMap }) { + const id = Number(report.target_id) + if (report.target_type === 'team_forum_thread') { + const t = threadMap.get(id) + return t && { + kind: 'thread', + threadId: t.id, + title: t.title, + type: t.type, + status: t.status, + author: t.created_username, + } + } + if (report.target_type === 'team_forum_post') { + const p = postMap.get(id) + return p && { + kind: 'post', + postId: p.id, + threadId: p.thread_id, + threadTitle: p.thread_title, + author: p.author_username, + status: p.status, + excerpt: excerpt(p.body_html), + createdAt: p.created_at, + } + } + if (report.target_type === 'team_forum_upload') { + const u = uploadMap.get(id) + // §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting. + // This is the payoff for §5.5.4's attribution table being load-bearing rather + // than bookkeeping. + return u && { + kind: 'upload', + uploadId: u.id, + postId: u.post_id, + uploader: u.uploader_username, + filename: u.filename, + url: `/uploads/${u.filename}`, + mimetype: u.mimetype, + byteSize: u.byte_size, + createdAt: u.created_at, + deleted: u.deleted_at != null, + } + } + return null +} + +function publicReport(row) { + return { + id: row.id, + targetType: row.target_type, + targetId: Number(row.target_id), + teamId: row.team_id, + reporter: row.reporter_username || '[deleted account]', + reporterDeleted: row.reporter_user_id == null, + reason: row.reason, + detail: row.detail, + status: row.status, + handledBy: row.handled_username, + handledNote: row.handled_note, + handledAt: row.handled_at, + createdAt: row.created_at, + } +} + +/** Move a report along the queue. Staff-only by its route. */ +async function handle({ id, actor, status, note }) { + if (!STATUSES.includes(status)) { + return { ok: false, status: 400, error: 'Unknown report status' } + } + const report = await reportsDb.byId(id) + if (!report) return { ok: false, status: 404, error: 'Report not found' } + + await reportsDb.handle(id, { + status, + handledBy: actor.id, + handledUsername: actor.username, + note, + }) + return { ok: true, report: publicReport(await reportsDb.byId(id)) } +} + +module.exports = { + TARGET_TYPES, + REASONS, + STATUSES, + EXCERPT_CHARS, + file, + queue, + handle, + openCount: reportsDb.openCount, + publicReport, + targetTeamId, +} diff --git a/server/src/model/teams/teamForum.db.js b/server/src/model/teams/teamForum.db.js index 6fa7c07..d4d7d95 100644 --- a/server/src/model/teams/teamForum.db.js +++ b/server/src/model/teams/teamForum.db.js @@ -224,6 +224,22 @@ async function softDeleteUploadsForPost(postId, deletedBy) { ) } +/** + * The other half of the pair: a restored post gets its images back. + * + * Without this, `delete` then `restore` returns the words and loses the pictures — + * and loses them SILENTLY, because the soft-deleted rows survive the retention + * window before the sweep takes the bytes, so the post looks fine until the night + * it does not. Beyond that window the row itself is gone and this is a no-op; + * nothing can be done about that and nothing should pretend otherwise. + */ +async function restoreUploadsForPost(postId) { + await query( + 'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL', + [postId], + ) +} + /** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */ async function sweepableUploads(retentionDays) { return query( diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index cec162f..c91fc7e 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -612,6 +612,20 @@ async function updateSettings(req, res) { const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge) if (!gate.ok) return res.status(gate.status).json({ message: gate.error }) } + if (forumSettings.EDIT_WINDOW_KEY in updates) { + // The post edit window (phase 5). An ordinary key with a range, validated + // here rather than left to the model's read-side clamp: a read that silently + // coerces a nonsense value back to the default is right for a hand-edited + // row and wrong for an admin who just typed one, who should be told. + const raw = updates[forumSettings.EDIT_WINDOW_KEY] + const n = Number(raw) + if (!Number.isInteger(n) || n < 0 || n > forumSettings.EDIT_WINDOW_MAX) { + return res.status(400).json({ + message: `teams_forum_edit_window_minutes must be a whole number of minutes between 0 and ${forumSettings.EDIT_WINDOW_MAX}`, + }) + } + updates[forumSettings.EDIT_WINDOW_KEY] = String(n) + } { // The stale-acknowledgement lock: a reworded notice freezes the forum // settings until it is re-given, and does NOT turn uploads off (§5.5.5). diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js index b4f1f5d..711556a 100644 --- a/server/src/router/v1/admin/moderation.controller.js +++ b/server/src/router/v1/admin/moderation.controller.js @@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model') const modNotes = require('../../../model/modNotes/modNotes.model') const modNotesDb = require('../../../model/modNotes/modNotes.db') const appeals = require('../../../model/appeals/appeals.model') +const contentReports = require('../../../model/reports/contentReports.model') const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure') const botInternalClient = require('../../../utils/botInternalClient') const activity = require('../../../model/activity/activity.model') @@ -295,6 +296,68 @@ async function getUserAppeals(req, res) { } } +// ── Content reports (TEAMS.md §5.6) ─────────────────────────────────────── +// +// Mounted here rather than under Teams, and that placement is the design: a +// staffer working a queue should have one place to work, and a report about a +// forum post is the same job as a report about anything else. `target_type` is a +// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives +// as a value in this same queue and not as a second screen. +// +// **This is the only view of the queue that exists.** Team leaders have no +// report-facing surface at all, because the gap §5.6 closes is that a Team's +// leaders are exactly the people who will not report their own Team. Org lead, +// 2026-08-18: reports are site administration only. + +async function getContentReports(req, res) { + try { + const { limit, offset } = pageParams(req) + const status = typeof req.query.status === 'string' ? req.query.status : undefined + if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) { + return res.status(400).json({ message: 'Unknown report status' }) + } + const teamId = Number(req.query.teamId) || undefined + return res.json({ + reports: await contentReports.queue({ status, teamId, limit, offset }), + openCount: await contentReports.openCount(), + }) + } catch (err) { + log.error('getContentReports failed', { error: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +/** + * Move a report along the queue. + * + * Every transition writes `activity_log`, including `dismissed` — especially + * `dismissed`. A queue where acting is audited and declining to act is not is one + * where the cheapest way to make a report disappear leaves no trace, and the + * reports most worth auditing are exactly the ones somebody wanted gone. + */ +async function handleContentReport(req, res) { + try { + const result = await contentReports.handle({ + id: Number(req.params.id), + actor: req.user, + status: req.body.status, + note: req.body.note, + }) + if (!result.ok) return res.status(result.status || 400).json({ message: result.error }) + + await activity.log({ + req, + action: 'moderation.report.handle', + detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}` + + `${req.body.note ? `: "${req.body.note}"` : ''}`, + }) + return res.json(result.report) + } catch (err) { + log.error('handleContentReport failed', { error: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + module.exports = { getSummary, getRecent, @@ -311,4 +374,6 @@ module.exports = { claimAppeal, resolveAppeal, getUserAppeals, + getContentReports, + handleContentReport, } diff --git a/server/src/router/v1/admin/moderation.router.js b/server/src/router/v1/admin/moderation.router.js index ee659bd..f697daa 100644 --- a/server/src/router/v1/admin/moderation.router.js +++ b/server/src/router/v1/admin/moderation.router.js @@ -1,4 +1,5 @@ -// Admin · Moderation — the moderation dashboard and the appeals queue. +// Admin · Moderation — the moderation dashboard, the appeals queue and the +// member-raised content-report queue (TEAMS.md §5.6). // // Mounted at /api/v1/admin/moderation by admin/index.js, which already applied // `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's @@ -16,6 +17,7 @@ const express = require('express') const { body, param } = require('express-validator') const moderation = require('./moderation.controller') +const contentReports = require('../../../model/reports/contentReports.model') const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') @@ -171,4 +173,34 @@ moderationRouter.get( moderation.getUserAppeals, ) +// ── Content reports (TEAMS.md §5.6) ─────────────────────────────────────── +// Beside appeals rather than under Teams: a staffer working a queue should have +// one place to work. There is no leader-facing counterpart to these two routes +// and there is not meant to be — see the controller. +moderationRouter.get( + '/reports', + // #swagger.tags = ['Admin · Moderation'] + // #swagger.summary = 'The member-raised content report queue' + // #swagger.description = 'Defaults to the open work (`open` + `reviewing`); filter with ?status= and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a post’s excerpt and author, a thread’s title, or an upload’s uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: "somebody reported this and by the time we looked it was gone" is a fact worth seeing.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The queue', content: { "application/json": { schema: { type: 'object', properties: { reports: { type: 'array', items: { $ref: "#/components/schemas/ContentReport" } }, openCount: { type: 'integer' } } } } } } */ + moderation.getContentReports, +) +moderationRouter.post( + '/reports/:id/handle', + // #swagger.tags = ['Admin · Moderation'] + // #swagger.summary = 'Claim, action or dismiss a content report' + // #swagger.description = 'Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['open','reviewing','actioned','dismissed'] }, note: { type: 'string', maxLength: 500 } } } } } } */ + /* #swagger.responses[200] = { description: 'The updated report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */ + /* #swagger.responses[404] = { description: 'Report not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }), + body('status').isIn(contentReports.STATUSES), + body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }), + validate, + moderation.handleContentReport, +) + module.exports = moderationRouter diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js index 6d1cee6..204c248 100644 --- a/server/src/router/v1/player/teamForum.controller.js +++ b/server/src/router/v1/player/teamForum.controller.js @@ -24,6 +24,7 @@ const grants = require('../../../model/teams/teamGrants.model') const forum = require('../../../model/teams/teamForum.model') const forumSettings = require('../../../model/teams/teamForumSettings.model') const uploads = require('../../../model/teams/teamForumUploads.model') +const reports = require('../../../model/reports/contentReports.model') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('teams') @@ -359,6 +360,45 @@ async function revokeGrant(req, res) { } } +// ── abuse reports (§5.6) ─────────────────────────────────────────────────── + +/** + * File a report about a thread, a post or an upload. + * + * **This is the one write in this file that does nothing to the content.** A + * report opens a queue item and changes no status, no flag and no counter — which + * is what keeps it out of §5.3's moderation ledger, and what stops "report" from + * becoming a way for any participant to hide anything. + * + * It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders + * moderate their own Team and a Team's leaders are exactly the people who will + * not report their own Team, so a leader-visible queue would hand a complaint + * about a leader straight back to them. There is deliberately no leader-facing + * view anywhere in this phase (org lead, 2026-08-18). + * + * The route sits behind the same `resolveForum` guard as everything else, so a + * reporter is by construction someone who can already see what they are + * reporting — and the model additionally checks the target really belongs to the + * Team the request came through, or the queue's per-Team filter would be lying. + */ +async function createReport(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + + return send(res, await reports.file({ + team: ctx.team, + actor: req.user, + targetType: req.body.targetType, + targetId: Number(req.body.targetId), + reason: req.body.reason, + detail: req.body.detail, + })) + } catch (err) { + return fail(res, err, 'create report') + } +} + // ── uploads (§5.5.4) ─────────────────────────────────────────────────────── /** @@ -409,4 +449,5 @@ module.exports = { revokeGrant, createUpload, deleteUpload, + createReport, } diff --git a/server/src/router/v1/player/teamForum.router.js b/server/src/router/v1/player/teamForum.router.js index 794dcc5..ffc1f40 100644 --- a/server/src/router/v1/player/teamForum.router.js +++ b/server/src/router/v1/player/teamForum.router.js @@ -16,6 +16,7 @@ const express = require('express') const { body, param } = require('express-validator') const ctrl = require('./teamForum.controller') +const contentReports = require('../../../model/reports/contentReports.model') const validate = require('../../../middleware/validate') const { makeLimiter } = require('../../../middleware/rateLimit') const { upload } = require('../admin/imageUpload') @@ -40,6 +41,17 @@ const grantLimiter = makeLimiter({ message: 'Too many grant changes. Please slow down.', }) +// Tightest of the three, and §5.6's third rule is why: a report costs the +// reporter nothing and costs a staffer attention, so the queue is the one surface +// here that can be used as a harassment tool. The unique key already stops +// duplicate open reports on one target; this stops a spread of them. +const reportLimiter = makeLimiter({ + windowMs: 60 * 60 * 1000, + max: 10, + label: 'team-forum-report', + message: 'Too many reports. Please give staff a chance to look at the ones you have raised.', +}) + // Bytes, not requests: the per-account daily quota lives in the uploads model, // and this is the per-IP flood guard in front of it. const uploadLimiter = makeLimiter({ @@ -219,6 +231,28 @@ forumRouter.delete( ctrl.revokeGrant, ) +// ── abuse reports (§5.6) ─────────────────────────────────────────────────── + +forumRouter.post( + '/:slug/forum/report', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Report a thread, post or upload to site staff' + // #swagger.description = 'The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Team’s moderation ledger and stops "report" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Team’s leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['targetType','targetId','reason'], properties: { targetType: { type: 'string', enum: ['team_forum_thread','team_forum_post','team_forum_upload'] }, targetId: { type: 'integer' }, reason: { type: 'string', enum: ['spam','abuse','sexual','illegal','impersonation','other'] }, detail: { type: 'string', maxLength: 500 } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */ + /* #swagger.responses[404] = { description: 'Forum off, no access, or the target is not in this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + reportLimiter, + body('targetType').isIn(contentReports.TARGET_TYPES), + body('targetId').isInt({ min: 1 }).toInt(), + body('reason').isIn(contentReports.REASONS), + body('detail').optional().isString().trim().isLength({ max: 500 }), + validate, + ctrl.createReport, +) + // ── uploads ──────────────────────────────────────────────────────────────── forumRouter.post( diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 6b0f874..8c0e56c 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -2050,6 +2050,152 @@ ] } }, + "/api/v1/admin/moderation/reports": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "The member-raised content report queue", + "description": "Defaults to the open work (`open` + `reviewing`); filter with ?status= and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a post’s excerpt and author, a thread’s title, or an upload’s uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: \"somebody reported this and by the time we looked it was gone\" is a fact worth seeing.", + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The queue", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentReport" + } + }, + "openCount": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/reports/{id}/handle": { + "post": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Claim, action or dismiss a content report", + "description": "Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Report id." + } + ], + "responses": { + "200": { + "description": "The updated report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContentReport" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Report not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "open", + "reviewing", + "actioned", + "dismissed" + ] + }, + "note": { + "type": "string", + "maxLength": 500 + } + } + } + } + } + } + } + }, "/api/v1/admin/moderation/search": { "get": { "tags": [ @@ -10252,6 +10398,356 @@ ] } }, + "/api/v1/player/teams/{slug}/forum/posts/{id}": { + "patch": { + "tags": [ + "Player · Teams" + ], + "summary": "Edit a post", + "description": "The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The post id." + } + ], + "responses": { + "200": { + "description": "Edited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "postId": { + "type": "integer" + }, + "threadId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your post, or the edit window has closed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Forum off, no such post, or no access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/player/teams/{slug}/forum/posts/{id}/moderate": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Hide, unhide, delete or restore a post", + "description": "Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so \"everything moderated in this Team\" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The post id." + } + ], + "responses": { + "200": { + "description": "Applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + }, + "postId": { + "type": "integer" + }, + "threadId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "An action that applies to a thread, not a post", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not a leader of this Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "hide", + "unhide", + "delete", + "restore" + ] + }, + "reason": { + "type": "string", + "maxLength": 255 + } + } + } + } + } + } + } + }, + "/api/v1/player/teams/{slug}/forum/report": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Report a thread, post or upload to site staff", + "description": "The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Team’s moderation ledger and stops \"report\" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Team’s leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "Raised", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "reportId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Forum off, no access, or the target is not in this Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "You already have an open report on this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "targetType", + "targetId", + "reason" + ], + "properties": { + "targetType": { + "type": "string", + "enum": [ + "team_forum_thread", + "team_forum_post", + "team_forum_upload" + ] + }, + "targetId": { + "type": "integer" + }, + "reason": { + "type": "string", + "enum": [ + "spam", + "abuse", + "sexual", + "illegal", + "impersonation", + "other" + ] + }, + "detail": { + "type": "string", + "maxLength": 500 + } + } + } + } + } + } + } + }, "/api/v1/player/teams/{slug}/forum/threads": { "get": { "tags": [ @@ -10314,8 +10810,8 @@ "tags": [ "Player · Teams" ], - "summary": "Post an announcement", - "description": "Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", + "summary": "Open a thread — an announcement or a discussion", + "description": "Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", "parameters": [ { "name": "slug", @@ -10353,7 +10849,7 @@ "description": "Unauthorized" }, "403": { - "description": "Not a leader of this Team", + "description": "Only a leader may post an announcement", "content": { "application/json": { "schema": { @@ -10391,8 +10887,10 @@ "type": { "type": "string", "enum": [ - "announcement" - ] + "announcement", + "discussion" + ], + "default": "announcement" }, "title": { "type": "string", @@ -10593,6 +11091,116 @@ } } }, + "/api/v1/player/teams/{slug}/forum/threads/{id}/posts": { + "post": { + "tags": [ + "Player · Teams" + ], + "summary": "Reply to a discussion thread", + "description": "Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The thread id." + } + ], + "responses": { + "200": { + "description": "Posted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "threadId": { + "type": "integer" + }, + "postId": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Announcements do not take replies", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "409": { + "description": "The thread is locked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + } + } + } + } + } + } + } + }, "/api/v1/player/teams/{slug}/forum/uploads": { "post": { "tags": [ @@ -15799,6 +16407,570 @@ } } }, + "ContentReport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A member-raised report about a piece of content (TEAMS.md §5.6). Generic by design: `targetType` is a string rather than an enum in the schema because a wiki page or a news comment is meant to become a new value here, not a new queue. Reports reach SITE STAFF only — there is no leader-facing view of this queue, because a Team's leaders are exactly the people who will not report their own Team." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 41 + } + } + }, + "targetType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "team_forum_post" + }, + "description": { + "type": "string", + "example": "team_forum_thread | team_forum_post | team_forum_upload" + } + } + }, + "targetId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 812 + } + } + }, + "teamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 7 + }, + "description": { + "type": "string", + "example": "Denormalised so the queue can filter by Team." + } + } + }, + "reporter": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + }, + "description": { + "type": "string", + "example": "Username snapshot; \"[deleted account]\" once the account is gone." + } + } + }, + "reporterDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "spam", + "abuse", + "sexual", + "illegal", + "impersonation", + "other" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "abuse" + } + } + }, + "detail": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "maxLength": { + "type": "number", + "example": 500 + }, + "example": { + "type": "string", + "example": "Personal attacks in the third paragraph." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "open", + "reviewing", + "actioned", + "dismissed" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "open" + } + } + }, + "handledBy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "moderator1" + } + } + }, + "handledNote": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Post hidden, author warned." + } + } + }, + "handledAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "target": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The reported content, already resolved so triage never means hunting. NULL when the target has since been hard-deleted — the report still lists, because \"somebody reported this and by the time we looked it was gone\" is a fact a moderator needs. An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4)." + }, + "properties": { + "type": "object", + "properties": { + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "thread", + "post", + "upload" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "post" + } + } + }, + "threadId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 19 + } + } + }, + "threadTitle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Raid night" + } + } + }, + "postId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 812 + } + } + }, + "uploadId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "announcement", + "discussion" + ], + "items": { + "type": "string" + } + } + } + }, + "author": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "someone" + } + } + }, + "uploader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "excerpt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Plain-text excerpt of the post body, capped at 300 characters." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "visible", + "hidden", + "deleted" + ], + "items": { + "type": "string" + } + } + } + }, + "filename": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "url": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "/uploads/a1b2c3.png" + } + } + }, + "mimetype": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "image/png" + }, + "description": { + "type": "string", + "example": "The sniffed type, never the client's header." + } + } + }, + "byteSize": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 184320 + } + } + }, + "deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + }, "AppealQueueItem": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 47731ee..6ec430f 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -607,6 +607,56 @@ const doc = { submitter_username: { type: 'string', nullable: true, example: 'newplayer' }, }, }, + ContentReport: { + type: 'object', + description: 'A member-raised report about a piece of content (TEAMS.md §5.6). ' + + 'Generic by design: `targetType` is a string rather than an enum in the schema ' + + 'because a wiki page or a news comment is meant to become a new value here, not a new queue. ' + + 'Reports reach SITE STAFF only — there is no leader-facing view of this queue, ' + + 'because a Team\'s leaders are exactly the people who will not report their own Team.', + properties: { + id: { type: 'integer', example: 41 }, + targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' }, + targetId: { type: 'integer', example: 812 }, + teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' }, + reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' }, + reporterDeleted: { type: 'boolean', example: false }, + reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' }, + detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' }, + status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' }, + handledBy: { type: 'string', nullable: true, example: 'moderator1' }, + handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' }, + handledAt: { type: 'string', format: 'date-time', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + target: { + type: 'object', + nullable: true, + description: 'The reported content, already resolved so triage never means hunting. ' + + 'NULL when the target has since been hard-deleted — the report still lists, because ' + + '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. ' + + 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).', + properties: { + kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' }, + threadId: { type: 'integer', nullable: true, example: 19 }, + threadTitle: { type: 'string', nullable: true, example: 'Raid night' }, + postId: { type: 'integer', nullable: true, example: 812 }, + uploadId: { type: 'integer', nullable: true }, + title: { type: 'string', nullable: true }, + type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] }, + author: { type: 'string', nullable: true, example: 'someone' }, + uploader: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' }, + status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] }, + filename: { type: 'string', nullable: true }, + url: { type: 'string', nullable: true, example: '/uploads/a1b2c3.png' }, + mimetype: { type: 'string', nullable: true, example: 'image/png', description: 'The sniffed type, never the client\'s header.' }, + byteSize: { type: 'integer', nullable: true, example: 184320 }, + deleted: { type: 'boolean', nullable: true }, + createdAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, AppealQueueItem: { allOf: [{ $ref: '#/components/schemas/Appeal' }], description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.', -- 2.49.1 From 128de0ff2ea57d234acc7cafd1ccd79b23ec1ffb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 12:58:15 -0500 Subject: [PATCH 3/5] test(teams): phase 5's server surface, and the negative property under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1008 pass (972 before). The tests worth reading first are the ones that pin a property no screen would look different without: * **The edit window is decided on the server, twice.** One test proves the read path stamps `canEdit` per post per viewer; another proves the WRITE path re-derives it from `created_at` and refuses a stale edit even though the client was told it could — because a time-bounded permission must not take its clock from the party it bounds. * **A locked thread refuses staff too**, asserted over member, leader and staff in one loop, at 409 rather than 403: well-formed request, refusing state. * **delete → restore is reversible for images.** Without the second half of the pair a restored post returns its words and loses its pictures a retention window later, silently — the test asserts both calls and that `hide` makes neither. * **Post moderation recomputes the thread's counters** rather than nudging them; the test runs hide → unhide → hide, which is the cycle a delta gets wrong. * **acceptance: nothing in the report model is reachable by a Team leader.** The negative property is the whole point of §5.6 and negatives are what nobody notices going, so it is asserted directly — the module's function surface is pinned, and `queue`/`handle` are checked not to mention leadership at all. If a leader-facing queue is ever wanted it is the org lead's decision, and this test is what makes somebody ask. * **A report never changes the content it is about**, proved by stubbing every mutation the forum has to throw. If filing a report touched a status then "report" would BE moderation, and the first person to work that out would have found a way to hide anything on the site. The test suite caught one real defect: `describeTarget` returned `undefined` for a hard-deleted target, and `undefined` is dropped by JSON.stringify — so the documented `target: null` would have reached clients as an absent key. Two phase-4 tests were updated rather than added to, both because phase 5 changed what they describe: `canPost` split into `canPost` (open a discussion, everyone) and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type. Phase 5's four new player routes are added to acceptance criterion 2's list, so "with the forum off every forum route 404s" keeps covering the whole surface. Co-Authored-By: Claude --- .../src/model/reports/contentReports.model.js | 18 +- server/test/contentReports.test.js | 296 ++++++++++++++++++ server/test/teamForum.test.js | 255 ++++++++++++++- server/test/teamRoutes.test.js | 104 +++++- 4 files changed, 660 insertions(+), 13 deletions(-) create mode 100644 server/test/contentReports.test.js diff --git a/server/src/model/reports/contentReports.model.js b/server/src/model/reports/contentReports.model.js index 47bd817..49fd435 100644 --- a/server/src/model/reports/contentReports.model.js +++ b/server/src/model/reports/contentReports.model.js @@ -135,11 +135,21 @@ async function queue({ status, teamId, limit, offset } = {}) { return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) })) } +/** + * The reported content, resolved. + * + * **Every miss returns `null`, never `undefined`.** They look interchangeable in + * JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so + * a hard-deleted target would reach the client as an ABSENT `target` key rather + * than as an explicit null, and the queue's own contract says nullable. A client + * distinguishing "gone" from "not resolved yet" would get it wrong. + */ 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 && { + if (!t) return null + return { kind: 'thread', threadId: t.id, title: t.title, @@ -150,7 +160,8 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) { } if (report.target_type === 'team_forum_post') { const p = postMap.get(id) - return p && { + if (!p) return null + return { kind: 'post', postId: p.id, threadId: p.thread_id, @@ -163,10 +174,11 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) { } if (report.target_type === 'team_forum_upload') { const u = uploadMap.get(id) + if (!u) return null // §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 && { + return { kind: 'upload', uploadId: u.id, postId: u.post_id, diff --git a/server/test/contentReports.test.js b/server/test/contentReports.test.js new file mode 100644 index 0000000..f905398 --- /dev/null +++ b/server/test/contentReports.test.js @@ -0,0 +1,296 @@ +// Member-raised abuse reports (docs/website/TEAMS.md §5.6). +// +// The property most worth protecting here is a negative one, and negatives are +// what nobody notices going: **reports reach site staff and nobody else.** The +// gap this feature closes is that leaders moderate their own Team's forum and a +// Team's leaders are exactly the people who will not report their own Team — so a +// leader-facing view, even a read-only one scoped to their own Team, would hand a +// complaint about a leader back to that leader. Org lead settled it on 2026-08-18: +// site administration only. The test at the bottom of this file is the one that +// fails if somebody adds one. + +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const reports = require('../src/model/reports/contentReports.model') +const reportsDb = require('../src/model/reports/contentReports.db') +const forumDb = require('../src/model/teams/teamForum.db') + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} +afterEach(() => { + while (saved.length) { + const [mod, name, original] = saved.pop() + mod[name] = original + } +}) + +const team = { id: 1, name: 'Ossuary' } +const reporter = { id: 11, username: 'wanderer' } + +// The world a report is filed into: one thread, one post in it, one upload, all +// in team 1. +function stubTargets({ teamId = 1 } = {}) { + patch(forumDb, 'threadById', async (id) => (id === 5 ? { id: 5, team_id: teamId } : null)) + patch(forumDb, 'postById', async (id) => (id === 80 ? { id: 80, thread_id: 5 } : null)) + patch(forumDb, 'uploadById', async (id) => (id === 3 ? { id: 3, team_id: teamId } : null)) +} + +let written = [] +function stubInsert({ duplicate = false } = {}) { + written = [] + patch(reportsDb, 'insert', async (row) => { + written.push(row) + return duplicate ? null : 41 + }) +} + +// ── filing ───────────────────────────────────────────────────────────────── + +test('a report can be filed against a thread, a post or an upload', async () => { + stubTargets() + stubInsert() + + const cases = [ + ['team_forum_thread', 5], + ['team_forum_post', 80], + ['team_forum_upload', 3], + ] + for (const [targetType, targetId] of cases) { + const result = await reports.file({ team, actor: reporter, targetType, targetId, reason: 'abuse' }) + assert.equal(result.ok, true, targetType) + assert.equal(result.reportId, 41) + } + assert.deepEqual(written.map((r) => r.targetType), cases.map((c) => c[0])) +}) + +test('a report never changes the content it is about', async () => { + stubTargets() + stubInsert() + // Rule 2 of §5.6, made structural: if filing a report touched a status, then + // "report" would BE moderation, and the first person to work that out would + // have found a way to hide anything on the site. + patch(forumDb, 'setPostStatus', async () => { throw new Error('a report must not moderate') }) + patch(forumDb, 'setThreadFlags', async () => { throw new Error('a report must not moderate') }) + patch(forumDb, 'insertModeration', async () => { throw new Error('a report is not a ledger entry') }) + + const result = await reports.file({ + team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'spam', + }) + assert.equal(result.ok, true) +}) + +test('a target in another Team reads as not found', async () => { + // 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. + stubTargets({ teamId: 999 }) + stubInsert() + + const result = await reports.file({ + team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse', + }) + assert.equal(result.ok, false) + assert.equal(result.status, 404) + assert.equal(written.length, 0) +}) + +test('a target that does not exist reads as not found, not as a 400', async () => { + stubTargets() + stubInsert() + const result = await reports.file({ + team, actor: reporter, targetType: 'team_forum_post', targetId: 9999, reason: 'abuse', + }) + assert.equal(result.status, 404) +}) + +test('an unknown target type or reason is refused before any lookup', async () => { + patch(forumDb, 'threadById', async () => { throw new Error('must not look up') }) + stubInsert() + + assert.equal((await reports.file({ + team, actor: reporter, targetType: 'wiki_page', targetId: 1, reason: 'abuse', + })).status, 400) + + assert.equal((await reports.file({ + team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'because', + })).status, 400) +}) + +test('a second open report on the same target answers 409 rather than pretending', async () => { + stubTargets() + stubInsert({ duplicate: true }) + + const result = await reports.file({ + team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse', + }) + assert.equal(result.ok, false) + assert.equal(result.status, 409) + // Silently accepting 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. + assert.match(result.error, /already reported/i) +}) + +test('the duplicate is caught by the index, not by a read-then-write', async () => { + stubTargets() + // The DB layer turns ER_DUP_ENTRY into a clean null, so two taps that race + // reach the same answer as two taps that do not. A SELECT-first check would + // give "usually not a duplicate". + patch(reportsDb, 'insert', reportsDb.insert) + const { insert } = require('../src/model/reports/contentReports.db') + assert.equal(typeof insert, 'function') +}) + +// ── the queue ────────────────────────────────────────────────────────────── + +const row = (over = {}) => ({ + id: 41, target_type: 'team_forum_post', target_id: 80, team_id: 1, + reporter_user_id: 11, reporter_username: 'wanderer', reason: 'abuse', + detail: null, status: 'open', handled_by: null, handled_username: null, + handled_note: null, handled_at: null, created_at: new Date(), ...over, +}) + +test('the queue resolves every row’s target in batched reads, not one per row', async () => { + const calls = { threads: 0, posts: 0, uploads: 0 } + patch(reportsDb, 'list', async () => [ + row({ id: 1, target_type: 'team_forum_post', target_id: 80 }), + row({ id: 2, target_type: 'team_forum_post', target_id: 81 }), + row({ id: 3, target_type: 'team_forum_thread', target_id: 5 }), + row({ id: 4, target_type: 'team_forum_upload', target_id: 3 }), + ]) + patch(reportsDb, 'postsByIds', async (ids) => { + calls.posts += 1 + return ids.map((id) => ({ + id, thread_id: 5, author_username: 'someone', body_html: '

Rude words

', + status: 'visible', created_at: new Date(), team_id: 1, thread_title: 'Raid night', + })) + }) + patch(reportsDb, 'threadsByIds', async (ids) => { + calls.threads += 1 + return ids.map((id) => ({ id, team_id: 1, title: 'Raid night', type: 'discussion', status: 'visible', created_username: 'someone' })) + }) + patch(reportsDb, 'uploadsByIds', async (ids) => { + calls.uploads += 1 + return ids.map((id) => ({ + id, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png', + mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null, + })) + }) + + const queue = await reports.queue({}) + assert.equal(queue.length, 4) + // Four rows, three reads. The N+1 version is the one that becomes a queue + // staff avoid opening. + assert.deepEqual(calls, { threads: 1, posts: 1, uploads: 1 }) +}) + +test('an upload report carries uploader, size and the SNIFFED type', async () => { + patch(reportsDb, 'list', async () => [row({ target_type: 'team_forum_upload', target_id: 3 })]) + patch(reportsDb, 'threadsByIds', async () => []) + patch(reportsDb, 'postsByIds', async () => []) + patch(reportsDb, 'uploadsByIds', async () => [{ + id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png', + mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null, + }]) + + const [item] = await reports.queue({}) + // §5.6's fourth rule — and the payoff for §5.5.4's attribution table being + // load-bearing rather than bookkeeping. + assert.equal(item.target.kind, 'upload') + assert.equal(item.target.uploader, 'someone') + assert.equal(item.target.byteSize, 184320) + assert.equal(item.target.mimetype, 'image/png') + assert.equal(item.target.url, '/uploads/a1b2.png') +}) + +test('a post report carries a plain-text excerpt, capped', async () => { + patch(reportsDb, 'list', async () => [row()]) + patch(reportsDb, 'threadsByIds', async () => []) + patch(reportsDb, 'uploadsByIds', async () => []) + patch(reportsDb, 'postsByIds', async () => [{ + id: 80, thread_id: 5, author_username: 'someone', status: 'visible', + body_html: `

${'x'.repeat(500)}

link`, + created_at: new Date(), team_id: 1, thread_title: 'Raid night', + }]) + + const [item] = await reports.queue({}) + assert.equal(item.target.excerpt.length, reports.EXCERPT_CHARS) + assert.ok(!item.target.excerpt.includes('<'), 'the queue triages on text, not markup') +}) + +test('a report whose target is already gone still lists, with a null target', async () => { + patch(reportsDb, 'list', async () => [row()]) + patch(reportsDb, 'threadsByIds', async () => []) + patch(reportsDb, 'postsByIds', async () => []) // hard-deleted since + patch(reportsDb, 'uploadsByIds', async () => []) + + const [item] = await reports.queue({}) + // Dropping the row would hide the pattern of a member deleting their own + // content the moment it is reported. + assert.equal(item.id, 41) + assert.equal(item.target, null) +}) + +test('a deleted reporter still shows as somebody, and is marked deleted', async () => { + patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })]) + patch(reportsDb, 'threadsByIds', async () => []) + patch(reportsDb, 'postsByIds', async () => []) + patch(reportsDb, 'uploadsByIds', async () => []) + + const [item] = await reports.queue({}) + assert.equal(item.reporter, '[deleted account]') + assert.equal(item.reporterDeleted, true) +}) + +// ── handling ─────────────────────────────────────────────────────────────── + +test('handling records who decided, when, and why', async () => { + const updates = [] + patch(reportsDb, 'byId', async () => row()) + patch(reportsDb, 'handle', async (id, patchRow) => { updates.push([id, patchRow]); return true }) + + const staff = { id: 2, username: 'root' } + const result = await reports.handle({ id: 41, actor: staff, status: 'dismissed', note: 'Nothing in it.' }) + assert.equal(result.ok, true) + assert.deepEqual(updates, [[41, { + status: 'dismissed', handledBy: 2, handledUsername: 'root', note: 'Nothing in it.', + }]]) +}) + +test('an unknown status is refused, and an absent report is 404', async () => { + patch(reportsDb, 'byId', async () => null) + patch(reportsDb, 'handle', async () => { throw new Error('must not write') }) + + assert.equal((await reports.handle({ + id: 41, actor: { id: 2, username: 'root' }, status: 'obliterated', + })).status, 400) + + assert.equal((await reports.handle({ + id: 41, actor: { id: 2, username: 'root' }, status: 'actioned', + })).status, 404) +}) + +// ── the negative property ────────────────────────────────────────────────── + +test('acceptance: nothing in the report model is reachable by a Team leader', () => { + // §5.6's whole point is a path that routes AROUND a Team's own leadership. The + // model exposes exactly three verbs — file, queue, handle — and `queue` and + // `handle` are mounted ONLY under /admin/moderation, which is gated to + // admin+moderator. There is deliberately no leader-scoped variant of either, + // and no `teamId`-scoped authority check that a leader could satisfy: the only + // teamId this model takes is a FILTER on a staff view. + // + // If a leader-facing queue is ever wanted, it is a design decision for the org + // lead and not a refactor — which is what this test is here to make somebody + // notice. + const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function') + assert.deepEqual(surface.sort(), ['file', 'handle', 'openCount', 'publicReport', 'queue', 'targetTeamId']) + + // `handle` takes the actor and never a Team: there is no seat at this table for + // "the leader of the Team the report is about". + assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.handle.toString())) + assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.queue.toString())) +}) diff --git a/server/test/teamForum.test.js b/server/test/teamForum.test.js index da8914f..ebe123f 100644 --- a/server/test/teamForum.test.js +++ b/server/test/teamForum.test.js @@ -1,7 +1,7 @@ -// The forum's access model, its switches, and its renderer -// (docs/website/TEAMS.md Part 5, phase 4 "5a"). +// The forum's access model, its switches, its renderer (phase 4, "5a") and its +// discussion half (phase 5, "5b") — docs/website/TEAMS.md Part 5. // -// The four tests named "acceptance" are §Phase 4's four acceptance criteria, +// The tests named "acceptance" are the phases' stated acceptance criteria, // verbatim. They are the ones to read first, and the ones not to weaken: each // names a property that the code around it can lose without any screen looking // different. @@ -306,16 +306,18 @@ test('a member who is also a grantee is listed as a member, not as a guest', asy // ── threads (§5.1, §5.3) ─────────────────────────────────────────────────── -test('5a creates announcements and refuses discussion threads', async () => { +test('both thread types are creatable, and an invented one is not', async () => { patch(forumDb, 'insertThread', async () => 1) patch(forumDb, 'insertPost', async () => 1) - const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '

Hi

' }) - assert.equal(ok.ok, true) + // Phase 5 opened `discussion`. Neither type needed a migration: both have been + // in the enum since 5a, which is what §5.1's split-by-layer bought. + for (const type of ['announcement', 'discussion']) { + const ok = await forum.createThread({ team, actor: leader, type, title: 'Raid', body: '

Hi

' }) + assert.equal(ok.ok, true, type) + } - // The type exists in the enum from day one so 5b adds no migration — but - // nothing creates one yet. - const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: '

Hi

' }) + const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: '

Hi

' }) assert.equal(refused.ok, false) assert.equal(refused.status, 400) }) @@ -372,3 +374,238 @@ test('a RIFF container that is not WebP is not accepted as one', () => { const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)]) assert.equal(uploads.sniff(wav), null) }) + + +// ── phase 5 ("5b"): replies, the edit window, post moderation ────────────── + +const member = { id: 11, username: 'wanderer', role: 'player' } + +// A visible discussion thread and one post in it, as the DB layer would return +// them. Written as a factory rather than a shared constant because half these +// tests mutate the row they are given. +const discussion = (over = {}) => ({ + id: 5, team_id: 1, type: 'discussion', title: 'Raid night', + status: 'visible', locked: 0, pinned: 0, post_count: 1, + created_by: 11, created_username: 'wanderer', ...over, +}) +const post = (over = {}) => ({ + id: 80, thread_id: 5, author_user_id: 11, author_username: 'wanderer', + body_html: '

Hi

', status: 'visible', created_at: new Date(), edited_at: null, + edited_by: null, ...over, +}) + +test('a reply lands on a discussion thread and never on an announcement', async () => { + patch(forumDb, 'insertPost', async () => 81) + + patch(forumDb, 'threadById', async () => discussion()) + const ok = await forum.createPost({ team, threadId: 5, actor: member, body: '

Count me in

' }) + assert.equal(ok.ok, true) + assert.equal(ok.postId, 81) + + // 400, not 404 and not 409: the request is malformed FOR THIS THREAD and no + // amount of retrying fixes it. An announcement takes no replies by TYPE. + patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' })) + const refused = await forum.createPost({ team, threadId: 5, actor: member, body: '

Hi

' }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 400) +}) + +test('a locked thread refuses replies with 409 — and refuses staff too', async () => { + patch(forumDb, 'insertPost', async () => { throw new Error('must not write') }) + patch(forumDb, 'threadById', async () => discussion({ locked: 1 })) + + for (const actor of [member, leader, staff]) { + const refused = await forum.createPost({ team, threadId: 5, actor, body: '

Hi

' }) + assert.equal(refused.ok, false, actor.username) + // Well-formed request, refusing STATE — which is the distinction a client + // needs to tell "you cannot" from "not right now". + assert.equal(refused.status, 409, actor.username) + } +}) + +test('a reply to a hidden or foreign thread reads as not found', async () => { + patch(forumDb, 'insertPost', async () => { throw new Error('must not write') }) + + patch(forumDb, 'threadById', async () => discussion({ status: 'hidden' })) + assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '

x

' })).status, 404) + + patch(forumDb, 'threadById', async () => discussion({ team_id: 999 })) + assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '

x

' })).status, 404) +}) + +test('the edit window is decided on the server, from created_at', () => { + const fresh = post({ created_at: new Date(Date.now() - 60_000) }) // a minute old + const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) // an hour old + + assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 15 }).canEdit, true) + assert.equal(forum.editability(stale, { userId: 11, windowMinutes: 15 }).canEdit, false) + + // Somebody else's post, inside the window, is still not theirs to edit. + assert.equal(forum.editability(fresh, { userId: 99, windowMinutes: 15 }).canEdit, false) + + // Staff are not time-bounded, and `editableUntil: null` reads as "no deadline" + // rather than as "no permission" — canEdit is the permission. + const asStaff = forum.editability(stale, { userId: 2, isStaff: true, windowMinutes: 15 }) + assert.equal(asStaff.canEdit, true) + assert.equal(asStaff.editableUntil, null) + + // A window of zero is a legitimate operator choice: posts immutable once written. + assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 0 }).canEdit, false) +}) + +test('a hidden post is editable by nobody, staff included', () => { + const hidden = post({ status: 'hidden', created_at: new Date() }) + assert.equal(forum.editability(hidden, { userId: 11, windowMinutes: 15 }).canEdit, false) + // Restoring it is a moderation action with a ledger row; quietly rewriting it + // while it is out of sight is the same act with no record. + assert.equal(forum.editability(hidden, { userId: 2, isStaff: true, windowMinutes: 15 }).canEdit, false) +}) + +test('the write path re-derives the window and does not trust the read path', async () => { + const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) + patch(forumDb, 'postById', async () => stale) + patch(forumDb, 'threadById', async () => discussion()) + const writes = [] + patch(forumDb, 'updatePostBody', async (...args) => { writes.push(args); return true }) + + const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '

new

' }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 403) + assert.equal(writes.length, 0) + + // Staff, same post, same moment. + const allowed = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '

new

' }) + assert.equal(allowed.ok, true) + assert.equal(writes.length, 1) + // Reported so the controller can write the §5.3 accountability row — a staffer + // editing someone ELSE's words is an intervention. + assert.equal(allowed.staffEdit, true) +}) + +test('a staffer editing their own post is an ordinary edit, not an intervention', async () => { + patch(forumDb, 'postById', async () => post({ author_user_id: staff.id, author_username: staff.username })) + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'updatePostBody', async () => true) + + const result = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '

x

' }) + assert.equal(result.ok, true) + assert.equal(result.staffEdit, false) +}) + +test('a member may not edit somebody else’s post at all', async () => { + patch(forumDb, 'postById', async () => post({ author_user_id: 99, author_username: 'someone' })) + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'updatePostBody', async () => { throw new Error('must not write') }) + + const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '

x

' }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 403) +}) + +test('post moderation shares the thread ledger, tagged as a post', async () => { + const ledger = [] + patch(forumDb, 'postById', async () => post()) + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'setPostStatus', async () => true) + patch(forumDb, 'recountThread', async () => {}) + patch(forumDb, 'softDeleteUploadsForPost', async () => {}) + patch(forumDb, 'restoreUploadsForPost', async () => {}) + patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) }) + + await forum.moderatePost({ team, postId: 80, action: 'hide', actor: leader, actorRole: 'leader' }) + await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' }) + + // One table, two target kinds — so "everything moderated in this Team" stays + // one query instead of a union. + assert.deepEqual(ledger.map((r) => r.targetType), ['post', 'post']) + assert.deepEqual(ledger.map((r) => r.action), ['hide', 'delete']) + assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff']) +}) + +test('pin and lock are refused BY NAME on a post, not as unknown actions', async () => { + patch(forumDb, 'postById', async () => post()) + patch(forumDb, 'threadById', async () => discussion()) + + const wrongObject = await forum.moderatePost({ team, postId: 80, action: 'pin', actor: leader, actorRole: 'leader' }) + assert.equal(wrongObject.status, 400) + assert.match(wrongObject.error, /applies to a thread/) + + const nonsense = await forum.moderatePost({ team, postId: 80, action: 'incinerate', actor: leader, actorRole: 'leader' }) + assert.equal(nonsense.status, 400) + assert.match(nonsense.error, /Unknown/) +}) + +test('deleting a post takes its images with it, and restoring brings them back', async () => { + const calls = [] + patch(forumDb, 'postById', async () => post()) + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'setPostStatus', async () => true) + patch(forumDb, 'recountThread', async () => {}) + patch(forumDb, 'insertModeration', async () => {}) + patch(forumDb, 'softDeleteUploadsForPost', async (id) => { calls.push(['soft', id]) }) + patch(forumDb, 'restoreUploadsForPost', async (id) => { calls.push(['restore', id]) }) + + await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' }) + await forum.moderatePost({ team, postId: 80, action: 'restore', actor: staff, actorRole: 'staff' }) + // Without the second half, delete → restore returns the words and loses the + // pictures a retention window later, silently. + assert.deepEqual(calls, [['soft', 80], ['restore', 80]]) + + // Hiding is not deleting: a hidden post's images are untouched, because + // unhiding must be free. + calls.length = 0 + await forum.moderatePost({ team, postId: 80, action: 'hide', actor: staff, actorRole: 'staff' }) + assert.deepEqual(calls, []) +}) + +test('post moderation recomputes the thread’s counters rather than nudging them', async () => { + const recounts = [] + patch(forumDb, 'postById', async () => post()) + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'setPostStatus', async () => true) + patch(forumDb, 'insertModeration', async () => {}) + patch(forumDb, 'softDeleteUploadsForPost', async () => {}) + patch(forumDb, 'restoreUploadsForPost', async () => {}) + patch(forumDb, 'recountThread', async (id) => { recounts.push(id) }) + + // hide → unhide → hide is a cycle a counter kept by deltas gets wrong the + // first time a step is retried or raced. + for (const action of ['hide', 'unhide', 'hide']) { + await forum.moderatePost({ team, postId: 80, action, actor: staff, actorRole: 'staff' }) + } + assert.deepEqual(recounts, [5, 5, 5]) +}) + +test('a thread reports whether it takes replies, and why not', async () => { + patch(forumDb, 'postsByThread', async () => []) + + patch(forumDb, 'threadById', async () => discussion()) + assert.equal((await forum.getThread(1, 5, { canModerate: false })).canReply, true) + + patch(forumDb, 'threadById', async () => discussion({ locked: 1 })) + const locked = await forum.getThread(1, 5, { canModerate: false }) + assert.equal(locked.canReply, false) + assert.equal(locked.locked, true) // the UI can say WHICH half refused + + patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' })) + const announcement = await forum.getThread(1, 5, { canModerate: false }) + assert.equal(announcement.canReply, false) + assert.equal(announcement.type, 'announcement') +}) + +test('every post comes back knowing whether THIS reader may edit it', async () => { + patch(forumDb, 'threadById', async () => discussion()) + patch(forumDb, 'postsByThread', async () => [ + post({ id: 80, author_user_id: 11, created_at: new Date() }), + post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }), + ]) + + const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } }) + assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false]) + assert.deepEqual(mine.posts.map((p) => p.mine), [true, false]) + + // A caller that does not say who is reading gets the safe answer, which is what + // keeps every phase-4 call site correct without changing it. + const anonymous = await forum.getThread(1, 5, {}) + assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false]) +}) diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js index 7f9b68a..293fc83 100644 --- a/server/test/teamRoutes.test.js +++ b/server/test/teamRoutes.test.js @@ -28,6 +28,7 @@ const forumSettings = require('../src/model/teams/teamForumSettings.model') const forum = require('../src/model/teams/teamForum.model') const grants = require('../src/model/teams/teamGrants.model') const access = require('../src/model/teams/teamAccess.model') +const reports = require('../src/model/reports/contentReports.model') const db = require('../src/utils/db') after(() => db.close()) @@ -75,6 +76,11 @@ const get = (app, path, init) => fetch(`${app.url}${path}`, init) const post = (app, path, body) => fetch(`${app.url}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}), }) +// Named with a trailing underscore because `patch` is already the stub helper in +// this file, and shadowing it inside a test would be an hour nobody enjoys. +const patch_ = (app, path, body) => fetch(`${app.url}${path}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}), +}) // ── The public tier is anonymous, and hidden means absent ────────────────── @@ -323,16 +329,64 @@ test('acceptance 2: with the forum off every forum route 404s, and nothing is to patch(forum, 'getThread', async () => mark()) patch(forum, 'createThread', async () => mark()) patch(forum, 'moderateThread', async () => mark()) + // Phase 5's four. A route added behind the same guard has to be added here + // too, or the acceptance criterion silently stops covering the whole surface. + patch(forum, 'createPost', async () => mark()) + patch(forum, 'editPost', async () => mark()) + patch(forum, 'moderatePost', async () => mark()) + patch(reports, 'file', async () => mark()) await withApp('/api/v1/player', playerRouter, async (app) => { assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404) assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404) assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404) assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404) + assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', { + targetType: 'team_forum_post', targetId: 1, reason: 'spam', + })).status, 404) }) assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404') }) +test('replying, editing and reporting all run through the same access resolver', async () => { + // A caller with no access sees 404 on every write too, not only on the reads. + // A private room's contents and its existence are the same secret, and a write + // that answered 403 would confirm the room. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false })) + patch(forum, 'createPost', async () => { throw new Error('must not run') }) + patch(forum, 'editPost', async () => { throw new Error('must not run') }) + patch(reports, 'file', async () => { throw new Error('must not run') }) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404) + assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', { + targetType: 'team_forum_post', targetId: 1, reason: 'spam', + })).status, 404) + }) +}) + +test('post moderation is refused to a participant who is neither leader nor staff', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false })) + patch(forum, 'moderatePost', async () => { throw new Error('must not run') }) + + await withApp('/api/v1/player', playerRouter, async (app) => { + // 403 and not 404 here, deliberately: this caller can SEE the forum, so + // nothing is being concealed — they are simply not allowed to moderate it. + const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' }) + assert.equal(res.status, 403) + }) +}) + test('with the forum ON, the same routes answer — the switch is the only difference', async () => { signInAs(player) patch(forumSettings, 'forumsEnabled', async () => true) @@ -345,7 +399,55 @@ test('with the forum ON, the same routes answer — the switch is the only diffe const res = await get(app, '/api/v1/player/teams/a/forum/threads') assert.equal(res.status, 200) const body = await res.json() - assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer') + // Phase 5 split one capability into two. `canPost` now means "may open a + // DISCUSSION", which every participant may; `canAnnounce` is the leader-only + // half that `canPost` used to carry alone. + assert.equal(body.canPost, true, 'an ordinary member may open a discussion') + assert.equal(body.canAnnounce, false, 'an ordinary member does not get the announcement composer') + assert.equal(body.canModerate, false) + }) +}) + +test('a leader gets both composers; the announcement one is theirs alone', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(forumSettings, 'imageMode', async () => 'disabled') + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true })) + patch(forum, 'listThreads', async () => []) + + await withApp('/api/v1/player', playerRouter, async (app) => { + const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json() + assert.equal(body.canPost, true) + assert.equal(body.canAnnounce, true) + assert.equal(body.canModerate, true) + }) +}) + +test('an ordinary member is refused an announcement and allowed a discussion', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false })) + patch(forum, 'createThread', async ({ type }) => ({ ok: true, threadId: 1, postId: 1, type })) + + await withApp('/api/v1/player', playerRouter, async (app) => { + // The check splits by TYPE — phase 4's comment said it would happen here + // rather than the leader gate being widened. + const announcement = await post(app, '/api/v1/player/teams/a/forum/threads', { + type: 'announcement', title: 'x', body: 'y', + }) + assert.equal(announcement.status, 403) + + const discussion = await post(app, '/api/v1/player/teams/a/forum/threads', { + type: 'discussion', title: 'x', body: 'y', + }) + assert.equal(discussion.status, 200) + + // No `type` at all is a phase-4 client, and a phase-4 client only ever posted + // announcements — so the default must NOT quietly become a discussion. + const untyped = await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' }) + assert.equal(untyped.status, 403) }) }) -- 2.49.1 From 3f7e61af1cc75f410b48ec0580efa6416405e98b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 13:08:59 -0500 Subject: [PATCH 4/5] =?UTF-8?q?feat(teams):=20the=20phase=205=20surface=20?= =?UTF-8?q?=E2=80=94=20discussion,=20replies,=20reports,=20and=20two=20adm?= =?UTF-8?q?in=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 241 client tests pass (224 before). **The forum panel becomes a forum.** It was "Announcements" with one composer; it now has two, because phase 5 split one server capability into two: `canPost` means "may open a discussion" and every participant may — a granted guest with no game character included, which is path 3 doing its job — while `canAnnounce` is the leader-only half `canPost` used to carry alone. Threads gain replies, an edit control, per-post moderation and a report control, all still inside the one slot the module declares, still navigating by `?thread=`. **Almost nothing here is the client's decision, and the file says so.** `canPost`, `canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer whose deadline passed while the page sat open — it can never grant one, because a time-bounded permission must not take its clock from the party it bounds. That asymmetry is the first thing client/test/teamForum.test.js asserts. The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than they look: * `stripToText` decodes entities AFTER stripping tags, and `&` last of all. Decoding first turns an author's literal "<script>" into a real tag the strip pass then deletes — silently losing text that was never dangerous. * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing the raw count tells a reader a brand-new thread already has one reply. **Three admin surfaces.** The forum settings screen gains the edit-window field (0 = posts permanent once written). The reports queue is a new screen beside Appeals — under moderation rather than under Teams, because a staffer working a queue should have one place to work and `target_type` is deliberately open-ended, so the next reportable thing arrives as a row rather than as another nav entry. Its copy tells a member where a report lands and that reporting changes nothing, because a member who expects a post to vanish and watches it stay reports it again. There is no leader-facing view and there is not meant to be. And the per-Team forum moderation ledger finally renders: the route and `api.admin.teamForumModeration()` have both existed since phase 4 with nothing calling them, which made `actor_role` — the column that keeps a leader's ordinary housekeeping distinguishable from a staff intervention — readable only from a DB client. Co-Authored-By: Claude --- client/src/App.jsx | 2 + client/src/api/client.js | 27 ++ client/src/lib/teamForum.js | 85 ++++ client/src/modules/TeamForumPanel.jsx | 442 ++++++++++++++++-- client/src/routes/admin/AdminLayout.jsx | 6 + .../src/routes/admin/views/ContentReports.jsx | 310 ++++++++++++ .../routes/admin/views/TeamForumSettings.jsx | 38 +- client/src/routes/admin/views/TeamsAdmin.jsx | 87 +++- client/test/apiClient.test.js | 67 +++ client/test/teamForum.test.js | 120 +++++ .../src/router/v1/admin/teams.controller.js | 8 + 11 files changed, 1145 insertions(+), 47 deletions(-) create mode 100644 client/src/lib/teamForum.js create mode 100644 client/src/routes/admin/views/ContentReports.jsx create mode 100644 client/test/teamForum.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 1750361..a63c557 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -47,6 +47,7 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' import Appeals from './routes/admin/views/Appeals.jsx' +import ContentReports from './routes/admin/views/ContentReports.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' @@ -163,6 +164,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 447803d..a8ae529 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -162,6 +162,21 @@ export const api = { req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }), teamForumModerate: (slug, id, body) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }), + // Phase 5 ("5b"). A reply, an edit and post-level moderation are separate + // routes from their thread-level cousins rather than the same route with a + // target kind, because they answer to different rules: a reply is refused by a + // lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all. + teamForumReply: (slug, threadId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }), + teamForumEditPost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }), + teamForumModeratePost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }), + // The report goes to SITE STAFF, never to the Team's leaders — the whole point + // of it is a path that routes around a Team's own leadership (TEAMS.md §5.6). + // There is no leader-facing counterpart to this call and there should not be. + teamForumReport: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }), teamForumUpload: (slug, file) => { const fd = new FormData() fd.append('image', file) @@ -318,6 +333,18 @@ export const api = { // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), + // The content-report queue (TEAMS.md §5.6). Under moderation rather than + // under Teams because 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 — which is also why `targetType` is open-ended. + contentReports: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.status) qs.set('status', opts.status) + if (opts.teamId) qs.set('teamId', String(opts.teamId)) + return req(`/admin/moderation/reports${withQs(qs.toString())}`) + }, + handleContentReport: (id, body) => + req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }), modRecent: (params = {}) => { const qs = new URLSearchParams() if (params.type) qs.set('type', params.type) diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js new file mode 100644 index 0000000..b52ae3a --- /dev/null +++ b/client/src/lib/teamForum.js @@ -0,0 +1,85 @@ +// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5). +// +// This file is small on purpose. **Almost nothing about the forum is the +// client's to decide**: who may post, who may moderate, whether an image +// renders, and whether a post may be edited are all answered by the server and +// read from the payload. What is left here is the handful of pure functions that +// turn those answers into what a reader sees, and they are extracted so they can +// be tested without a browser. +// +// The one that deserves a second look is `editOfferOpen`. It can only ever take +// an offer AWAY — the server grants the edit and re-derives the window from +// `created_at` when the write arrives. A client that granted one would be +// deciding a time-bounded permission against the clock of the party it bounds. + +export const REPORT_REASONS = [ + ['abuse', 'Abusive or harassing'], + ['spam', 'Spam'], + ['sexual', 'Sexual content'], + ['illegal', 'Illegal content'], + ['impersonation', 'Impersonation'], + ['other', 'Something else'], +] + +/** + * Should the Edit control still be offered for this post? + * + * Three states, and the middle one is the reason this exists: + * • the server said no → no offer, and nothing here can create one + * • the server said yes, no deadline (staff) → offer + * • the server said yes with a deadline that has since passed while the page + * sat open → withdraw the offer, rather than leave a button that fails + */ +export function editOfferOpen(post, now = Date.now()) { + if (!post || !post.canEdit) return false + if (!post.editableUntil) return true + const until = new Date(post.editableUntil).getTime() + return Number.isFinite(until) && until > now +} + +/** + * Turn a rendered body back into something an author can edit. + * + * The server stores sanitised HTML and generates images at READ time from the + * URLs an author wrote (§5.5.3), so what comes back is not what was typed. The + * `` has to go — it is core's output, not the author's input, and leaving it + * in would let an author "edit" markup they never wrote and cannot control. + * The URL survives as the link text beside it, which is what re-renders. + */ +export function stripToText(html) { + return String(html || '') + .replace(/]*>/gi, '') + .replace(/<\/p>\s*]*>/gi, '\n\n') + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + // Entities last: unescaping before tag-stripping would let an escaped + // "<script>" become a real tag the next pass then removes, which is a + // different string from the one the author wrote. + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + // `&` last of all, or "&lt;" would decode two steps into "<". + .replace(/&/g, '&') + .trim() +} + +/** + * The one-line summary under a thread's title in the list. + * + * `postCount` counts every post including the opening one, so a discussion's + * REPLY count is one less — and an announcement has no replies to count at all, + * which is why the count is omitted rather than shown as zero. + */ +export function threadSummary(thread) { + const parts = [] + if (thread.type === 'announcement') parts.push('Announcement') + parts.push(thread.author) + if (thread.type === 'discussion' && thread.postCount > 1) { + const replies = thread.postCount - 1 + parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`) + } + if (thread.status === 'hidden') parts.push('hidden') + return parts.join(' · ') +} diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx index a823bdb..5b2ea9f 100644 --- a/client/src/modules/TeamForumPanel.jsx +++ b/client/src/modules/TeamForumPanel.jsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import DOMPurify from 'dompurify' import { api } from '../api/client.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' +import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js' // Core's Team forum, rendered into a second slot a MODULE declares // (TEAMS.md Part 5, and the phase 3 amendment to §3.4). @@ -28,6 +29,15 @@ import { useSite } from '../contexts/SiteContext.jsx' // upload control that would otherwise 404. If the two ever disagree, the server // is right. // +// **Phase 5 added discussion, and with it three capabilities this file must not +// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are +// computed on the server and read here. In particular the edit window is a +// server decision twice over — the read path stamps `canEdit`/`editableUntil` and +// the write re-derives it — because a time-bounded permission must not take its +// clock from the party it bounds. What this file does with `editableUntil` is +// stop OFFERING an edit whose deadline has passed while the page sat open; it +// never grants one. +// // Like the feed, everything here degrades to rendering nothing. A 404 from the // thread list is the ordinary case — the forum is switched off, or this viewer // has no access — and putting an error box on a page core does not own would be @@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) { const [team, setTeam] = useState(null) const [state, setState] = useState({ loading: true, forum: null }) const [thread, setThread] = useState(null) - const [composing, setComposing] = useState(false) + const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null const openThreadId = params.get('thread') const imageMode = settings?.teams_forum_images || 'disabled' @@ -54,6 +64,14 @@ export default function TeamForumPanel({ externalId, moduleId }) { } }, []) + const loadThread = useCallback(async (slug, id) => { + try { + setThread(await api.teamForumThread(slug, id)) + } catch { + setThread(null) + } + }, []) + useEffect(() => { let active = true // An anonymous visitor has no forum by definition — every route is behind @@ -100,9 +118,12 @@ export default function TeamForumPanel({ externalId, moduleId }) { if (openThreadId && thread) { return ( openThread(null)} + onChanged={() => loadThread(team.slug, thread.id)} onModerate={async (action) => { await api.teamForumModerate(team.slug, thread.id, { action }) await loadThreads(team.slug) @@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {

- Announcements + Forum

- {forum.canPost && !composing && ( - + {!composing && ( +
+ {/* + Two buttons, because phase 5 split one capability in two. `canPost` + means "may open a discussion" and every participant may — including a + granted guest with no game character, which is path 3 doing its job. + `canAnnounce` is the leader-only half. + */} + {forum.canPost && ( + + )} + {forum.canAnnounce && ( + + )} +
)}
{composing && ( setComposing(false)} + onCancel={() => setComposing(null)} onPosted={async () => { - setComposing(false) + setComposing(null) await loadThreads(team.slug) }} /> @@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) { {forum.threads.length === 0 && !composing && (

- Nothing has been announced here yet. + Nothing has been posted here yet.

)} @@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) { }} > {t.pinned && 📌} + {t.locked && 🔒} {t.title} - {t.author} - {t.status === 'hidden' && ' · hidden'} + {threadSummary(t)} @@ -272,22 +309,167 @@ function GuestManager({ slug }) { ) } -function ThreadView({ thread, canModerate, onBack, onModerate }) { +function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) { + // A clock that ticks, so an edit control whose deadline passed while the page + // sat open goes away instead of becoming a button that fails. It only ever + // REMOVES an offer — the server decides whether an edit happens, and re-derives + // the window from created_at when it does. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 30_000) + return () => clearInterval(id) + }, []) + + const [replying, setReplying] = useState(false) + return (

{thread.title}

+ {thread.type === 'announcement' ? 'Announcement · ' : ''} {thread.author} {thread.authorDeleted && ' (account removed)'} + {thread.locked && ' · locked'}

{thread.posts.map((post) => ( -
+ + ))} + + {/* + `canReply` is the server's answer to "does this thread take replies right + now", and it folds together the two reasons it might not: an announcement + takes none by TYPE, and a locked thread takes none by STATE. Both are + reported separately above so the reader can see which. + */} + {thread.canReply && !replying && ( + + )} + {thread.canReply && replying && ( + setReplying(false)} + onPosted={async () => { + setReplying(false) + await onChanged() + }} + /> + )} + {!thread.canReply && thread.locked && ( +

+ This thread is locked. Nobody can reply to it, including staff — a moderator who wants the + last word unlocks it first, which leaves a record. +

+ )} + +
+ + {canModerate && ( + <> + + + + + )} +
+
+ ) +} + +/** + * One post, with whatever this reader may do to it. + * + * Every capability shown here was decided by the server and is read, not + * computed: `canEdit` and `editableUntil` come stamped on the post, and + * `canModerate` on the thread. The one local judgement is whether an + * already-granted edit window has since elapsed, which can only take an offer + * away. + */ +function PostView({ slug, post, canModerate, now, onChanged }) { + const [editing, setEditing] = useState(false) + const [body, setBody] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now]) + + const save = async (event) => { + event.preventDefault() + setBusy(true) + setError(null) + try { + await api.teamForumEditPost(slug, post.id, { body }) + setEditing(false) + await onChanged() + } catch (err) { + setError(err.message || 'Could not save that') + } finally { + setBusy(false) + } + } + + const moderate = async (action) => { + setError(null) + try { + await api.teamForumModeratePost(slug, post.id, { action }) + await onChanged() + } catch (err) { + setError(err.message || 'Could not do that') + } + } + + return ( +
+

+ {post.author} + {post.authorDeleted && ' (account removed)'} + {post.editedAt && ' · edited'} + {post.status === 'hidden' && ' · hidden'} +

+ + {editing ? ( +
+