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

@@ -104,6 +104,45 @@ async function setPostStatus(id, status) {
return res.affectedRows > 0
}
/**
* Rewrite a post's body, stamping who edited it and when.
*
* `edited_at` is set unconditionally, including when a staffer edits — the column
* answers "has this been changed since it was written", which a reader needs to
* know regardless of whose hand did it. `edited_by` is the second half of that
* answer and is why the two are separate columns rather than a boolean.
*/
async function updatePostBody(id, bodyHtml, editedBy) {
const res = await query(
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
[bodyHtml, editedBy, id],
)
return res.affectedRows > 0
}
/**
* Recompute a thread's denormalised counters from the posts that are actually
* visible.
*
* Called after every post moderation rather than incrementing and decrementing,
* because hide → unhide → delete → restore is a sequence in which a counter kept
* by deltas drifts the first time any step is retried or raced. The read is one
* indexed aggregate over one thread; correctness is worth more than the write it
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
*/
async function recountThread(threadId) {
await query(
`UPDATE team_forum_threads t
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible'),
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible')
WHERE t.id = ?`,
[threadId],
)
}
// ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
@@ -222,6 +261,8 @@ module.exports = {
postById,
insertPost,
setPostStatus,
updatePostBody,
recountThread,
insertModeration,
moderationForTeam,
insertUpload,
@@ -230,6 +271,7 @@ module.exports = {
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,