feat(teams): discussion threads, replies, the edit window and post moderation

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:43:09 -05:00
parent 763de66ebb
commit ae0d27cf27
5 changed files with 542 additions and 52 deletions

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
// 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,