Files
website/server/src/model/teams/teamForum.db.js
wtclaude 41a1e84b51
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s
feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 05:41:00 -05:00

305 lines
12 KiB
JavaScript

// 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
}
/** A thread's opening post, whatever its status — what a report on the thread quotes. */
async function firstPostOf(threadId) {
const rows = await query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE thread_id = ? ORDER BY created_at, id LIMIT 1`,
[threadId],
)
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
}
/**
* Rewrite a post's body, stamping who edited it and when.
*
* `edited_at` is set unconditionally, including when a staffer edits — the column
* answers "has this been changed since it was written", which a reader needs to
* know regardless of whose hand did it. `edited_by` is the second half of that
* answer and is why the two are separate columns rather than a boolean.
*/
async function updatePostBody(id, bodyHtml, editedBy) {
const res = await query(
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
[bodyHtml, editedBy, id],
)
return res.affectedRows > 0
}
/**
* Recompute a thread's denormalised counters from the posts that are actually
* visible.
*
* Called after every post moderation rather than incrementing and decrementing,
* because hide → unhide → delete → restore is a sequence in which a counter kept
* by deltas drifts the first time any step is retried or raced. The read is one
* indexed aggregate over one thread; correctness is worth more than the write it
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
*/
async function recountThread(threadId) {
await query(
`UPDATE team_forum_threads t
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible'),
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible')
WHERE t.id = ?`,
[threadId],
)
}
// ── 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],
)
}
/**
* The other half of the pair: a restored post gets its images back.
*
* Without this, `delete` then `restore` returns the words and loses the pictures —
* and loses them SILENTLY, because the soft-deleted rows survive the retention
* window before the sweep takes the bytes, so the post looks fine until the night
* it does not. Beyond that window the row itself is gone and this is a no-op;
* nothing can be done about that and nothing should pretend otherwise.
*/
async function restoreUploadsForPost(postId) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
[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,
firstPostOf,
insertPost,
setPostStatus,
updatePostBody,
recountThread,
insertModeration,
moderationForTeam,
insertUpload,
uploadById,
bytesUploadedSince,
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,
}