// 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, }