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
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountRoles struct {
AccountID uuid.UUID `sql:"primary_key"`
Role string `sql:"primary_key"`
GrantedAt time.Time
}
@@ -0,0 +1,29 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type FeedbackMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Body string
Attachment *[]byte
AttachmentName *string
SenderIP *string
Channel string
ReadAt *time.Time
ArchivedAt *time.Time
ReplyBody *string
RepliedAt *time.Time
ReplyReadAt *time.Time
CreatedAt time.Time
}
@@ -0,0 +1,84 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountRoles = newAccountRolesTable("backend", "account_roles", "")
type accountRolesTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Role postgres.ColumnString
GrantedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountRolesTable struct {
accountRolesTable
EXCLUDED accountRolesTable
}
// AS creates new AccountRolesTable with assigned alias
func (a AccountRolesTable) AS(alias string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountRolesTable with assigned schema name
func (a AccountRolesTable) FromSchema(schemaName string) *AccountRolesTable {
return newAccountRolesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountRolesTable with assigned table prefix
func (a AccountRolesTable) WithPrefix(prefix string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountRolesTable with assigned table suffix
func (a AccountRolesTable) WithSuffix(suffix string) *AccountRolesTable {
return newAccountRolesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountRolesTable(schemaName, tableName, alias string) *AccountRolesTable {
return &AccountRolesTable{
accountRolesTable: newAccountRolesTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountRolesTableImpl("", "excluded", ""),
}
}
func newAccountRolesTableImpl(schemaName, tableName, alias string) accountRolesTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
RoleColumn = postgres.StringColumn("role")
GrantedAtColumn = postgres.TimestampzColumn("granted_at")
allColumns = postgres.ColumnList{AccountIDColumn, RoleColumn, GrantedAtColumn}
mutableColumns = postgres.ColumnList{GrantedAtColumn}
defaultColumns = postgres.ColumnList{GrantedAtColumn}
)
return accountRolesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Role: RoleColumn,
GrantedAt: GrantedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,114 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var FeedbackMessages = newFeedbackMessagesTable("backend", "feedback_messages", "")
type feedbackMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
AccountID postgres.ColumnString
Body postgres.ColumnString
Attachment postgres.ColumnBytea
AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString
Channel postgres.ColumnString
ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type FeedbackMessagesTable struct {
feedbackMessagesTable
EXCLUDED feedbackMessagesTable
}
// AS creates new FeedbackMessagesTable with assigned alias
func (a FeedbackMessagesTable) AS(alias string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new FeedbackMessagesTable with assigned schema name
func (a FeedbackMessagesTable) FromSchema(schemaName string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new FeedbackMessagesTable with assigned table prefix
func (a FeedbackMessagesTable) WithPrefix(prefix string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new FeedbackMessagesTable with assigned table suffix
func (a FeedbackMessagesTable) WithSuffix(suffix string) *FeedbackMessagesTable {
return newFeedbackMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newFeedbackMessagesTable(schemaName, tableName, alias string) *FeedbackMessagesTable {
return &FeedbackMessagesTable{
feedbackMessagesTable: newFeedbackMessagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newFeedbackMessagesTableImpl("", "excluded", ""),
}
}
func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
AccountIDColumn = postgres.StringColumn("account_id")
BodyColumn = postgres.StringColumn("body")
AttachmentColumn = postgres.ByteaColumn("attachment")
AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel")
ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return feedbackMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
AccountID: AccountIDColumn,
Body: BodyColumn,
Attachment: AttachmentColumn,
AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn,
Channel: ChannelColumn,
ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -10,6 +10,7 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountRoles = AccountRoles.FromSchema(schema)
AccountStats = AccountStats.FromSchema(schema)
AccountSuspensions = AccountSuspensions.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
@@ -18,6 +19,7 @@ func UseSchema(schema string) {
Complaints = Complaints.FromSchema(schema)
DictionaryState = DictionaryState.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
FeedbackMessages = FeedbackMessages.FromSchema(schema)
FriendCodes = FriendCodes.FromSchema(schema)
Friendships = Friendships.FromSchema(schema)
GameDrafts = GameDrafts.FromSchema(schema)
@@ -0,0 +1,54 @@
-- +goose Up
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
SET search_path = backend, pg_catalog;
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go).
CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
body text NOT NULL,
attachment bytea,
attachment_name text,
sender_ip text,
channel text NOT NULL,
read_at timestamptz,
archived_at timestamptz,
reply_body text,
replied_at timestamptz,
reply_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
-- console's user-scoped search all look an account up newest-first.
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
-- The console queue lists messages newest-first across all accounts.
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
CREATE TABLE account_roles (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
role text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, role)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_roles;
DROP TABLE IF EXISTS feedback_messages;