# 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.
