feat(teams): the grant flow, announcements, and the routes behind both guards
Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.
Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.
Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.
Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.
Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.
Under /player rather than /admin: 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.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
193
server/src/model/teams/teamForum.model.js
Normal file
193
server/src/model/teams/teamForum.model.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// ── The forum, phase 4 ("5a": access + announcements) ──────────────────────
|
||||
//
|
||||
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a ships the whole access
|
||||
// model and a single announcements stream per Team; 5b opens discussion threads,
|
||||
// replies and editing. The schema for all of it landed together, so 5b enables
|
||||
// paths here rather than migrating data — which is why `type` is a parameter
|
||||
// below and not a constant, and why `locked` is honoured on a thread nothing can
|
||||
// reply to yet.
|
||||
//
|
||||
// **Every function here takes an already-resolved access decision.** Nothing in
|
||||
// this file reads `team_members` or `team_forum_grants`; the caller asks
|
||||
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
|
||||
// by checking membership directly, which is how paths 1 and 3 would drift back
|
||||
// together", made structural.
|
||||
//
|
||||
// **The read path is where the image policy is applied**, once, in `renderPost`.
|
||||
// Not in the controller and never in the client: the client is TOLD the mode so it
|
||||
// can draw the right composer, and is never the thing that decides whether an
|
||||
// image appears (§5.5.6).
|
||||
|
||||
const forumDb = require('./teamForum.db')
|
||||
const forumSettings = require('./teamForumSettings.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
||||
|
||||
// Announcements are leader-authored and replies are disabled; 5b's discussion
|
||||
// threads are member-authored and take replies. Both types exist in the enum from
|
||||
// day one — this is the list of what 5a will CREATE.
|
||||
const CREATABLE_TYPES_5A = ['announcement']
|
||||
|
||||
const DELETED_AUTHOR = '[deleted account]'
|
||||
|
||||
/**
|
||||
* Moderation actions, and what each one does to the row.
|
||||
*
|
||||
* A table rather than a switch because the ledger and the effect have to stay in
|
||||
* step: every entry here writes one row of `team_forum_moderation` naming the
|
||||
* authority that was exercised, and an action with an effect but no ledger entry
|
||||
* would be a moderation nobody can audit.
|
||||
*/
|
||||
const THREAD_ACTIONS = {
|
||||
pin: { pinned: true },
|
||||
unpin: { pinned: false },
|
||||
lock: { locked: true },
|
||||
unlock: { locked: false },
|
||||
hide: { status: 'hidden' },
|
||||
unhide: { status: 'visible' },
|
||||
delete: { status: 'deleted' },
|
||||
restore: { status: 'visible' },
|
||||
}
|
||||
|
||||
function publicThread(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
author: row.created_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.created_by == null,
|
||||
createdAt: row.created_at,
|
||||
lastPostAt: row.last_post_at,
|
||||
postCount: row.post_count,
|
||||
pinned: Boolean(row.pinned),
|
||||
locked: Boolean(row.locked),
|
||||
status: row.status,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One post, rendered for one image policy.
|
||||
*
|
||||
* `body` is what the reader gets and `mode` decides whether it carries images.
|
||||
* The STORED html is never modified — flipping the policy changes this function's
|
||||
* output and nothing on disk, which is the property §5.5.3 exists to give and the
|
||||
* one acceptance criterion 3 measures.
|
||||
*/
|
||||
function renderPost(row, mode) {
|
||||
return {
|
||||
id: row.id,
|
||||
author: row.author_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.author_user_id == null,
|
||||
body: renderForumBody(row.body_html, mode),
|
||||
createdAt: row.created_at,
|
||||
editedAt: row.edited_at,
|
||||
status: row.status,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread list for one viewer.
|
||||
*
|
||||
* `canModerate` widens what is returned, not just what is offered: a hidden
|
||||
* thread is visible to the people who can unhide it and to nobody else, so the
|
||||
* same call answers both audiences without a second endpoint that could disagree
|
||||
* with this one.
|
||||
*/
|
||||
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
|
||||
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
|
||||
return rows.map(publicThread)
|
||||
}
|
||||
|
||||
/** One thread with its posts, rendered under the current image policy. */
|
||||
async function getThread(teamId, threadId, { canModerate = false } = {}) {
|
||||
const thread = await forumDb.threadById(threadId)
|
||||
// The team check is here rather than in the SQL so a thread id from another
|
||||
// Team reads as "not found" and not as "found, but not yours" — a forum is a
|
||||
// private room and the existence of a thread in it is itself private.
|
||||
if (!thread || thread.team_id !== teamId) return null
|
||||
if (thread.status === 'deleted' && !canModerate) return null
|
||||
if (thread.status === 'hidden' && !canModerate) return null
|
||||
|
||||
const mode = await forumSettings.imageMode()
|
||||
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
|
||||
return { ...publicThread(thread), posts: posts.map((p) => renderPost(p, mode)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Post an announcement: a thread and its first post, in one call.
|
||||
*
|
||||
* An announcement is a degenerate thread rather than its own thing (§5.1) — which
|
||||
* is why this writes the ordinary tables and 5b adds no migration. `locked` is
|
||||
* left false: replies are refused because the TYPE takes none, not because the
|
||||
* thread was closed, and conflating the two would make "unlock" look like it
|
||||
* would open replies on an announcement.
|
||||
*/
|
||||
async function createThread({ team, actor, type, title, body }) {
|
||||
if (!CREATABLE_TYPES_5A.includes(type)) {
|
||||
return { ok: false, status: 400, error: 'Only announcements can be posted yet' }
|
||||
}
|
||||
const cleaned = cleanForumBody(body)
|
||||
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
||||
return { ok: false, status: 400, error: 'An announcement needs a body' }
|
||||
}
|
||||
const threadId = await forumDb.insertThread({
|
||||
teamId: team.id,
|
||||
type,
|
||||
title,
|
||||
createdBy: actor.id,
|
||||
createdUsername: actor.username,
|
||||
})
|
||||
await forumDb.insertPost({
|
||||
threadId,
|
||||
authorUserId: actor.id,
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
return { ok: true, threadId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a moderation action to a thread, and record WHICH authority did it.
|
||||
*
|
||||
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
|
||||
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
|
||||
* The caller resolves it; this function records it and never infers it, because
|
||||
* an actor who is both would otherwise be recorded as whichever the code checked
|
||||
* first.
|
||||
*/
|
||||
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
|
||||
const effect = THREAD_ACTIONS[action]
|
||||
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
|
||||
|
||||
const thread = await forumDb.threadById(threadId)
|
||||
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
|
||||
|
||||
await forumDb.setThreadFlags(threadId, effect)
|
||||
await forumDb.insertModeration({
|
||||
teamId: team.id,
|
||||
targetType: 'thread',
|
||||
targetId: threadId,
|
||||
action,
|
||||
actorUserId: actor.id,
|
||||
actorUsername: actor.username,
|
||||
actorRole,
|
||||
reason,
|
||||
})
|
||||
return { ok: true, action, threadId }
|
||||
}
|
||||
|
||||
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
|
||||
async function moderationLedger(teamId, opts) {
|
||||
return forumDb.moderationForTeam(teamId, opts)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CREATABLE_TYPES_5A,
|
||||
THREAD_ACTIONS,
|
||||
listThreads,
|
||||
getThread,
|
||||
createThread,
|
||||
moderateThread,
|
||||
moderationLedger,
|
||||
publicThread,
|
||||
renderPost,
|
||||
}
|
||||
Reference in New Issue
Block a user