// ── The forum: access + announcements (5a), discussion + moderation (5b) ─── // // 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 // teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never // by checking membership directly, which is how paths 1 and 3 would drift back // together", made structural. // // **The read path is where the image policy is applied**, once, in `renderPost`. // Not in the controller and never in the client: the client is TOLD the mode so it // can draw the right composer, and is never the thing that decides whether an // image appears (§5.5.6). const forumDb = require('./teamForum.db') const forumSettings = require('./teamForumSettings.model') const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml') // 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]' /** * Moderation actions, and what each one does to the row. * * A table rather than a switch because the ledger and the effect have to stay in * step: every entry here writes one row of `team_forum_moderation` naming the * authority that was exercised, and an action with an effect but no ledger entry * would be a moderation nobody can audit. */ const THREAD_ACTIONS = { pin: { pinned: true }, unpin: { pinned: false }, lock: { locked: true }, unlock: { locked: false }, hide: { status: 'hidden' }, unhide: { status: 'visible' }, delete: { status: 'deleted' }, 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, type: row.type, title: row.title, author: row.created_username || DELETED_AUTHOR, authorDeleted: row.created_by == null, createdAt: row.created_at, lastPostAt: row.last_post_at, postCount: row.post_count, pinned: Boolean(row.pinned), locked: Boolean(row.locked), status: row.status, } } /** * 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, viewer) { return { id: row.id, author: row.author_username || DELETED_AUTHOR, authorDeleted: row.author_user_id == null, body: renderForumBody(row.body_html, 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), } } /** * The thread list for one viewer. * * `canModerate` widens what is returned, not just what is offered: a hidden * thread is visible to the people who can unhide it and to nobody else, so the * same call answers both audiences without a second endpoint that could disagree * with this one. */ async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) { const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset }) return rows.map(publicThread) } /** * 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 // private room and the existence of a thread in it is itself private. if (!thread || thread.team_id !== teamId) return null if (thread.status === 'deleted' && !canModerate) return null if (thread.status === 'hidden' && !canModerate) return null const mode = await forumSettings.imageMode() const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate }) 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)), } } /** * 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 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.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: 'A post needs a body' } } const threadId = await forumDb.insertThread({ teamId: team.id, type, title, createdBy: actor.id, createdUsername: actor.username, }) const postId = await forumDb.insertPost({ threadId, authorUserId: actor.id, authorUsername: actor.username, bodyHtml: cleaned, }) // `notify` is what the CONTROLLER needs to fan a notification out, and it is a // separate key rather than more fields on the result because the controller // spreads the result straight into the response body — a notification's excerpt // is not part of the API's answer to "did my post save". // // The notification itself is fired from the controller and not from here, on // this file's own rule (see the header): everything in it takes an // already-resolved access decision and reads no membership table. The fan-out // reads both, so importing it here would make the forum model transitively // depend on exactly what it exists not to touch. return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } } } /** * 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, }) // The thread's OWN title and type, not the reply's — a reply has neither, and // what a recipient needs to know is which conversation moved. `type` is always // 'discussion' here (an announcement takes no replies) and is carried anyway so // the controller has one shape to hand the fan-out from both routes. return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } } } /** * 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 } } /** * Apply a moderation action to a thread, and record WHICH authority did it. * * `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary * housekeeping distinguishable from a staff intervention after the fact (§5.3). * The caller resolves it; this function records it and never infers it, because * an actor who is both would otherwise be recorded as whichever the code checked * first. */ async function moderateThread({ team, threadId, action, actor, actorRole, reason }) { const effect = THREAD_ACTIONS[action] if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' } const thread = await forumDb.threadById(threadId) if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' } await forumDb.setThreadFlags(threadId, effect) await forumDb.insertModeration({ teamId: team.id, targetType: 'thread', targetId: threadId, action, actorUserId: actor.id, actorUsername: actor.username, 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, }