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
164 lines
5.7 KiB
JavaScript
164 lines
5.7 KiB
JavaScript
// 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, snapshot, actioned_with`
|
|
|
|
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, snapshot }) {
|
|
try {
|
|
const res = await query(
|
|
`INSERT INTO content_reports
|
|
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail, snapshot)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null,
|
|
snapshot ? JSON.stringify(snapshot) : 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.
|
|
*
|
|
* `actionedWith` is written only when given (the queue's "Hide for everyone"):
|
|
* a later status change by hand records a new decision, and does not un-say
|
|
* that the item was hidden.
|
|
*/
|
|
async function handle(id, { status, handledBy, handledUsername, note, actionedWith }) {
|
|
const withAction = actionedWith !== undefined
|
|
const res = await query(
|
|
`UPDATE content_reports
|
|
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
|
|
${withAction ? ', actioned_with = ?' : ''}
|
|
WHERE id = ?`,
|
|
[status, handledBy, handledUsername, note ?? null, ...(withAction ? [actionedWith] : []), 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_by, 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,
|
|
}
|