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