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'smod_actionslog. 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
appealsis a server-owned table — only the website reads/writes it. It references the bot-ownedmod_actionslog 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 themod_actionsrow'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_reviewappeal is allowed for a givenmod_action_idat 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 optionalstaff_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 |
pending → under_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.