feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
This commit is contained in:
Ilia Denisov
2026-06-15 12:23:10 +02:00
parent fc848157d6
commit 419ea11b14
75 changed files with 3638 additions and 55 deletions
+39
View File
@@ -703,6 +703,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
@@ -817,3 +818,41 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
`BACKEND_DICT_DIR`.
- After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
## 15. User feedback & account roles
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
delete / delete-all actions — each marks the message read; merely opening the detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list
plus the ≤1,000,000-byte size cap as the trust boundary; file *content* is not inspected. The
1,000,000-byte cap keeps the whole `feedback.submit` request under the gateway's 1 MiB edge body
cap (§12) without weakening it.
**Reply delivery.** The operator's reply lives on the message row and is shown back on the
feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied
message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and
is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with
the friend-request count — signals an undelivered reply; it rides the existing `NotificationEvent`
with a new `admin_reply` sub-kind (no new push schema) plus an authoritative poll on lobby load.
**Account roles.** `account_roles` (account_id, role) is the project's first per-account role
table — the reusable replacement for per-feature boolean flags. The first role, `feedback_banned`,
blocks **only** feedback submission (unlike a suspension, the whole-account block of §12). It is
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration.