TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the `moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or Discord-sanction-shaped, and nothing anywhere let a member say "this is a problem". That was survivable while every piece of content on the site came from staff; phase 5 lets players write to each other, so it stops being. The gap has a specific shape: leaders moderate their own Team's forum, and a Team's leaders are exactly the people who will not report their own Team. So the whole point of this queue is a path that routes AROUND a Team's own leadership. Org lead settled it on 2026-08-18: **reports are site administration only** — there is no leader-facing view of this queue, not even a read-only one scoped to their own Team. §5.6's "a leader may also see and act on reports for their own Team" is not implemented and is not deferred. `content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki page or a news comment becomes a value rather than a table — and the queue is mounted beside appeals under /admin/moderation rather than under Teams, because a staffer working a queue should have one place to work. **§5.6's literal unique key has a defect and this does not copy it.** Written as (target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide with each other too: reporter reports a post, staff dismiss it, the behaviour recurs, they report again — and the second dismissal is an UPDATE into a tuple that already exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The key is on a generated `open_marker` instead, the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open report per (target, reporter)". Two other departures from the doc, both small and both flagged in the docs PR: `handled_note`, because a queue whose resolution reason lives only in an activity_log line is one where the next staffer to see a repeat report cannot find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted Team does not leave a queue full of reports about content that no longer exists. Also here: a report is filed against a target the model verifies really belongs to the Team the request came through, or the queue's per-Team filter would quietly be lying; the queue resolves every row's target in three batched reads rather than N+1, which is §5.6's fourth rule (uploader, size and sniffed type without hunting) actually paying for §5.5.4's attribution table; a target that has since been hard-deleted comes back null and the report still lists, because "somebody reported this and by the time we looked it was gone" is a fact a moderator needs; and every transition writes activity_log, `dismissed` included — a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. `teams_forum_edit_window_minutes` gains its range validation on the admin settings PUT and is seeded at 15, so the value on the settings screen is the value in force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost. Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
11 KiB
JavaScript
295 lines
11 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
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
insertPost,
|
|
setPostStatus,
|
|
updatePostBody,
|
|
recountThread,
|
|
insertModeration,
|
|
moderationForTeam,
|
|
insertUpload,
|
|
uploadById,
|
|
bytesUploadedSince,
|
|
listUploads,
|
|
softDeleteUpload,
|
|
softDeleteUploadsForPost,
|
|
restoreUploadsForPost,
|
|
sweepableUploads,
|
|
orphanedUploads,
|
|
deleteUploadRows,
|
|
}
|