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:
@@ -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;
|
||||
Reference in New Issue
Block a user