Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s

This commit is contained in:
2026-07-19 09:04:43 +00:00
23 changed files with 3083 additions and 8 deletions

View File

@@ -0,0 +1,156 @@
// Data-access for the server-owned `appeals` table (Phase 6c). Mirrors the
// modNotes/moderation split: this module is the only place that touches the
// table's SQL. Reads LEFT JOIN the bot-owned mod_actions row (no hard FK — the
// join is by the plain mod_action_id column) to surface the original action's
// target/reason/created_at, and LEFT JOIN users to surface the submitter's
// username. All writes belong to the site (the bot never touches this table).
const { query } = require('../../utils/db')
// Shared SELECT for a single appeal enriched with the originating action + the
// submitting account. ma.* columns are null when the mod_action was purged.
const APPEAL_SELECT = `
SELECT a.id, a.mod_action_id, a.discord_user_id, a.action_type, a.user_id,
a.status, a.submitted_text, a.staff_response,
a.handled_by_user_id, a.handled_by_tag, a.reversal_status,
a.submitted_at, a.resolved_at,
ma.target_tag AS action_target_tag,
ma.reason AS action_reason,
ma.created_at AS action_created_at,
ma.duration_seconds AS action_duration_seconds,
submitter.username AS submitter_username
FROM appeals a
LEFT JOIN mod_actions ma ON ma.id = a.mod_action_id
LEFT JOIN users submitter ON submitter.id = a.user_id`
async function insert({ modActionId, discordUserId, actionType, userId = null, submittedText }) {
const res = await query(
`INSERT INTO appeals (mod_action_id, discord_user_id, action_type, user_id, submitted_text)
VALUES (?, ?, ?, ?, ?)`,
[modActionId, discordUserId, actionType, userId, submittedText],
)
return res.insertId
}
async function getById(id) {
const rows = await query(`${APPEAL_SELECT} WHERE a.id = ? LIMIT 1`, [id])
return rows[0] || null
}
// The caller's own appeals, newest first (My Appeals page).
async function listForUser(userId) {
return query(`${APPEAL_SELECT} WHERE a.user_id = ? ORDER BY a.id DESC`, [userId])
}
// All appeals for a Discord id (admin per-user view), newest first.
async function listForDiscordUser(discordUserId) {
return query(`${APPEAL_SELECT} WHERE a.discord_user_id = ? ORDER BY a.id DESC`, [discordUserId])
}
// The staff queue: filtered to a set of statuses (array), newest first, paged.
// An empty `statuses` returns nothing rather than the whole table.
async function listQueue({ statuses = [], limit = 50, offset = 0 } = {}) {
if (!statuses.length) return []
const placeholders = statuses.map(() => '?').join(', ')
return query(
`${APPEAL_SELECT} WHERE a.status IN (${placeholders})
ORDER BY a.submitted_at ASC, a.id ASC
LIMIT ? OFFSET ?`,
[...statuses, limit, offset],
)
}
// The active (pending/under_review) appeal for a mod_action, or null. Used to
// enforce one-active-appeal-per-action.
async function activeForAction(modActionId) {
const rows = await query(
`SELECT id, status FROM appeals
WHERE mod_action_id = ? AND status IN ('pending','under_review')
LIMIT 1`,
[modActionId],
)
return rows[0] || null
}
// The caller's ban/mute mod_actions that have NO active appeal — the set of
// actions the player is allowed to open an appeal against. Left-anti-join
// against active appeals for the same action id.
async function eligibleActions(discordUserId) {
return query(
`SELECT ma.id, ma.action_type, ma.target_tag, ma.reason,
ma.duration_seconds, ma.created_at
FROM mod_actions ma
LEFT JOIN appeals a
ON a.mod_action_id = ma.id AND a.status IN ('pending','under_review')
WHERE ma.target_user_id = ?
AND ma.action_type IN ('ban','mute')
AND a.id IS NULL
ORDER BY ma.created_at DESC`,
[discordUserId],
)
}
// Read a single bot-owned mod_action by id, for submit-time validation (does it
// exist? is it the caller's? is it appealable?). Read-only — the site never
// writes mod_actions. Returns null when the id is unknown.
async function getModAction(modActionId) {
const rows = await query(
`SELECT id, action_type, target_user_id, target_tag, reason, duration_seconds, created_at
FROM mod_actions WHERE id = ? LIMIT 1`,
[modActionId],
)
return rows[0] || null
}
// pending -> under_review, stamping the claiming staffer.
async function setUnderReview(id, { handlerUserId, handlerTag }) {
await query(
`UPDATE appeals
SET status = 'under_review', handled_by_user_id = ?, handled_by_tag = ?
WHERE id = ?`,
[handlerUserId, handlerTag, id],
)
}
// Resolve to a terminal status (approved/denied), recording the staff response,
// handler, reversal outcome, and resolution timestamp.
async function resolve(id, { status, staffResponse, handlerUserId, handlerTag, reversalStatus }) {
await query(
`UPDATE appeals
SET status = ?, staff_response = ?, handled_by_user_id = ?, handled_by_tag = ?,
reversal_status = ?, resolved_at = NOW()
WHERE id = ?`,
[status, staffResponse ?? null, handlerUserId, handlerTag, reversalStatus, id],
)
}
// Straight status flip (used for withdraw). Stamps resolved_at when moving to a
// terminal status so the row shows when it closed.
async function setStatus(id, status) {
await query(
`UPDATE appeals
SET status = ?,
resolved_at = CASE WHEN ? IN ('approved','denied','withdrawn') THEN NOW() ELSE resolved_at END
WHERE id = ?`,
[status, status, id],
)
}
// Count of appeals grouped by status, for the queue's tab badges.
async function countByStatus() {
return query('SELECT status, COUNT(*) AS c FROM appeals GROUP BY status')
}
module.exports = {
insert,
getById,
listForUser,
listForDiscordUser,
listQueue,
activeForAction,
eligibleActions,
getModAction,
setUnderReview,
resolve,
setStatus,
countByStatus,
}

