Files
docs/website/MODERATION_APPEALS.md

5.7 KiB

Runic Gateway Website — Moderation Appeals (Phase 6c/6d)

Website feature branch: feature/moderation-appeals. Builds on the moderation dashboard (Phase 6a/6b) and the Discord bot's mod_actions log. Companion to website-README.md (overview) and BACKEND_DESIGN.md (base API contract).

1. Overview

A player whose linked Discord identity was banned or muted — an action recorded in the bot's mod_actions log — can open an appeal from the player portal and track its status. Staff (admin or moderator role) work the appeal from an appeals queue in the admin moderation section: claim it, then resolve it approved or denied with a written staff response.

When staff approve a ban/mute appeal, the website makes a best-effort call to the Discord bot's internal API to actually lift the ban / clear the timeout in Discord, and the bot posts a mod-log embed ("Appeal approved"). This is best-effort: if the bot is unreachable the appeal still resolves as approved, the reversal is recorded as failed, and staff can reverse the sanction manually in Discord.

Only ban and mute actions are appealable — the sanctions that have an ongoing effect. Warnings/kicks and similar one-shot actions are not.

2. Ownership & eligibility

  • appeals is a server-owned table — only the website reads/writes it. It references the bot-owned mod_actions log by a plain id column (mod_action_id); there is no hard cross-owner foreign key between the two databases, so the reference is validated in application code (same pattern as the rest of the uo-link / bot integration, where the two services never share a live FK).
  • Eligibility — the appellant must be a logged-in player whose linked Discord identity (user_identities, provider = 'discord') matches the mod_actions row's target. A player cannot open an appeal for someone else's action, and an unlinked player has nothing eligible to appeal.
  • One active appeal per action — only one pending / under_review appeal is allowed for a given mod_action_id at a time; a second attempt while one is already open is rejected.

3. Appeal lifecycle

pending ──▶ under_review ──▶ approved
                          └─▶ denied

pending ──▶ withdrawn
under_review ──▶ withdrawn
  • pending — submitted by the player, not yet claimed.
  • under_review — claimed by a staffer (the claiming admin/moderator is stamped on the row).
  • approved / denied — resolved by staff with an optional staff_response. Approving a ban/mute appeal triggers the Phase 6d reversal (§5).
  • withdrawn — the player pulled the appeal back before it was resolved.

reversal_status (only meaningful on an approved ban/mute appeal) is one of none (not attempted / not applicable), done, or failed. No new mod_actions row is written for a reversal — it modifies the original action's standing rather than logging a new one.

4. API — player (role: player)

Base /api/v1/player/appeals.

Method Path Purpose
GET /player/appeals The caller's own appeals.
GET /player/appeals/eligible The caller's ban/mute actions with no active appeal (empty if they have no linked Discord identity).
POST /player/appeals Open an appeal — { mod_action_id, submitted_text }. 403 if the action isn't the caller's, 400 if the action isn't a ban/mute, 409 if one is already open for it.
POST /player/appeals/:id/withdraw Withdraw an appeal that hasn't been resolved yet.

5. API — staff (role: admin or moderator)

Base /api/v1/admin/moderation/appeals, alongside the existing moderation section.

Method Path Purpose
GET /admin/moderation/appeals?status=&limit=&offset= The queue. Defaults to pending + under_review; pass status=all or a specific status to filter.
GET /admin/moderation/appeals/:id One appeal.
POST /admin/moderation/appeals/:id/claim pendingunder_review, stamping the claiming staffer.
POST /admin/moderation/appeals/:id/resolve { status: 'approved' | 'denied', staff_response? }. On an approved ban/mute, triggers the Discord reversal (§6).
GET /admin/moderation/user/:discordId/appeals A user's appeals — shown as a tab on the per-user moderation history page.

resolve returns a reversal object describing what happened:

{
  "reversal": {
    "attempted": true,
    "ok": true,
    "reversal_status": "done",   // "none" | "done" | "failed"
    "bot_status": 200,
    "error": null
  }
}

6. Auto-reversal (Phase 6d)

On resolve with status: 'approved' against a ban/mute appeal, the website calls the Discord bot's internal API:

POST /internal/mod-reverse

— gated by the same shared-secret scheme as the existing /internal/announce call. The bot lifts the ban / clears the timeout for the target and posts an "Appeal approved" embed to its mod log.

The call is best-effort: the appeal resolution itself always completes (the appeal is marked approved and the staff response is saved) regardless of whether the bot answers. If the bot is down or the call otherwise fails, reversal_status is recorded as failed and staff are expected to reverse the sanction by hand in Discord; the reversal object in the resolve response surfaces ok: false and an error so the UI can flag it. Denied appeals never attempt a reversal.


See website-README.md for the moderation dashboard's place in the wider site, and BACKEND_DESIGN.md for the base API conventions (auth, error shapes, response codes) these endpoints follow.