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
+45
View File
@@ -52,6 +52,7 @@ independent (see ARCHITECTURE §9.1).
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Test-contour verification & defect fixes | **done** |
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.
@@ -419,6 +420,37 @@ parameterised for this; the test contour leaves it `:80` behind the host caddy).
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
provided cert) at the contour caddy; prod VPN; rollback.
### Stage 19 — User feedback *(done)*
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message
(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered
**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's
**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the
`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension),
granted from the feedback section's delete-with-block and granted/revoked from `/users`.
- **Data:** migration `00004_feedback``feedback_messages` (body, attachment `bytea`, sender_ip, channel,
read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain
`internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface.
- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего
последнего обращения"); enforced server-side. This *is* the rate limit — no separate one.
- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше
последнее сообщение") for the player's latest replied message; it becomes read the moment the screen
fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend
requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind —
no wire-schema change for the push.
- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is
**guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`),
so a guest's submission never reaches the backend; the backend rejects guests too.
- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments
served with `X-Content-Type-Options: nosniff`, images inline-previewed via `<img>` (which never
executes), everything else download-only; the UI gates the attachment by extension (allowed types not
listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user
content as auto-escaped text (`html/template`).
Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types
(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests
cannot submit; three-way admin filter.
## Refinements logged during implementation
- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
@@ -1476,6 +1508,19 @@ provided cert) at the contour caddy; prod VPN; rollback.
`proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.
### Stage 19 — user feedback
- Dropped the planned `feedback_messages.attachment_type` column: the safe content-type is derived from the
file-name extension at both validate and serve time (one source of truth), so a stored column was
redundant.
- The admin filter is **three-way** (Непрочитанные / Прочитанные / Архив) rather than the two first
sketched, mirroring complaints' `open·resolved·all`, so a read-but-unarchived message is never
unreachable (owner-approved at planning).
- The guest gate was extended to the **gateway** at the owner's request: a new `Op.NonGuest` flag plus
`is_guest` threaded through the session resolve + gateway cache reject a guest's `feedback.submit` with
the `guest_forbidden` result code before any backend call (the backend keeps its own guest check as the
backstop). `handleResolveSession` now loads the account best-effort to report `is_guest` (a read error
falls back to false so the auth hot path stays resilient).
## Deferred TODOs (cross-stage)
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is