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,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)