Files
website/server/src/model/teams/teamForum.model.js
wtclaude 673701c91b
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s
fix(safety): team notifications skip members who block the author
#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-31 00:23:18 -05:00

495 lines
21 KiB
JavaScript

// ── 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')
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
const safetyDb = require('../safety/userSafety.db')
// 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']
/**
* 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' },
}
/**
* One thread's list shape, for one viewer.
*
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
* "[deleted player]" to a member and its stored name to staff, and
* `authorDeleted` says which case it is so the staff view can mark it.
*/
function publicThread(row, viewer = null) {
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
return {
id: row.id,
type: row.type,
title: row.title,
author: author.name,
authorDeleted: author.deleted,
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) {
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
return {
id: row.id,
author: author.name,
authorDeleted: author.deleted,
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, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
}
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
//
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
// and the threads and posts they hid. Applied on the server, to this reader's
// read only — nothing on disk changes, and nobody else's view does (S9).
/** Does this reader's filter leave this thread out? */
function leftOutThread(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
}
/**
* May this reader block these authors? Decided here and handed to the client,
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
* the menu does not offer what the server would refuse.
*/
async function blockable(viewer, authorIds) {
const me = viewer && viewer.userId
if (!me) return () => false
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
}
/** Does this reader's filter leave this post out? */
function leftOutPost(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
}
/**
* 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, filter = null } = {}) {
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
// A thread this reader hid, or opened by someone they block, is gone from
// their view — a link to it included.
if (leftOutThread(thread, filter)) return null
const mode = await forumSettings.imageMode()
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
.filter((p) => !leftOutPost(p, filter))
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
return {
...publicThread(thread, viewer),
canBlock: canBlock(thread.created_by),
// 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), canBlock: canBlock(p.author_user_id) })),
}
}
/**
* 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,
// `threadAuthorUserId`: a member who blocks whoever started the thread cannot
// open it (leftOutThread), so they are not told about replies in it either.
notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned, threadAuthorUserId: thread.created_by ?? null },
}
}
/**
* 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,
leftOutThread,
leftOutPost,
}