Merge branch 'main' into feat/password-reset
This commit is contained in:
@@ -1147,6 +1147,73 @@ adminRouter.post(
|
||||
moderation.addUserNote,
|
||||
)
|
||||
|
||||
// ── Appeals queue (Phase 6c, admin + moderator) ───────────────────────
|
||||
// Staff triage of player-submitted ban/mute appeals. Approving an appeal can
|
||||
// trigger an automatic Discord reversal (Phase 6d) — see resolveAppeal.
|
||||
adminRouter.get(
|
||||
'/moderation/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'List moderation appeals (default: pending + under_review)'
|
||||
// #swagger.description = 'Filter with ?status=<pending|under_review|approved|denied|withdrawn> or ?status=all. Paginated with ?limit&offset.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Appeals queue', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
moderation.getAppeals,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/appeals/:id',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Get a single moderation appeal'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.getAppeal,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/moderation/appeals/:id/claim',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Claim a pending appeal (→ under_review)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The claimed appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is not open for claiming', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.claimAppeal,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/moderation/appeals/:id/resolve',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Resolve an appeal (approved | denied); approval may auto-reverse the Discord action'
|
||||
// #swagger.description = 'Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResolveAppealRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The resolved appeal (with reversal outcome)', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealResolveResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (status must be approved or denied)', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('status').isIn(['approved', 'denied']),
|
||||
body('staff_response').optional({ values: 'falsy' }).isString().trim().isLength({ max: 4000 }),
|
||||
validate,
|
||||
moderation.resolveAppeal,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/user/:discordId/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Appeals submitted for a Discord user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
|
||||
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
// ── User management (admin only) ──────────────────────────────────────
|
||||
adminRouter.use('/users', adminOnly)
|
||||
adminRouter.get(
|
||||
|
||||
@@ -5,10 +5,19 @@
|
||||
const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('moderation')
|
||||
|
||||
// The statuses the appeals queue can be filtered to. ?status=all expands to all
|
||||
// of them; a specific ?status=<value> narrows to one; the default is the open
|
||||
// set (pending + under_review) that still needs staff attention.
|
||||
const APPEAL_STATUSES = ['pending', 'under_review', 'approved', 'denied', 'withdrawn']
|
||||
const DEFAULT_APPEAL_STATUSES = ['pending', 'under_review']
|
||||
|
||||
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
||||
const MAX_LIMIT = 200
|
||||
const DEFAULT_LIMIT = 50
|
||||
@@ -156,6 +165,136 @@ async function addUserNote(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 6c: appeals staff queue ─────────────────────────────────────
|
||||
// The status filter for the queue: ?status=all → every status, ?status=<one> →
|
||||
// just that one (if valid), otherwise the default open set.
|
||||
function appealStatusFilter(req) {
|
||||
const s = req.query.status
|
||||
if (s === 'all') return APPEAL_STATUSES
|
||||
if (APPEAL_STATUSES.includes(s)) return [s]
|
||||
return DEFAULT_APPEAL_STATUSES
|
||||
}
|
||||
|
||||
async function getAppeals(req, res) {
|
||||
try {
|
||||
const { limit, offset } = pageParams(req)
|
||||
const statuses = appealStatusFilter(req)
|
||||
return res.json(await appeals.queue({ statuses, limit, offset }))
|
||||
} catch (err) {
|
||||
log.error('getAppeals failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppeal(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
return res.json(appeal)
|
||||
} catch (err) {
|
||||
log.error('getAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Claim a pending appeal → under_review, stamping the claiming staffer. Only a
|
||||
// still-pending appeal can be claimed (a second claim, or claiming a resolved
|
||||
// one, is a 409).
|
||||
async function claimAppeal(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
if (appeal.status !== 'pending') {
|
||||
return res.status(409).json({ message: 'Appeal is not open for claiming' })
|
||||
}
|
||||
const updated = await appeals.claim(appeal.id, {
|
||||
handlerUserId: req.user.id,
|
||||
handlerTag: req.user.username,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.appeal.claim',
|
||||
detail: { appealId: appeal.id, discordUserId: appeal.discord_user_id },
|
||||
})
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
log.error('claimAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve an appeal (approved | denied). On an APPROVED ban/mute we best-effort
|
||||
// ask the bot to reverse the Discord action (unban / clear timeout). The bot
|
||||
// being down never fails the resolution — we record reversal_status='failed'
|
||||
// and still close the appeal. The response echoes the updated appeal plus a
|
||||
// `reversal` object describing what was attempted.
|
||||
async function resolveAppeal(req, res) {
|
||||
try {
|
||||
const status = req.body.status
|
||||
const staffResponse = req.body.staff_response ?? null
|
||||
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
if (isTerminal(appeal.status)) {
|
||||
return res.status(409).json({ message: 'Appeal is already resolved' })
|
||||
}
|
||||
|
||||
// Best-effort Discord reversal only for an approved, appealable action.
|
||||
const shouldReverse = status === 'approved' && isAppealableType(appeal.action_type)
|
||||
let botResult = null
|
||||
if (shouldReverse) {
|
||||
botResult = await botInternalClient.reverseModAction({
|
||||
discordUserId: appeal.discord_user_id,
|
||||
actionType: appeal.action_type,
|
||||
appealId: appeal.id,
|
||||
})
|
||||
}
|
||||
|
||||
const reversalStatus = reversalStatusFor({
|
||||
status,
|
||||
actionType: appeal.action_type,
|
||||
botOk: botResult ? botResult.ok : false,
|
||||
})
|
||||
|
||||
const updated = await appeals.resolve(appeal.id, {
|
||||
status,
|
||||
staffResponse,
|
||||
handlerUserId: req.user.id,
|
||||
handlerTag: req.user.username,
|
||||
reversalStatus,
|
||||
})
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.appeal.resolve',
|
||||
detail: { appealId: appeal.id, status, reversalStatus },
|
||||
})
|
||||
|
||||
// Describe the reversal so the UI can show "unban succeeded / failed / n/a".
|
||||
const reversal = {
|
||||
attempted: shouldReverse,
|
||||
ok: botResult ? botResult.ok : false,
|
||||
reversal_status: reversalStatus,
|
||||
bot_status: botResult ? botResult.status : null,
|
||||
error: botResult && !botResult.ok ? botResult.error || null : null,
|
||||
}
|
||||
|
||||
return res.json({ ...updated, reversal })
|
||||
} catch (err) {
|
||||
log.error('resolveAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserAppeals(req, res) {
|
||||
try {
|
||||
return res.json(await appeals.listForDiscordUser(req.params.discordId))
|
||||
} catch (err) {
|
||||
log.error('getUserAppeals failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSummary,
|
||||
getRecent,
|
||||
@@ -167,4 +306,9 @@ module.exports = {
|
||||
getUserActions,
|
||||
getUserNotes,
|
||||
addUserNote,
|
||||
getAppeals,
|
||||
getAppeal,
|
||||
claimAppeal,
|
||||
resolveAppeal,
|
||||
getUserAppeals,
|
||||
}
|
||||
|
||||
101
server/src/router/v1/player/appeals.controller.js
Normal file
101
server/src/router/v1/player/appeals.controller.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Player self-service moderation appeals (Phase 6c). A player appeals one of
|
||||
// their OWN ban/mute mod_actions: they see the actions they're allowed to appeal
|
||||
// (ban/mute with no active appeal), submit one, view their appeals, and withdraw
|
||||
// one that hasn't been resolved yet. Ownership is proven by matching the
|
||||
// action's target_user_id against the caller's linked Discord identity — the
|
||||
// same (provider='discord', subject=<snowflake>) link the SSO flow writes.
|
||||
// Mounted behind the player-role gate (see player.routes.js).
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const appealsDb = require('../../../model/appeals/appeals.db')
|
||||
const { isAppealableType, isTerminal } = require('../../../model/appeals/appeals.pure')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
|
||||
const log = require('../../../utils/logger')('player-appeals')
|
||||
|
||||
// The caller's linked Discord snowflake, or null if they have no Discord
|
||||
// identity linked. mod_actions are keyed by this snowflake.
|
||||
async function callerDiscordSubject(userId) {
|
||||
const identities = await userIdentities.listForUser(userId)
|
||||
const discord = identities.find((i) => i.provider === 'discord')
|
||||
return discord ? discord.subject : null
|
||||
}
|
||||
|
||||
// GET /player/appeals — the caller's own appeals, newest first.
|
||||
async function listMine(req, res) {
|
||||
try {
|
||||
return res.json(await appeals.listMine(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('listMine failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/appeals/eligible — the caller's ban/mute actions with no active
|
||||
// appeal. If the caller has no linked Discord account we return [] (not an
|
||||
// error) so the UI can show a "link your Discord account" hint instead.
|
||||
async function listEligible(req, res) {
|
||||
try {
|
||||
const subject = await callerDiscordSubject(req.user.id)
|
||||
if (!subject) return res.json([])
|
||||
return res.json(await appealsDb.eligibleActions(subject))
|
||||
} catch (err) {
|
||||
log.error('listEligible failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/appeals — open an appeal for one of the caller's own actions.
|
||||
async function create(req, res) {
|
||||
try {
|
||||
const modActionId = Number(req.body.mod_action_id)
|
||||
const submittedText = req.body.submitted_text
|
||||
|
||||
const action = await appealsDb.getModAction(modActionId)
|
||||
if (!action) return res.status(404).json({ message: 'Mod action not found' })
|
||||
|
||||
const subject = await callerDiscordSubject(req.user.id)
|
||||
if (!subject || String(action.target_user_id) !== String(subject)) {
|
||||
return res.status(403).json({ message: 'This action is not yours to appeal' })
|
||||
}
|
||||
|
||||
if (!isAppealableType(action.action_type)) {
|
||||
return res.status(400).json({ message: 'Only bans and mutes can be appealed' })
|
||||
}
|
||||
|
||||
const active = await appeals.activeForAction(modActionId)
|
||||
if (active) return res.status(409).json({ message: 'An appeal for this action is already open' })
|
||||
|
||||
const appeal = await appeals.submit({
|
||||
modActionId,
|
||||
discordUserId: subject,
|
||||
actionType: action.action_type,
|
||||
userId: req.user.id,
|
||||
submittedText,
|
||||
})
|
||||
return res.status(201).json(appeal)
|
||||
} catch (err) {
|
||||
log.error('create failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/appeals/:id/withdraw — the owner withdraws a non-resolved appeal.
|
||||
async function withdraw(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
// 404 for both "no such appeal" and "not the caller's" — never confirm the
|
||||
// existence of another player's appeal.
|
||||
if (!appeal || appeal.user_id !== req.user.id) {
|
||||
return res.status(404).json({ message: 'Appeal not found' })
|
||||
}
|
||||
if (isTerminal(appeal.status)) {
|
||||
return res.status(409).json({ message: 'This appeal is already resolved' })
|
||||
}
|
||||
return res.json(await appeals.withdraw(appeal.id))
|
||||
} catch (err) {
|
||||
log.error('withdraw failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listMine, listEligible, create, withdraw }
|
||||
@@ -11,6 +11,7 @@ const { body, param } = require('express-validator')
|
||||
|
||||
const account = require('../admin/account.controller')
|
||||
const shard = require('./shard.controller')
|
||||
const appeals = require('./appeals.controller')
|
||||
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -232,4 +233,58 @@ playerRouter.get(
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
// ── Moderation appeals (uo-link / Discord moderation) ──────────────────────
|
||||
// A player appeals one of their own ban/mute mod_actions. Ownership is proven by
|
||||
// matching the action against the caller's linked Discord identity.
|
||||
playerRouter.get(
|
||||
'/appeals',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'List the caller’s moderation appeals'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s appeals', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Appeal" } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
appeals.listMine,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/appeals/eligible',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'List the caller’s ban/mute actions eligible for appeal'
|
||||
// #swagger.description = 'The caller’s ban/mute mod_actions that have no active appeal. Returns an empty array when the caller has no linked Discord account (the UI shows a “link Discord” hint).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Appealable actions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealEligibleAction" } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
appeals.listEligible,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/appeals',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'Submit a moderation appeal for one of the caller’s actions'
|
||||
// #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the caller’s linked Discord identity) and has no active appeal.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateAppealRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Appeal created', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or the action type is not appealable', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The action does not belong to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Mod action not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'An appeal for this action is already open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('mod_action_id').isInt({ min: 1 }).toInt(),
|
||||
body('submitted_text').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||
validate,
|
||||
appeals.create,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/appeals/:id/withdraw',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'Withdraw one of the caller’s pending appeals'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id (must belong to the caller).' }
|
||||
/* #swagger.responses[200] = { description: 'The withdrawn appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such appeal for the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
appeals.withdraw,
|
||||
)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
Reference in New Issue
Block a user