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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user