feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155

Merged
whitlocktech merged 5 commits from feature/teams-phase5-discussion into edge 2026-08-18 18:36:52 +00:00
5 changed files with 542 additions and 52 deletions
Showing only changes of commit ae0d27cf27 - Show all commits

View File

@@ -104,6 +104,45 @@ async function setPostStatus(id, status) {
return res.affectedRows > 0 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) ──────────────────────────────────── // ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) { async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
@@ -222,6 +261,8 @@ module.exports = {
postById, postById,
insertPost, insertPost,
setPostStatus, setPostStatus,
updatePostBody,
recountThread,
insertModeration, insertModeration,
moderationForTeam, moderationForTeam,
insertUpload, insertUpload,
@@ -230,6 +271,7 @@ module.exports = {
listUploads, listUploads,
softDeleteUpload, softDeleteUpload,
softDeleteUploadsForPost, softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads, sweepableUploads,
orphanedUploads, orphanedUploads,
deleteUploadRows, deleteUploadRows,

View File

@@ -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 // 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 opens discussion threads, // model and a single announcements stream per Team; 5b (phase 5) opens discussion
// replies and editing. The schema for all of it landed together, so 5b enables // threads, replies, editing and post-level moderation. The schema for all of it
// paths here rather than migrating data — which is why `type` is a parameter // landed together, so this phase added no ALTER — every column it needed
// below and not a constant, and why `locked` is honoured on a thread nothing can // (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
// reply to yet. // ledger's `target_type='post'`) was already there waiting.
// //
// **Every function here takes an already-resolved access decision.** Nothing in // **Every function here takes an already-resolved access decision.** Nothing in
// this file reads `team_members` or `team_forum_grants`; the caller asks // 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 forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml') const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
// Announcements are leader-authored and replies are disabled; 5b's discussion // Announcements are leader-authored and take no replies; discussion threads are
// threads are member-authored and take replies. Both types exist in the enum from // member-authored and do. Both have been in the enum since 5a — what phase 5
// day one — this is the list of what 5a will CREATE. // 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'] 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]' const DELETED_AUTHOR = '[deleted account]'
/** /**
@@ -48,6 +63,17 @@ const THREAD_ACTIONS = {
restore: { status: 'visible' }, 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) { function publicThread(row) {
return { return {
id: row.id, 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. * `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 * 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 * output and nothing on disk, which is the property §5.5.3 exists to give and the
* one acceptance criterion 3 measures. * 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 { return {
id: row.id, id: row.id,
author: row.author_username || DELETED_AUTHOR, author: row.author_username || DELETED_AUTHOR,
@@ -81,6 +137,8 @@ function renderPost(row, mode) {
createdAt: row.created_at, createdAt: row.created_at,
editedAt: row.edited_at, editedAt: row.edited_at,
status: row.status, 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) 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) const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another // 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 // 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 mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate }) 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 * 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 * is why phase 5 added no migration — a discussion thread is the same two writes
* left false: replies are refused because the TYPE takes none, not because the * with a different `type`. The FIRST post is an ordinary post and is moderated,
* thread was closed, and conflating the two would make "unlock" look like it * edited and reported like any other; nothing here marks it as special, because a
* would open replies on an announcement. * 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 }) { async function createThread({ team, actor, type, title, body }) {
if (!CREATABLE_TYPES_5A.includes(type)) { if (!CREATABLE_TYPES.includes(type)) {
return { ok: false, status: 400, error: 'Only announcements can be posted yet' } return { ok: false, status: 400, error: 'Unknown thread type' }
} }
const cleaned = cleanForumBody(body) const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) { 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({ const threadId = await forumDb.insertThread({
teamId: team.id, teamId: team.id,
@@ -136,13 +211,102 @@ async function createThread({ team, actor, type, title, body }) {
createdBy: actor.id, createdBy: actor.id,
createdUsername: actor.username, createdUsername: actor.username,
}) })
await forumDb.insertPost({ const postId = await forumDb.insertPost({
threadId, threadId,
authorUserId: actor.id, authorUserId: actor.id,
authorUsername: actor.username, authorUsername: actor.username,
bodyHtml: cleaned, 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 } 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. */ /** The ledger for the admin Team page. Staff-only by its route, not by this function. */
async function moderationLedger(teamId, opts) { async function moderationLedger(teamId, opts) {
return forumDb.moderationForTeam(teamId, opts) return forumDb.moderationForTeam(teamId, opts)
} }
module.exports = { module.exports = {
CREATABLE_TYPES,
CREATABLE_TYPES_5A, CREATABLE_TYPES_5A,
REPLYABLE_TYPES,
THREAD_ACTIONS, THREAD_ACTIONS,
POST_ACTIONS,
listThreads, listThreads,
getThread, getThread,
createThread, createThread,
createPost,
editPost,
moderateThread, moderateThread,
moderatePost,
moderationLedger, moderationLedger,
publicThread, publicThread,
renderPost, renderPost,
editability,
} }

View File

@@ -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 // TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
// file rather than in settings.model.js is that only one of them is an ordinary // they live in their own file rather than in settings.model.js is that only two
// key: `teams_forum_images` has a server-side precondition, and a precondition // of them are ordinary keys: `teams_forum_images` has a server-side precondition,
// buried in the generic setMany() loop is one nobody reading that loop would // and a precondition buried in the generic setMany() loop is one nobody reading
// know about. // that loop would know about.
// //
// teams_forums_enabled '0' | '1' default '0' — off // teams_forums_enabled '0' | '1' default '0' — off
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled' // teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given // 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 // **Every read fails closed.** A DB fault reports the forum off, images disabled
// disabled, because the alternative is a transient error opening a feature the // and the edit window shut, because the alternative is a transient error opening a
// operator turned off, or rendering third-party images on a site whose operator // feature the operator turned off, or rendering third-party images on a site whose
// chose not to. The cost of failing closed here is a forum that 404s for a minute; // operator chose not to. The cost of failing closed here is a forum that 404s for a
// the cost of failing open is a policy that is not a policy. // minute; the cost of failing open is a policy that is not a policy.
const settingsDb = require('../settings/settings.db') const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_forums_enabled' const ENABLED_KEY = 'teams_forums_enabled'
const IMAGES_KEY = 'teams_forum_images' const IMAGES_KEY = 'teams_forum_images'
const ACK_KEY = 'teams_forum_uploads_ack' const ACK_KEY = 'teams_forum_uploads_ack'
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
const IMAGE_MODES = ['disabled', 'remote', 'uploads'] 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 // 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 // makes every stored acknowledgement stale — see `ackState` below for what that
// then does, which is deliberately NOT "turn uploads off". // 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. */ /** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
async function uploadsEnabled() { async function uploadsEnabled() {
return (await imageMode()) === 'uploads' return (await imageMode()) === 'uploads'
@@ -127,7 +160,7 @@ async function assertAcknowledged(nextMode, acknowledge) {
* key. * key.
*/ */
async function assertSettingsWritable(keys, acknowledge) { 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 } if (!touchesForum) return { ok: true }
const state = await ackState() const state = await ackState()
if (!state.stale) return { ok: true } if (!state.stale) return { ok: true }
@@ -148,10 +181,14 @@ module.exports = {
ENABLED_KEY, ENABLED_KEY,
IMAGES_KEY, IMAGES_KEY,
ACK_KEY, ACK_KEY,
EDIT_WINDOW_KEY,
IMAGE_MODES, IMAGE_MODES,
ACK_VERSION, ACK_VERSION,
EDIT_WINDOW_DEFAULT,
EDIT_WINDOW_MAX,
forumsEnabled, forumsEnabled,
imageMode, imageMode,
editWindowMinutes,
uploadsEnabled, uploadsEnabled,
ackState, ackState,
assertAcknowledged, assertAcknowledged,

View File

@@ -59,6 +59,7 @@ async function resolveForum(req) {
return { return {
team, team,
access: resolved, access: resolved,
staff,
// Staff moderate anywhere; a leader moderates their own Team. `actorRole` // Staff moderate anywhere; a leader moderates their own Team. `actorRole`
// records WHICH of the two was exercised, and leadership wins when both are // 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 // 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 ──────────────────────────────────────────────────────────────── // ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) { async function listThreads(req, res) {
@@ -77,7 +93,15 @@ async function listThreads(req, res) {
if (!ctx) return res.status(404).json({ message: 'Not found' }) if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({ return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }), 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, canModerate: ctx.canModerate,
imageMode: await forumSettings.imageMode(), imageMode: await forumSettings.imageMode(),
}) })
@@ -90,7 +114,10 @@ async function getThread(req, res) {
try { try {
const ctx = await resolveForum(req) const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' }) 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' }) if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate }) return res.json({ ...thread, canModerate: ctx.canModerate })
} catch (err) { } 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 * **The check splits by TYPE, which is what phase 4 said would happen here.** An
* phase only: in 5a the only creatable type is an announcement, whose author must * announcement is leader-authored; a discussion is open to every participant — and
* be a leader. 5b adds `type: 'discussion'`, which any member may create — at * "participant" means anyone `resolveForum` let through, which includes a granted
* which point the check splits by type rather than being widened. * 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) { async function createThread(req, res) {
try { try {
const ctx = await resolveForum(req) const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' }) 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({ const result = await forum.createThread({
team: ctx.team, team: ctx.team,
actor: req.user, actor: req.user,
type: req.body.type || 'announcement', type,
title: req.body.title, title: req.body.title,
body: req.body.body, 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. * Pin / lock / hide / delete a thread, and its opposites.
* *
@@ -279,7 +400,10 @@ module.exports = {
listThreads, listThreads,
getThread, getThread,
createThread, createThread,
createPost,
editPost,
moderateThread, moderateThread,
moderatePost,
listGrants, listGrants,
createGrant, createGrant,
revokeGrant, revokeGrant,

View File

@@ -64,16 +64,16 @@ forumRouter.get(
forumRouter.post( forumRouter.post(
'/:slug/forum/threads', '/:slug/forum/threads',
// #swagger.tags = ['Player · Teams'] // #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Post an announcement' // #swagger.summary = 'Open a thread — an announcement or a discussion'
// #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 5s discussion threads add no migration. The body is sanitised with the FORUMs 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.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 FORUMs 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.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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */ /* #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, postLimiter,
param('slug').isString().trim().isLength({ min: 1, max: 191 }), 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('title').isString().trim().isLength({ min: 1, max: 200 }),
body('body').isString().isLength({ min: 1, max: 40000 }), body('body').isString().isLength({ min: 1, max: 40000 }),
validate, validate,
@@ -113,6 +113,63 @@ forumRouter.post(
ctrl.moderateThread, 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 threads 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 elses 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 threads 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 ───────────────────────────────────────────────────────────────── // ── grants ─────────────────────────────────────────────────────────────────
forumRouter.get( forumRouter.get(