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,
|
||||
|
||||
@@ -64,16 +64,16 @@ forumRouter.get(
|
||||
forumRouter.post(
|
||||
'/:slug/forum/threads',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Post an announcement'
|
||||
// #swagger.description = 'Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
|
||||
// #swagger.summary = 'Open a thread — an announcement or a discussion'
|
||||
// #swagger.description = 'Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement'] }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement','discussion'], default: 'announcement' }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Only a leader may post an announcement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
body('type').optional().isIn(['announcement']),
|
||||
body('type').optional().isIn(['announcement', 'discussion']),
|
||||
body('title').isString().trim().isLength({ min: 1, max: 200 }),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
@@ -113,6 +113,63 @@ forumRouter.post(
|
||||
ctrl.moderateThread,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/threads/:id/posts',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Reply to a discussion thread'
|
||||
// #swagger.description = 'Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' }, postId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Announcements do not take replies', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The thread is locked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
ctrl.createPost,
|
||||
)
|
||||
|
||||
forumRouter.patch(
|
||||
'/:slug/forum/posts/:id',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Edit a post'
|
||||
// #swagger.description = 'The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Edited', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not your post, or the edit window has closed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no such post, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
ctrl.editPost,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/posts/:id/moderate',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Hide, unhide, delete or restore a post'
|
||||
// #swagger.description = 'Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so "everything moderated in this Team" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An action that applies to a thread, not a post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('action').isIn(['hide', 'unhide', 'delete', 'restore']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.moderatePost,
|
||||
)
|
||||
|
||||
// ── grants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
forumRouter.get(
|
||||
|
||||
Reference in New Issue
Block a user