The roster sync tickles at most ONCE per stream per run, not once per member: a tickle is content-free, so five people joining in one sweep is five identical notifications and one piece of information. Suppressed on a Team's FIRST roster, the same condition the activity feed uses and the half where it matters more — importing a 155-member guild would otherwise wake every one of their phones. Forum notifications fire from the CONTROLLER, not from the forum model. That file takes an already-resolved access decision and reads no membership table by design; the fan-out reads both to compute its recipients, so calling it from inside would make the forum model transitively depend on exactly what its header says it must not touch. The model returns a `notify` key the controller destructures out before the response, so the API's answer to "did my post save" is unchanged. `pageUrlTemplate` joins the team provider — the one thing phase 6 found that the design of record had not anticipated. Phase 3 left core with no Team page and therefore no way to LINK to one, so a notification email could name a Team and not take you to it. It is data rather than a callback: a function would put a module hook on the mail path to produce a string that never varies. Relative paths only, and protocol-relative is refused with absolute. The unsubscribe endpoint is the only write in the public tier and the only route with no `siteMode` — the reader is in their mail client, and the mail went out before the site went into maintenance. POST always answers 200, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET redirects and acts on nothing, so a mail client's link scanner cannot mute Teams nobody asked to leave. Co-Authored-By: Claude <noreply@anthropic.com>
486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
// Player · Team forums — the participant surface (TEAMS.md §5.4).
|
|
//
|
|
// Under `/player` rather than `/admin` for the reason §2.11 gives: a forum
|
|
// participant may be a plain player, a LEADER is a player, and the `/admin` tier
|
|
// gate is `requireRole('admin','editor','moderator')` — putting a leader endpoint
|
|
// behind it would mean widening that gate. The leader check is a per-handler
|
|
// question on top of the tier's `requireAuth`.
|
|
//
|
|
// **Two guards run before anything else in this file, in this order:**
|
|
//
|
|
// 1. `teams_forums_enabled` — off means every route here answers 404, not 403.
|
|
// A 403 says "this exists and you may not have it", which advertises a
|
|
// feature the operator deliberately turned off; 404 says "not a thing on
|
|
// this site", which is the true statement (§5.5.1).
|
|
// 2. the §2.5 access resolver — and never a membership check. Both a member and
|
|
// a granted non-member reach the forum, and asking `team_members` directly
|
|
// here is precisely how paths 1 and 3 drift back together.
|
|
//
|
|
// Both live in `resolveForum` below so a handler cannot forget either.
|
|
|
|
const teamsDb = require('../../../model/teams/teams.db')
|
|
const access = require('../../../model/teams/teamAccess.model')
|
|
const grants = require('../../../model/teams/teamGrants.model')
|
|
const forum = require('../../../model/teams/teamForum.model')
|
|
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
|
const uploads = require('../../../model/teams/teamForumUploads.model')
|
|
const reports = require('../../../model/reports/contentReports.model')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
const teamNotify = require('../../../utils/teamNotify')
|
|
|
|
const log = require('../../../utils/logger')('teams')
|
|
|
|
const STAFF_ROLES = ['admin', 'moderator']
|
|
const isStaff = (user) => STAFF_ROLES.includes(user?.role)
|
|
|
|
const fail = (res, err, what) => {
|
|
log.error(`player team forum: ${what} failed`, { message: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
|
|
const send = (res, result, body = { ok: true }) =>
|
|
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
|
|
|
|
/**
|
|
* The two guards, plus the Team, plus what this caller may do in it.
|
|
*
|
|
* Returns null when the caller should see a 404 — which covers three different
|
|
* situations on purpose: the forum is switched off, the Team does not exist, and
|
|
* the caller has no access to it. A private room's contents and its existence are
|
|
* the same secret.
|
|
*/
|
|
async function resolveForum(req) {
|
|
if (!(await forumSettings.forumsEnabled())) return null
|
|
const team = await teamsDb.findBySlug(req.params.slug)
|
|
if (!team) return null
|
|
|
|
const resolved = await access.forumAccess(team.id, req.user.id)
|
|
const staff = isStaff(req.user)
|
|
if (!resolved.allowed && !staff) return null
|
|
|
|
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
|
|
// ordinary housekeeping, and logging it as a staff intervention would put a
|
|
// guild's day-to-day tidying into the site's staff-accountability trail.
|
|
canModerate: resolved.isLeader || staff,
|
|
actorRole: resolved.isLeader ? 'leader' : 'staff',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
|
|
*
|
|
* **Here rather than in the forum model**, because the model takes an
|
|
* already-resolved access decision and reads no membership table by design, and
|
|
* the fan-out reads both to compute its recipients. A notification call inside the
|
|
* model would make it transitively depend on what its own header says it must not.
|
|
*
|
|
* **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
|
|
* everything and returns; awaiting it costs the response the time of one recipient
|
|
* query plus, in `immediate` mode, the SMTP calls — which is why the alternative
|
|
* (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
|
|
* Express handler is an unhandled rejection, and the tests would have no moment at
|
|
* which to assert the fan-out happened.
|
|
*/
|
|
async function announce(ctx, actor, notify) {
|
|
if (!notify) return
|
|
await teamNotify.forumPost({
|
|
team: ctx.team,
|
|
threadId: notify.threadId,
|
|
threadTitle: notify.title,
|
|
type: notify.type,
|
|
authorUserId: actor.id,
|
|
authorName: actor.username,
|
|
bodyHtml: notify.bodyHtml,
|
|
})
|
|
}
|
|
|
|
// ── threads ────────────────────────────────────────────────────────────────
|
|
|
|
async function listThreads(req, res) {
|
|
try {
|
|
const ctx = await resolveForum(req)
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return res.json({
|
|
threads: await forum.listThreads(ctx.team.id, { canModerate: 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(),
|
|
})
|
|
} catch (err) {
|
|
return fail(res, err, 'list threads')
|
|
}
|
|
}
|
|
|
|
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,
|
|
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) {
|
|
return fail(res, err, 'get thread')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a thread.
|
|
*
|
|
* **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' })
|
|
|
|
const type = req.body.type || 'announcement'
|
|
if (type === 'announcement' && !ctx.canModerate) {
|
|
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
|
|
}
|
|
|
|
const { notify, ...result } = await forum.createThread({
|
|
team: ctx.team,
|
|
actor: req.user,
|
|
type,
|
|
title: req.body.title,
|
|
body: req.body.body,
|
|
})
|
|
if (result.ok) await announce(ctx, req.user, notify)
|
|
return send(res, result)
|
|
} catch (err) {
|
|
return fail(res, err, 'create thread')
|
|
}
|
|
}
|
|
|
|
/** 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' })
|
|
|
|
const { notify, ...result } = await forum.createPost({
|
|
team: ctx.team,
|
|
threadId: Number(req.params.id),
|
|
actor: req.user,
|
|
body: req.body.body,
|
|
})
|
|
if (result.ok) await announce(ctx, req.user, notify)
|
|
return send(res, result)
|
|
} 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.
|
|
*
|
|
* A staff-exercised action ALSO writes `activity_log`; a leader-exercised one
|
|
* writes only the forum ledger (§5.3). That asymmetry is the whole reason the two
|
|
* ledgers are cross-referenced rather than merged: routing a guild leader locking
|
|
* a thread into the site's sanction pipeline would make ordinary housekeeping an
|
|
* appealable staff action.
|
|
*/
|
|
async function moderateThread(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.moderateThread({
|
|
team: ctx.team,
|
|
threadId: 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} thread #${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 thread')
|
|
}
|
|
}
|
|
|
|
// ── grants (§2.5 path 3, leader-exercised) ─────────────────────────────────
|
|
|
|
/**
|
|
* The grant surface is reachable whether or not the FORUM is on.
|
|
*
|
|
* Not an oversight: §5.5.1 says a toggle-off revokes no grant and that the rows
|
|
* stay authoritative, so a leader must still be able to see and manage them —
|
|
* they simply have nothing to grant access to for the moment. What the switch
|
|
* guards is the forum's CONTENT, not its access list.
|
|
*/
|
|
async function listGrants(req, res) {
|
|
try {
|
|
const team = await teamsDb.findBySlug(req.params.slug)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
const authority = await grants.authorityFor(team.id, req.user)
|
|
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
|
|
return res.json({
|
|
guests: await grants.forumGuests(team.id),
|
|
cap: await grants.grantCap(),
|
|
as: authority.as,
|
|
})
|
|
} catch (err) {
|
|
return fail(res, err, 'list grants')
|
|
}
|
|
}
|
|
|
|
async function createGrant(req, res) {
|
|
try {
|
|
const team = await teamsDb.findBySlug(req.params.slug)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
const result = await grants.grant({
|
|
team,
|
|
actor: req.user,
|
|
userId: req.body.userId,
|
|
username: req.body.username,
|
|
reason: req.body.reason,
|
|
})
|
|
if (result.ok && result.as === 'staff') {
|
|
await activity.log({
|
|
req,
|
|
action: 'team.forum.grant',
|
|
detail: `${req.user.username} (#${req.user.id}) granted forum access to ${result.grantee} `
|
|
+ `on team "${team.name}" (#${team.id})`,
|
|
})
|
|
}
|
|
return send(res, result)
|
|
} catch (err) {
|
|
return fail(res, err, 'create grant')
|
|
}
|
|
}
|
|
|
|
async function revokeGrant(req, res) {
|
|
try {
|
|
const team = await teamsDb.findBySlug(req.params.slug)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
const result = await grants.revoke({
|
|
team,
|
|
actor: req.user,
|
|
userId: Number(req.params.userId),
|
|
reason: req.body.reason,
|
|
})
|
|
if (result.ok && result.as === 'staff') {
|
|
await activity.log({
|
|
req,
|
|
action: 'team.forum.revoke',
|
|
detail: `${req.user.username} (#${req.user.id}) revoked forum access from ${result.grantee} `
|
|
+ `on team "${team.name}" (#${team.id})`,
|
|
})
|
|
}
|
|
return send(res, result)
|
|
} catch (err) {
|
|
return fail(res, err, 'revoke grant')
|
|
}
|
|
}
|
|
|
|
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* File a report about a thread, a post or an upload.
|
|
*
|
|
* **This is the one write in this file that does nothing to the content.** A
|
|
* report opens a queue item and changes no status, no flag and no counter — which
|
|
* is what keeps it out of §5.3's moderation ledger, and what stops "report" from
|
|
* becoming a way for any participant to hide anything.
|
|
*
|
|
* It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders
|
|
* moderate their own Team and a Team's leaders are exactly the people who will
|
|
* not report their own Team, so a leader-visible queue would hand a complaint
|
|
* about a leader straight back to them. There is deliberately no leader-facing
|
|
* view anywhere in this phase (org lead, 2026-08-18).
|
|
*
|
|
* The route sits behind the same `resolveForum` guard as everything else, so a
|
|
* reporter is by construction someone who can already see what they are
|
|
* reporting — and the model additionally checks the target really belongs to the
|
|
* Team the request came through, or the queue's per-Team filter would be lying.
|
|
*/
|
|
async function createReport(req, res) {
|
|
try {
|
|
const ctx = await resolveForum(req)
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
|
|
return send(res, await reports.file({
|
|
team: ctx.team,
|
|
actor: req.user,
|
|
targetType: req.body.targetType,
|
|
targetId: Number(req.body.targetId),
|
|
reason: req.body.reason,
|
|
detail: req.body.detail,
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'create report')
|
|
}
|
|
}
|
|
|
|
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The same 404 guard, applied at a second level: these routes answer 404 in any
|
|
* image mode but `uploads`, for the same reason the forum's do when the switch is
|
|
* off. An upload control the client offers and the server refuses is worse than
|
|
* no control, which is why the mode is published (§5.5.6) — but the SERVER is
|
|
* still what enforces it.
|
|
*/
|
|
async function createUpload(req, res) {
|
|
try {
|
|
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
|
|
const ctx = await resolveForum(req)
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
|
|
|
return send(res, await uploads.accept({ team: ctx.team, actor: req.user, file: req.file }))
|
|
} catch (err) {
|
|
return fail(res, err, 'upload')
|
|
}
|
|
}
|
|
|
|
async function deleteUpload(req, res) {
|
|
try {
|
|
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
|
|
const ctx = await resolveForum(req)
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return send(res, await uploads.remove({
|
|
id: Number(req.params.id),
|
|
actor: req.user,
|
|
isStaff: isStaff(req.user),
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'delete upload')
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
listThreads,
|
|
getThread,
|
|
createThread,
|
|
createPost,
|
|
editPost,
|
|
moderateThread,
|
|
moderatePost,
|
|
listGrants,
|
|
createGrant,
|
|
revokeGrant,
|
|
createUpload,
|
|
deleteUpload,
|
|
createReport,
|
|
}
|