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
305 lines
12 KiB
JavaScript
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,
|
|
}
|