// 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, }