// SQL for `content_reports` (TEAMS.md §5.6). // // Not under model/teams/ even though Team forum content is its only consumer // today: the table is deliberately generic — `target_type` is a VARCHAR so that a // wiki page or a news comment becomes a new value rather than a new table — and // filing it under a feature it will outgrow is how the next consumer ends up // building its own. // // Nothing here decides who may read a report. That is the route's job, and there // is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling // that reports are site administration only). const { query } = require('../../utils/db') const COLUMNS = ` id, target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail, status, handled_by, handled_username, handled_note, handled_at, created_at` const OPEN_STATUSES = ['open', 'reviewing'] /** * File a report. * * The duplicate is caught by the unique key rather than by a SELECT first, which * is the difference between "usually not a duplicate" and "never a duplicate": * two taps of a report button race, and only the index settles it. ER_DUP_ENTRY * comes back as a clean `null` so the caller can answer 409 without knowing what * a MySQL error code looks like. */ async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) { try { const res = await query( `INSERT INTO content_reports (target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`, [targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null], ) return res.insertId } catch (err) { if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null throw err } } async function byId(id) { const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id]) return rows[0] || null } /** * The queue. * * `status` defaults to the two OPEN statuses rather than to everything: a staffer * opening the queue wants the work, not the archive. 'all' is the explicit escape * hatch and every single status is selectable, so nothing is unreachable. */ async function list({ status, teamId, limit = 100, offset = 0 } = {}) { const where = [] const args = [] if (status && status !== 'all') { where.push('status = ?') args.push(status) } else if (!status) { where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`) args.push(...OPEN_STATUSES) } if (teamId) { where.push('team_id = ?') args.push(teamId) } args.push(limit, offset) return query( `SELECT ${COLUMNS} FROM content_reports ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, args, ) } /** How many are waiting, for the dashboard badge. */ async function openCount() { const rows = await query( `SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`, OPEN_STATUSES, ) return Number(rows[0]?.n || 0) } /** * Record a staffer's decision. * * `handled_*` is stamped for every status including `reviewing`, so "who has this" * is answerable while it is in progress and not only after it is closed — that is * what stops two staffers working the same report. */ async function handle(id, { status, handledBy, handledUsername, note }) { const res = await query( `UPDATE content_reports SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW() WHERE id = ?`, [status, handledBy, handledUsername, note ?? null, id], ) return res.affectedRows > 0 } // ── target enrichment ────────────────────────────────────────────────────── // // Three batched reads rather than one per row. §5.6's fourth rule — "reports on // uploads carry the team_forum_uploads row, so a staffer sees uploader, size and // sniffed type without hunting" — is the reason the queue enriches at all, and a // queue that N+1s to do it would be the version that gets turned off. async function threadsByIds(ids) { if (!ids.length) return [] return query( `SELECT id, team_id, title, type, status, created_username FROM team_forum_threads WHERE id IN (${ids.map(() => '?').join(',')})`, ids, ) } async function postsByIds(ids) { if (!ids.length) return [] return query( `SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status, p.created_at, t.team_id, t.title AS thread_title FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id WHERE p.id IN (${ids.map(() => '?').join(',')})`, ids, ) } async function uploadsByIds(ids) { if (!ids.length) return [] return query( `SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size, created_at, deleted_at FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`, ids, ) } module.exports = { OPEN_STATUSES, insert, byId, list, openCount, handle, threadsByIds, postsByIds, uploadsByIds, }