# 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](website-README.md) (overview) and > [BACKEND_DESIGN.md](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` | `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: ```jsonc { "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](website-README.md) for the moderation dashboard's place in the wider site, and [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the base API conventions (auth, error shapes, response codes) these endpoints follow.