docs(website): moderation appeals + Discord reversal (Phase 6c/6d) #5

Merged
whitlocktech merged 2 commits from docs/moderation-appeals into main 2026-07-19 03:41:13 +00:00
Showing only changes of commit 4f0c282f3f - Show all commits

View File

@@ -0,0 +1,128 @@
# 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.