feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -41,6 +41,49 @@ async function activeGrants(teamId) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
|
||||||
|
async function activeGrantCount(teamId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
|
||||||
|
[teamId],
|
||||||
|
)
|
||||||
|
return Number(rows[0]?.n || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a grant.
|
||||||
|
*
|
||||||
|
* Writes nothing but this table — that is the non-contamination invariant, and it
|
||||||
|
* is a property of this function being the ONLY writer on the grant path rather
|
||||||
|
* than of anyone remembering it at the call site. The username snapshots are
|
||||||
|
* taken here so the ledger still reads after either account is deleted (§2.10).
|
||||||
|
*/
|
||||||
|
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke the active grant, if there is one.
|
||||||
|
*
|
||||||
|
* An UPDATE of the existing row rather than a delete: the table is a ledger as
|
||||||
|
* well as the current state, and `revoked_at` is what moves a row out of the
|
||||||
|
* unique key (the generated `active_marker` goes NULL) while keeping the history.
|
||||||
|
*/
|
||||||
|
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE team_forum_grants
|
||||||
|
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
|
||||||
|
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||||
|
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
|
||||||
|
)
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||||
@@ -87,6 +130,9 @@ module.exports = {
|
|||||||
activeGrant,
|
activeGrant,
|
||||||
grantLedger,
|
grantLedger,
|
||||||
activeGrants,
|
activeGrants,
|
||||||
|
activeGrantCount,
|
||||||
|
insertGrant,
|
||||||
|
revokeGrant,
|
||||||
overridesForTeam,
|
overridesForTeam,
|
||||||
overrideFor,
|
overrideFor,
|
||||||
setOverride,
|
setOverride,
|
||||||
|
|||||||
236
server/src/model/teams/teamForum.db.js
Normal file
236
server/src/model/teams/teamForum.db.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// SQL for the four forum tables (TEAMS.md §5.2, §5.2a).
|
||||||
|
//
|
||||||
|
// Kept apart from teamAccess.db.js for the same reason that file is kept apart
|
||||||
|
// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a
|
||||||
|
// query here that read `team_members` to decide who may see a thread would be the
|
||||||
|
// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand
|
||||||
|
// it a decision the resolver already made.
|
||||||
|
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const THREAD_COLUMNS = `
|
||||||
|
id, team_id, type, title, created_by, created_username, created_at,
|
||||||
|
last_post_at, post_count, pinned, locked, status`
|
||||||
|
|
||||||
|
const POST_COLUMNS = `
|
||||||
|
id, thread_id, author_user_id, author_username, body_html, created_at,
|
||||||
|
edited_at, edited_by, status`
|
||||||
|
|
||||||
|
// ── threads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Team's threads, newest activity first with pinned rows on top.
|
||||||
|
*
|
||||||
|
* `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden
|
||||||
|
* thread stays in the ledger and comes back with `unhide`, which is why the
|
||||||
|
* status filter is a parameter rather than a WHERE clause everyone remembers.
|
||||||
|
*/
|
||||||
|
async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) {
|
||||||
|
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
|
||||||
|
return query(
|
||||||
|
`SELECT ${THREAD_COLUMNS} FROM team_forum_threads
|
||||||
|
WHERE team_id = ? AND status IN ${statuses}
|
||||||
|
ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[teamId, limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadById(id) {
|
||||||
|
const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertThread({ teamId, type, title, createdBy, createdUsername }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), 0)`,
|
||||||
|
[teamId, type, title, createdBy, createdUsername],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply one moderation action's effect. The LEDGER row is written separately. */
|
||||||
|
async function setThreadFlags(id, { pinned, locked, status }) {
|
||||||
|
const sets = []
|
||||||
|
const args = []
|
||||||
|
if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) }
|
||||||
|
if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) }
|
||||||
|
if (status !== undefined) { sets.push('status = ?'); args.push(status) }
|
||||||
|
if (!sets.length) return false
|
||||||
|
args.push(id)
|
||||||
|
const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args)
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── posts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function postsByThread(threadId, { includeHidden = false } = {}) {
|
||||||
|
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
|
||||||
|
return query(
|
||||||
|
`SELECT ${POST_COLUMNS} FROM team_forum_posts
|
||||||
|
WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`,
|
||||||
|
[threadId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postById(id) {
|
||||||
|
const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a post and move the thread's counters in the same breath.
|
||||||
|
*
|
||||||
|
* Two statements rather than a trigger: the counters are a denormalisation for
|
||||||
|
* the thread list, and a trigger would put half the write in the schema where
|
||||||
|
* nobody reading this file would find it.
|
||||||
|
*/
|
||||||
|
async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[threadId, authorUserId, authorUsername, bodyHtml],
|
||||||
|
)
|
||||||
|
await query(
|
||||||
|
'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?',
|
||||||
|
[threadId],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPostStatus(id, status) {
|
||||||
|
const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id])
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the moderation ledger (append-only) ────────────────────────────────────
|
||||||
|
|
||||||
|
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO team_forum_moderation
|
||||||
|
(team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username,
|
||||||
|
actor_role, reason, created_at
|
||||||
|
FROM team_forum_moderation WHERE team_id = ?
|
||||||
|
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[teamId, limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── uploads (§5.2a) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const UPLOAD_COLUMNS = `
|
||||||
|
id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype,
|
||||||
|
byte_size, created_at, deleted_at, deleted_by`
|
||||||
|
|
||||||
|
async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO team_forum_uploads
|
||||||
|
(team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadById(id) {
|
||||||
|
const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */
|
||||||
|
async function bytesUploadedSince(userId, sinceHours) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads
|
||||||
|
WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`,
|
||||||
|
[userId, sinceHours],
|
||||||
|
)
|
||||||
|
return Number(rows[0]?.bytes || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin attribution view: who uploaded what, when, how much, and where. */
|
||||||
|
async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username,
|
||||||
|
u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by,
|
||||||
|
t.name AS team_name, t.slug AS team_slug
|
||||||
|
FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id
|
||||||
|
${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'}
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function softDeleteUpload(id, deletedBy) {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL',
|
||||||
|
[deletedBy, id],
|
||||||
|
)
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */
|
||||||
|
async function softDeleteUploadsForPost(postId, deletedBy) {
|
||||||
|
await query(
|
||||||
|
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL',
|
||||||
|
[deletedBy, postId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
|
||||||
|
async function sweepableUploads(retentionDays) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, filename FROM team_forum_uploads
|
||||||
|
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`,
|
||||||
|
[retentionDays],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */
|
||||||
|
async function orphanedUploads(graceHours) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, filename FROM team_forum_uploads
|
||||||
|
WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`,
|
||||||
|
[graceHours],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUploadRows(ids) {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const res = await query(
|
||||||
|
`DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
return res.affectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
threadsByTeam,
|
||||||
|
threadById,
|
||||||
|
insertThread,
|
||||||
|
setThreadFlags,
|
||||||
|
postsByThread,
|
||||||
|
postById,
|
||||||
|
insertPost,
|
||||||
|
setPostStatus,
|
||||||
|
insertModeration,
|
||||||
|
moderationForTeam,
|
||||||
|
insertUpload,
|
||||||
|
uploadById,
|
||||||
|
bytesUploadedSince,
|
||||||
|
listUploads,
|
||||||
|
softDeleteUpload,
|
||||||
|
softDeleteUploadsForPost,
|
||||||
|
sweepableUploads,
|
||||||
|
orphanedUploads,
|
||||||
|
deleteUploadRows,
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
165
server/src/model/teams/teamGrants.model.js
Normal file
165
server/src/model/teams/teamGrants.model.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The RESOLVER lives in teamAccess.model.js and answers "may this account use the
|
||||||
|
// forum". This file is the WRITE half: who may hand that access out, to whom, and
|
||||||
|
// what stops a leader turning a Team forum into open hosting on the operator's
|
||||||
|
// site.
|
||||||
|
//
|
||||||
|
// **Two authorities, and they are not the same authority with different reach.**
|
||||||
|
//
|
||||||
|
// staff (admin | moderator) — any Team, no cap, may revoke anything
|
||||||
|
// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant
|
||||||
|
//
|
||||||
|
// The last clause is the one worth stating: a leader who could revoke a
|
||||||
|
// staff-issued grant could undo a moderation decision, which is the whole reason
|
||||||
|
// `granted_by` is retained rather than collapsed into a boolean.
|
||||||
|
//
|
||||||
|
// **Nothing here writes `team_members`, in either direction, ever.** A grant is
|
||||||
|
// not a membership: it may name any Runic Gateway account, including one with no
|
||||||
|
// linked game identity at all — that is the point of it, since letting an unlinked
|
||||||
|
// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such
|
||||||
|
// an account off the roster and out of every membership count, and path 4 keeps it
|
||||||
|
// off external platforms.
|
||||||
|
|
||||||
|
const accessDb = require('./teamAccess.db')
|
||||||
|
const teamsDb = require('./teams.db')
|
||||||
|
const access = require('./teamAccess.model')
|
||||||
|
const usersDb = require('../users/users.db')
|
||||||
|
const settingsDb = require('../settings/settings.db')
|
||||||
|
|
||||||
|
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
|
||||||
|
// unlimited arbitrary accounts to a private space on the operator's host is a
|
||||||
|
// quiet way to turn a Team forum into free hosting; the cap is what makes it a
|
||||||
|
// decision the operator made rather than one a leader made for them.
|
||||||
|
const CAP_KEY = 'teams_max_grants_per_team'
|
||||||
|
const DEFAULT_CAP = 50
|
||||||
|
|
||||||
|
const STAFF_ROLES = ['admin', 'moderator']
|
||||||
|
|
||||||
|
async function grantCap() {
|
||||||
|
const raw = await settingsDb.get(CAP_KEY)
|
||||||
|
const n = Number.parseInt(raw, 10)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStaff = (actor) => STAFF_ROLES.includes(actor?.role)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What may this actor do with grants on this Team?
|
||||||
|
*
|
||||||
|
* Resolved once and returned whole, so the controller asks a question rather than
|
||||||
|
* assembling the answer from three booleans — the shape that lets a leader check
|
||||||
|
* and a staff check drift apart.
|
||||||
|
*/
|
||||||
|
async function authorityFor(teamId, actor) {
|
||||||
|
if (isStaff(actor)) return { may: true, as: 'staff' }
|
||||||
|
const leads = await access.isLeaderByUser(teamId, actor?.id)
|
||||||
|
return { may: leads, as: leads ? 'leader' : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a grant. Returns the model result shape the Teams controllers translate:
|
||||||
|
* `{ ok }` or `{ ok: false, status, error }`.
|
||||||
|
*
|
||||||
|
* `warning` on a staff grant past the cap is deliberate and is not an error:
|
||||||
|
* staff are exempt, and silently exceeding a ceiling the operator configured is
|
||||||
|
* worth saying out loud on the way past.
|
||||||
|
*/
|
||||||
|
async function grant({ team, actor, userId, username, reason }) {
|
||||||
|
const authority = await authorityFor(team.id, actor)
|
||||||
|
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
|
||||||
|
|
||||||
|
const target = userId
|
||||||
|
? await usersDb.findById(userId)
|
||||||
|
: await usersDb.findByUsername(username)
|
||||||
|
if (!target) return { ok: false, status: 404, error: 'No such account' }
|
||||||
|
|
||||||
|
const existing = await accessDb.activeGrant(team.id, target.id)
|
||||||
|
if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' }
|
||||||
|
|
||||||
|
const cap = await grantCap()
|
||||||
|
const count = await accessDb.activeGrantCount(team.id)
|
||||||
|
let warning = null
|
||||||
|
if (count >= cap) {
|
||||||
|
if (authority.as === 'leader') {
|
||||||
|
return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` }
|
||||||
|
}
|
||||||
|
warning = `This Team is past the configured limit of ${cap} forum guests`
|
||||||
|
}
|
||||||
|
|
||||||
|
await accessDb.insertGrant({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: target.id,
|
||||||
|
username: target.username,
|
||||||
|
grantedBy: actor.id,
|
||||||
|
grantedUsername: actor.username,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a grant.
|
||||||
|
*
|
||||||
|
* The one asymmetry with `grant`: a leader may not revoke what staff issued.
|
||||||
|
* Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored
|
||||||
|
* flag, so an account that has since lost its staff role stops protecting the
|
||||||
|
* grants it made — which is the behaviour an operator demoting someone expects.
|
||||||
|
*/
|
||||||
|
async function revoke({ team, actor, userId, reason }) {
|
||||||
|
const authority = await authorityFor(team.id, actor)
|
||||||
|
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
|
||||||
|
|
||||||
|
const existing = await accessDb.activeGrant(team.id, userId)
|
||||||
|
if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' }
|
||||||
|
|
||||||
|
if (authority.as === 'leader' && existing.granted_by) {
|
||||||
|
const issuer = await usersDb.findById(existing.granted_by)
|
||||||
|
if (isStaff(issuer)) {
|
||||||
|
return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await accessDb.revokeGrant({
|
||||||
|
teamId: team.id,
|
||||||
|
userId,
|
||||||
|
revokedBy: actor.id,
|
||||||
|
revokedUsername: actor.username,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
return { ok: true, as: authority.as, grantee: existing.username }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Team's forum guests — active grants for accounts that are NOT members.
|
||||||
|
*
|
||||||
|
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
|
||||||
|
* and a grantee is a member, listed on the roster, and appears here not at all.
|
||||||
|
* Both facts stay true in the ledger; only the presentation picks one.
|
||||||
|
*/
|
||||||
|
async function forumGuests(teamId) {
|
||||||
|
const [grants, members] = await Promise.all([
|
||||||
|
accessDb.activeGrants(teamId),
|
||||||
|
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
|
||||||
|
])
|
||||||
|
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
|
||||||
|
return grants
|
||||||
|
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
|
||||||
|
.map((g) => ({
|
||||||
|
userId: g.user_id,
|
||||||
|
username: g.username,
|
||||||
|
grantedBy: g.granted_username,
|
||||||
|
grantedAt: g.granted_at,
|
||||||
|
reason: g.reason,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CAP_KEY,
|
||||||
|
DEFAULT_CAP,
|
||||||
|
grantCap,
|
||||||
|
authorityFor,
|
||||||
|
grant,
|
||||||
|
revoke,
|
||||||
|
forumGuests,
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ const access = require('../../../model/teams/teamAccess.model')
|
|||||||
const teamSync = require('../../../model/teams/teamSync.model')
|
const teamSync = require('../../../model/teams/teamSync.model')
|
||||||
const teamsDb = require('../../../model/teams/teams.db')
|
const teamsDb = require('../../../model/teams/teams.db')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
const forum = require('../../../model/teams/teamForum.model')
|
||||||
|
const forumDb = require('../../../model/teams/teamForum.db')
|
||||||
|
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
|
||||||
|
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('teams')
|
const log = require('../../../utils/logger')('teams')
|
||||||
|
|
||||||
@@ -85,6 +89,65 @@ async function grants(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Forum: the ledger and the upload attribution view (§5.4) ──────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Team's forum moderation ledger.
|
||||||
|
*
|
||||||
|
* Served whether or not the forum is switched on, unlike every /player forum
|
||||||
|
* route. The switch guards the forum as a FEATURE — what members can read and
|
||||||
|
* write — and an operator who turned it off to deal with a problem is precisely
|
||||||
|
* the operator who needs to see what was moderated (§5.5.1: no data is deleted).
|
||||||
|
*/
|
||||||
|
async function forumModeration(req, res) {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const team = await teamsDb.findById(id)
|
||||||
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||||
|
return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) })
|
||||||
|
} catch (err) {
|
||||||
|
return fail(res, err, 'forum moderation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who uploaded what, when, and how much — across every Team.
|
||||||
|
*
|
||||||
|
* This view is the reason §5.5.4 added an attribution table at all: the
|
||||||
|
* acknowledgement an operator gives before enabling uploads is meaningless if the
|
||||||
|
* question it makes them responsible for cannot be answered afterwards.
|
||||||
|
*/
|
||||||
|
async function forumUploads(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json({
|
||||||
|
uploads: await forumDb.listUploads({
|
||||||
|
limit: Number(req.query.limit) || 100,
|
||||||
|
offset: Number(req.query.offset) || 0,
|
||||||
|
includeDeleted: req.query.deleted === '1',
|
||||||
|
}),
|
||||||
|
quota: {
|
||||||
|
dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES,
|
||||||
|
retentionDays: forumUploadsModel.RETENTION_DAYS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return fail(res, err, 'forum uploads')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The forum settings' own state — the acknowledgement, which is not a public key. */
|
||||||
|
async function forumSettingsState(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json({
|
||||||
|
enabled: await forumSettings.forumsEnabled(),
|
||||||
|
imageMode: await forumSettings.imageMode(),
|
||||||
|
acknowledgement: await forumSettings.ackState(),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return fail(res, err, 'forum settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
||||||
|
|
||||||
async function setLeaderOverride(req, res) {
|
async function setLeaderOverride(req, res) {
|
||||||
@@ -195,6 +258,9 @@ async function decideRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
forumModeration,
|
||||||
|
forumUploads,
|
||||||
|
forumSettingsState,
|
||||||
listTeams,
|
listTeams,
|
||||||
getTeam,
|
getTeam,
|
||||||
resync,
|
resync,
|
||||||
|
|||||||
@@ -92,6 +92,37 @@ teamsRouter.post(
|
|||||||
|
|
||||||
// ── :id paths ──────────────────────────────────────────────────────────────
|
// ── :id paths ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
|
||||||
|
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
|
||||||
|
// on the validator to reject it would mean the route table's meaning depended on
|
||||||
|
// a param check three lines further down.
|
||||||
|
teamsRouter.get(
|
||||||
|
'/forum/uploads',
|
||||||
|
// #swagger.tags = ['Admin · Teams']
|
||||||
|
// #swagger.summary = 'Upload attribution across every Team forum'
|
||||||
|
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||||
|
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
|
||||||
|
query('offset').optional().isInt({ min: 0 }).toInt(),
|
||||||
|
query('deleted').optional().isIn(['0', '1']),
|
||||||
|
validate,
|
||||||
|
ctrl.forumUploads,
|
||||||
|
)
|
||||||
|
|
||||||
|
teamsRouter.get(
|
||||||
|
'/forum/settings',
|
||||||
|
// #swagger.tags = ['Admin · Teams']
|
||||||
|
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgement’s state'
|
||||||
|
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
|
||||||
|
ctrl.forumSettingsState,
|
||||||
|
)
|
||||||
|
|
||||||
teamsRouter.get(
|
teamsRouter.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
// #swagger.tags = ['Admin · Teams']
|
// #swagger.tags = ['Admin · Teams']
|
||||||
@@ -119,6 +150,20 @@ teamsRouter.get(
|
|||||||
ctrl.grants,
|
ctrl.grants,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
teamsRouter.get(
|
||||||
|
'/:id/forum/moderation',
|
||||||
|
// #swagger.tags = ['Admin · Teams']
|
||||||
|
// #swagger.summary = 'A Team’s forum moderation ledger'
|
||||||
|
// #swagger.description = 'Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.'
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt({ min: 1 }).toInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.forumModeration,
|
||||||
|
)
|
||||||
|
|
||||||
teamsRouter.post(
|
teamsRouter.post(
|
||||||
'/:id/archive',
|
'/:id/archive',
|
||||||
// #swagger.tags = ['Admin · Teams']
|
// #swagger.tags = ['Admin · Teams']
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
|||||||
const accountRouter = require('./account.router')
|
const accountRouter = require('./account.router')
|
||||||
const appealsRouter = require('./appeals.router')
|
const appealsRouter = require('./appeals.router')
|
||||||
const teamsRouter = require('./teams.router')
|
const teamsRouter = require('./teams.router')
|
||||||
|
const teamForumRouter = require('./teamForum.router')
|
||||||
|
|
||||||
const playerRouter = express.Router()
|
const playerRouter = express.Router()
|
||||||
|
|
||||||
@@ -41,5 +42,9 @@ playerRouter.use(noindex, requireAuth)
|
|||||||
playerRouter.use('/account', accountRouter)
|
playerRouter.use('/account', accountRouter)
|
||||||
playerRouter.use('/appeals', appealsRouter)
|
playerRouter.use('/appeals', appealsRouter)
|
||||||
playerRouter.use('/teams', teamsRouter)
|
playerRouter.use('/teams', teamsRouter)
|
||||||
|
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
||||||
|
// different capability from "the caller's own Teams", and splitting them keeps
|
||||||
|
// each file about one thing; no path in the two collides.
|
||||||
|
playerRouter.use('/teams', teamForumRouter)
|
||||||
|
|
||||||
module.exports = playerRouter
|
module.exports = playerRouter
|
||||||
|
|||||||
288
server/src/router/v1/player/teamForum.controller.js
Normal file
288
server/src/router/v1/player/teamForum.controller.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// 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 activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
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 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 }),
|
||||||
|
canPost: 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 })
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post an announcement. 5a: leaders (and staff) only, replies disabled.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 result = await forum.createThread({
|
||||||
|
team: ctx.team,
|
||||||
|
actor: req.user,
|
||||||
|
type: req.body.type || 'announcement',
|
||||||
|
title: req.body.title,
|
||||||
|
body: req.body.body,
|
||||||
|
})
|
||||||
|
return send(res, result)
|
||||||
|
} catch (err) {
|
||||||
|
return fail(res, err, 'create thread')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
moderateThread,
|
||||||
|
listGrants,
|
||||||
|
createGrant,
|
||||||
|
revokeGrant,
|
||||||
|
createUpload,
|
||||||
|
deleteUpload,
|
||||||
|
}
|
||||||
198
server/src/router/v1/player/teamForum.router.js
Normal file
198
server/src/router/v1/player/teamForum.router.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Player · Team forums (TEAMS.md §5.4) and the leader-exercised grant flow (§2.11).
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/player/teams by player/index.js — the SAME prefix as
|
||||||
|
// teams.router.js, which is why this file exists separately rather than being
|
||||||
|
// merged into it: that router is the caller's own Team reads, this one is the
|
||||||
|
// forum and the grants. Express walks both in mount order and no path collides
|
||||||
|
// ('/:slug/access' vs '/:slug/forum/*' and '/:slug/grants').
|
||||||
|
//
|
||||||
|
// Every forum route here 404s while `teams_forums_enabled` is off, and the upload
|
||||||
|
// routes 404 in any image mode but `uploads`. Both guards are in the controller
|
||||||
|
// rather than in middleware here, because both need the resolved Team and the
|
||||||
|
// caller's access to decide, and a guard that answers before those are known
|
||||||
|
// would have to answer 403 — which is the thing §5.5.1 says not to say.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const ctrl = require('./teamForum.controller')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
const { makeLimiter } = require('../../../middleware/rateLimit')
|
||||||
|
const { upload } = require('../admin/imageUpload')
|
||||||
|
|
||||||
|
const forumRouter = express.Router()
|
||||||
|
|
||||||
|
// Writes are rate-limited, reads are not. The caps are per IP and generous enough
|
||||||
|
// that a Team having a busy afternoon never meets them; what they stop is a script.
|
||||||
|
const postLimiter = makeLimiter({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
label: 'team-forum-post',
|
||||||
|
message: 'Too many forum posts. Please slow down.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tighter than posting, and for a different reason: §2.5 caps how many active
|
||||||
|
// grants a Team may hold, and this caps how fast a leader may approach that cap.
|
||||||
|
const grantLimiter = makeLimiter({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
max: 15,
|
||||||
|
label: 'team-forum-grant',
|
||||||
|
message: 'Too many grant changes. Please slow down.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bytes, not requests: the per-account daily quota lives in the uploads model,
|
||||||
|
// and this is the per-IP flood guard in front of it.
|
||||||
|
const uploadLimiter = makeLimiter({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
label: 'team-forum-upload',
|
||||||
|
message: 'Too many uploads. Please slow down.',
|
||||||
|
})
|
||||||
|
|
||||||
|
forumRouter.get(
|
||||||
|
'/:slug/forum/threads',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'List a Team forum’s threads'
|
||||||
|
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` 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. Hidden threads are included for a leader or staff and for nobody else.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Forum off, no such Team, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
ctrl.listThreads,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.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.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" } } } } */
|
||||||
|
postLimiter,
|
||||||
|
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
|
||||||
|
body('type').optional().isIn(['announcement']),
|
||||||
|
body('title').isString().trim().isLength({ min: 1, max: 200 }),
|
||||||
|
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||||
|
validate,
|
||||||
|
ctrl.createThread,
|
||||||
|
)
|
||||||
|
|
||||||
|
forumRouter.get(
|
||||||
|
'/:slug/forum/threads/:id',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Read one thread and its posts'
|
||||||
|
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
|
||||||
|
// #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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The thread', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThread" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Forum off, no such thread, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt({ min: 1 }).toInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.getThread,
|
||||||
|
)
|
||||||
|
|
||||||
|
forumRouter.post(
|
||||||
|
'/:slug/forum/threads/:id/moderate',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Pin, lock, hide or delete a thread'
|
||||||
|
// #swagger.description = 'Leader or staff. Every action writes the Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s mod_actions/appeals pair, which is Discord-sanction-shaped.'
|
||||||
|
// #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: ['action'], properties: { action: { type: 'string', enum: ['pin','unpin','lock','unlock','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' }, threadId: { type: 'integer' } } } } } } */
|
||||||
|
/* #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(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
|
||||||
|
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
ctrl.moderateThread,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── grants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forumRouter.get(
|
||||||
|
'/:slug/grants',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'The Team’s forum guests, and the per-Team cap'
|
||||||
|
// #swagger.description = 'Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Forum guests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumGuestList" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
ctrl.listGrants,
|
||||||
|
)
|
||||||
|
|
||||||
|
forumRouter.post(
|
||||||
|
'/:slug/grants',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Grant forum access to an account'
|
||||||
|
// #swagger.description = 'A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, reason: { type: 'string', maxLength: 255 } } } } } } */
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Granted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' }, warning: { type: 'string' } } } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Already granted, or the Team is at its cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
grantLimiter,
|
||||||
|
body('userId').optional().isInt({ min: 1 }).toInt(),
|
||||||
|
body('username').optional().isString().trim().isLength({ min: 1, max: 32 }),
|
||||||
|
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
ctrl.createGrant,
|
||||||
|
)
|
||||||
|
|
||||||
|
forumRouter.delete(
|
||||||
|
'/:slug/grants/:userId',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Revoke forum access'
|
||||||
|
// #swagger.description = 'The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader 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, so an account that has since lost its staff role stops protecting the grants it made.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
// #swagger.parameters['userId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grantee’s account id.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' } } } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Not a leader, or the grant was staff-issued', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
grantLimiter,
|
||||||
|
param('userId').isInt({ min: 1 }).toInt(),
|
||||||
|
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
ctrl.revokeGrant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── uploads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forumRouter.post(
|
||||||
|
'/:slug/forum/uploads',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Upload an image to a Team forum'
|
||||||
|
// #swagger.description = 'Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload path’s 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a client’s Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: 'object', properties: { image: { type: 'string', format: 'binary' } } } } } } */
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Stored', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, id: { type: 'integer' }, url: { type: 'string' }, bytes: { type: 'integer' } } } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Not the image type it claims to be', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Daily upload quota reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
uploadLimiter,
|
||||||
|
upload.single('image'),
|
||||||
|
ctrl.createUpload,
|
||||||
|
)
|
||||||
|
|
||||||
|
forumRouter.delete(
|
||||||
|
'/:slug/forum/uploads/:id',
|
||||||
|
// #swagger.tags = ['Player · Teams']
|
||||||
|
// #swagger.summary = 'Remove an uploaded image'
|
||||||
|
// #swagger.description = 'The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.'
|
||||||
|
// #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 upload id.' }
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Not your upload', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt({ min: 1 }).toInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.deleteUpload,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = forumRouter
|
||||||
Reference in New Issue
Block a user