docs(website): add moderation appeals + Discord reversal (Phase 6c/6d)
This commit is contained in:
128
website/MODERATION_APPEALS.md
Normal file
128
website/MODERATION_APPEALS.md
Normal 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.
|
||||||
Reference in New Issue
Block a user