View File

@@ -0,0 +1,82 @@
// Business layer for moderation appeals (Phase 6c). A thin wrapper over
// appeals.db that returns the freshly-read row after each mutation, so callers
// always hand the client the enriched (joined) shape rather than a bare
// insert/update result. Ownership/type/duplicate validation lives in the
// controllers (they hold the request context — caller identity, the mod_action
// being appealed); this layer just performs the persistence.
const appealsDb = require('./appeals.db')
async function submit({ modActionId, discordUserId, actionType, userId, submittedText }) {
const id = await appealsDb.insert({ modActionId, discordUserId, actionType, userId, submittedText })
return appealsDb.getById(id)
}
async function getById(id) {
return appealsDb.getById(id)
}
// The caller's own appeals, newest first.
async function listMine(userId) {
return appealsDb.listForUser(userId)
}
// The caller's appealable actions (ban/mute with no active appeal).
async function eligibleActions(discordUserId) {
return appealsDb.eligibleActions(discordUserId)
}
// The active (pending/under_review) appeal for an action, or null.
async function activeForAction(modActionId) {
return appealsDb.activeForAction(modActionId)
}
// Owner-initiated withdraw: flip to 'withdrawn' and return the updated row.
async function withdraw(id) {
await appealsDb.setStatus(id, 'withdrawn')
return appealsDb.getById(id)
}
// Staff queue read.
async function queue({ statuses, limit, offset }) {
return appealsDb.listQueue({ statuses, limit, offset })
}
// Staff claim: pending -> under_review, stamping the handler.
async function claim(id, { handlerUserId, handlerTag }) {
await appealsDb.setUnderReview(id, { handlerUserId, handlerTag })
return appealsDb.getById(id)
}
// Staff resolution (approved/denied) with the reversal outcome already decided
// by the controller (which owns the best-effort bot call).
async function resolve(id, opts) {
await appealsDb.resolve(id, opts)
return appealsDb.getById(id)
}
// Appeals for a Discord id (admin per-user view).
async function listForDiscordUser(discordUserId) {
return appealsDb.listForDiscordUser(discordUserId)
}
// Number of appeals still awaiting first triage (status = 'pending'), for the
// dashboard badge.
async function pendingCount() {
const rows = await appealsDb.countByStatus()
const row = rows.find((r) => r.status === 'pending')
return row ? Number(row.c) : 0
}
module.exports = {
submit,
getById,
listMine,
eligibleActions,
activeForAction,
withdraw,
queue,
claim,
resolve,
listForDiscordUser,
pendingCount,
}

View File

@@ -0,0 +1,48 @@
// Pure helpers for the moderation-appeals feature (Phase 6c). No DB access —
// just the status/type vocabularies and row-shaping so both the model layer and
// the tests can reason about appeal state without a database.
// Terminal statuses: an appeal that has reached one of these is closed and can
// no longer be withdrawn, claimed, or resolved.
const TERMINAL = new Set(['approved', 'denied', 'withdrawn'])
// Active statuses: an appeal that still occupies the "one active appeal per
// action" slot. A player cannot open a second appeal for a mod_action while one
// of these is outstanding.
const ACTIVE = new Set(['pending', 'under_review'])
// Only bans and mutes are appealable (kicks/warns are not — a kick is not a
// standing state, and a warn carries no access restriction to reverse).
const APPEALABLE_TYPES = new Set(['ban', 'mute'])
// True if a status is one an appeal can still transition away from.
function isTerminal(status) {
return TERMINAL.has(status)
}
function isActive(status) {
return ACTIVE.has(status)
}
function isAppealableType(actionType) {
return APPEALABLE_TYPES.has(actionType)
}
// Map an approve/deny resolution to the reversal_status the row should carry.
// A denial never reverses; an approval only reverses when the underlying action
// is appealable (ban/mute) and the bot call succeeded.
function reversalStatusFor({ status, actionType, botOk }) {
if (status !== 'approved') return 'none'
if (!APPEALABLE_TYPES.has(actionType)) return 'none'
return botOk ? 'done' : 'failed'
}
module.exports = {
TERMINAL,
ACTIVE,
APPEALABLE_TYPES,
isTerminal,
isActive,
isAppealableType,
reversalStatusFor,
}