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

@@ -59,6 +59,7 @@ async function resolveForum(req) {
return {
team,
access: resolved,
staff,
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
// records WHICH of the two was exercised, and leadership wins when both are
// true: a leader who is also a moderator acting on their own Team is doing
@@ -69,6 +70,21 @@ async function resolveForum(req) {
}
}
/**
* Who is reading, for the read path's per-post `canEdit`.
*
* A separate read of the edit window rather than one folded into `resolveForum`,
* because only the two routes that render posts need it and `resolveForum` runs
* on every route in this file including the ones that never look at a body.
*/
async function viewerFor(ctx, user) {
return {
userId: user.id,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
}
}
// ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) {
@@ -77,7 +93,15 @@ async function listThreads(req, res) {
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
canPost: ctx.canModerate,
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
// read one boolean would have to guess which right it described.
// `canPost` is kept and now means "may open a discussion", which is what a
// 5a client's composer was for — an old client offering the composer to a
// member is a client offering the thing the server now allows.
canPost: true,
canAnnounce: ctx.canModerate,
canModerate: ctx.canModerate,
imageMode: await forumSettings.imageMode(),
})
@@ -90,7 +114,10 @@ async function getThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { canModerate: ctx.canModerate })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })
} catch (err) {
@@ -99,23 +126,34 @@ async function getThread(req, res) {
}
/**
* Post an announcement. 5a: leaders (and staff) only, replies disabled.
* Open a thread.
*
* The `canModerate` gate is doing double duty here and that is deliberate for one
* phase only: in 5a the only creatable type is an announcement, whose author must
* be a leader. 5b adds `type: 'discussion'`, which any member may create — at
* which point the check splits by type rather than being widened.
* **The check splits by TYPE, which is what phase 4 said would happen here.** An
* announcement is leader-authored; a discussion is open to every participant — and
* "participant" means anyone `resolveForum` let through, which includes a granted
* non-member with no game identity at all. That is path 3 doing its job: a forum
* guest reads and writes exactly as a member does, because the alternative is a
* second class of reader whose rights have to be tracked somewhere else.
*
* The default type is still `announcement`, unchanged from 5a: a client that
* posts without saying what it is posting is a 5a client, and a 5a client only
* ever posted announcements. Defaulting the other way would silently turn its
* announcements into discussions.
*/
async function createThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Only Team leaders may post announcements' })
const type = req.body.type || 'announcement'
if (type === 'announcement' && !ctx.canModerate) {
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
}
const result = await forum.createThread({
team: ctx.team,
actor: req.user,
type: req.body.type || 'announcement',
type,
title: req.body.title,
body: req.body.body,
})
@@ -125,6 +163,89 @@ async function createThread(req, res) {
}
}
/** Reply to a discussion thread. Every participant may; the model decides the rest. */
async function createPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await forum.createPost({
team: ctx.team,
threadId: Number(req.params.id),
actor: req.user,
body: req.body.body,
}))
} catch (err) {
return fail(res, err, 'create post')
}
}
/**
* Edit a post.
*
* A staff edit of somebody else's words is an intervention and writes
* `activity_log` (§5.3) — the one asymmetry that keeps the site's
* staff-accountability trail complete without dragging a member fixing their own
* typo into it. The model reports which case this was; the controller never
* re-derives it, because the two would disagree the day one of them changed.
*/
async function editPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const result = await forum.editPost({
team: ctx.team,
postId: Number(req.params.id),
actor: req.user,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
body: req.body.body,
})
if (result.ok && result.staffEdit) {
await activity.log({
req,
action: 'team.forum.edit',
detail: `${req.user.username} (#${req.user.id}) edited post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'edit post')
}
}
/** Hide, unhide, delete or restore one post. Pin and lock belong to threads. */
async function moderatePost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
const result = await forum.moderatePost({
team: ctx.team,
postId: Number(req.params.id),
action: req.body.action,
actor: req.user,
actorRole: ctx.actorRole,
reason: req.body.reason,
})
if (result.ok && ctx.actorRole === 'staff') {
await activity.log({
req,
action: 'team.forum.moderate',
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'moderate post')
}
}
/**
* Pin / lock / hide / delete a thread, and its opposites.
*
@@ -279,7 +400,10 @@ module.exports = {
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
listGrants,
createGrant,
revokeGrant,