From ae0d27cf276c0214b9f672b27c4bd6881e7d09e7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 10:43:09 -0500 Subject: [PATCH] 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(