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
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:
@@ -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.
|
||||
|
||||
@@ -172,6 +172,18 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge".
|
||||
|
||||
### Feedback
|
||||
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
|
||||
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
|
||||
PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form
|
||||
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
||||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||||
below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it
|
||||
and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
||||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||
|
||||
### History & statistics
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG; the export is offered **only once a game is finished** (exporting a live game
|
||||
@@ -225,3 +237,12 @@ From the user card the operator can also **top up a player's hint wallet**: an a
|
||||
(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** —
|
||||
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
|
||||
over-grant cannot be reversed there.
|
||||
|
||||
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
|
||||
**unread / read / archived** with per-user search, each shown with its sender, source, channel, IP
|
||||
and any attachment. The operator can mark a message read, **reply** to the player (delivered
|
||||
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
||||
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
||||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||||
|
||||
@@ -177,6 +177,18 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
|
||||
### Обратная связь
|
||||
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
|
||||
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
|
||||
текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления
|
||||
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
|
||||
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
|
||||
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
|
||||
«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и
|
||||
исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
||||
@@ -231,3 +243,13 @@ high-rate флага. С карточки пользователя операт
|
||||
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
|
||||
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
|
||||
партии), поэтому ошибочно начисленное через консоль там не отнять.
|
||||
|
||||
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
|
||||
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
|
||||
каналом, IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
|
||||
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
|
||||
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
|
||||
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
|
||||
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
|
||||
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
|
||||
рендерятся).
|
||||
|
||||
@@ -136,6 +136,15 @@ tests or touching CI.
|
||||
postgres_exporter** Grafana dashboard on the contour. Two passes are recorded — the
|
||||
early [`REPORT-R2.md`](../loadtest/REPORT-R2.md) and the final, tuned
|
||||
[`REPORT-R7.md`](../loadtest/REPORT-R7.md). See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
- **User feedback** — `internal/feedback` unit tests cover the attachment allow-list /
|
||||
content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and
|
||||
the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e
|
||||
(submit → confirm + resend blocked; an operator reply raises the badge and shows on the screen).
|
||||
The gateway `connectsrv` test asserts the **guest gate** (a guest's `feedback.submit` →
|
||||
`guest_forbidden` with no backend call; a non-gated authenticated op still passes). Postgres-backed
|
||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
+11
-1
@@ -33,7 +33,9 @@ Login uses `Screen`.
|
||||
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
||||
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The
|
||||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
|
||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
|
||||
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
|
||||
it slides in/out like a sub-screen rather than switching a tab in place.
|
||||
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
||||
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
|
||||
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
|
||||
@@ -252,6 +254,14 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
|
||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||
sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️
|
||||
nudge takes the field's place.
|
||||
- **Feedback** (`screens/Feedback.svelte`, reached from a button at the bottom of the ℹ️ Info
|
||||
tab, registered accounts only): a multi-line message field over a row that pairs a **ghost**
|
||||
attach button (a hidden `<input type=file>`, with a chosen-file name + a remove control) with
|
||||
the filled accent **Send** button. A rejected file shows one generic notice (the allowed types
|
||||
are not advertised). The send controls disable while a previous message is under review or the
|
||||
account is barred; the operator's reply renders below as a titled block. An undelivered reply
|
||||
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
|
||||
Info tab.
|
||||
|
||||
## Caveat
|
||||
|
||||
|
||||
Reference in New Issue
Block a user