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
+4 -2
View File
@@ -27,9 +27,11 @@ type okResponse struct {
OK bool `json:"ok"`
}
// resolveResponse maps a session token to its account.
// resolveResponse maps a session token to its account. IsGuest lets the gateway
// gate guest-forbidden operations without an extra round-trip.
type resolveResponse struct {
UserID string `json:"user_id"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
}
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
+20
View File
@@ -12,6 +12,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
@@ -77,6 +78,11 @@ func (s *Server) registerRoutes() {
u.PUT("/games/:id/draft", s.handleSaveDraft)
u.POST("/games/:id/hide", s.handleHideGame)
}
if s.feedback != nil {
u.POST("/feedback", s.handleFeedbackSubmit)
u.GET("/feedback", s.handleFeedbackState)
u.GET("/feedback/unread", s.handleFeedbackUnread)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
}
@@ -232,6 +238,20 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid"
case errors.Is(err, feedback.ErrGuestForbidden):
return http.StatusForbidden, "feedback_guest_forbidden"
case errors.Is(err, feedback.ErrBanned):
return http.StatusForbidden, "feedback_banned"
case errors.Is(err, feedback.ErrPendingReview):
return http.StatusConflict, "feedback_pending"
case errors.Is(err, feedback.ErrEmptyMessage):
return http.StatusUnprocessableEntity, "feedback_empty"
case errors.Is(err, feedback.ErrMessageTooLong):
return http.StatusUnprocessableEntity, "feedback_too_long"
case errors.Is(err, feedback.ErrAttachmentTooLarge):
return http.StatusUnprocessableEntity, "feedback_attachment_too_large"
case errors.Is(err, feedback.ErrAttachmentType):
return http.StatusUnprocessableEntity, "feedback_attachment_type"
default:
return http.StatusInternalServerError, "internal"
}
@@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
@@ -68,6 +70,16 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
if s.feedback != nil {
gm.GET("/feedback", s.consoleFeedback)
gm.GET("/feedback/:id", s.consoleFeedbackDetail)
gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/dictionary", s.consoleDictionary)
@@ -87,6 +99,9 @@ func (s *Server) consoleDashboard(c *gin.Context) {
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
if s.feedback != nil {
view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
}
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
view.PendingChanges = len(changes)
}
@@ -316,6 +331,10 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -0,0 +1,293 @@
package server
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/feedback"
)
// consoleFeedback renders the paginated feedback queue, filtered by state
// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by
// sender glob masks (?name / ?ext).
func (s *Server) consoleFeedback(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
status := normalizeFeedbackStatus(c.Query("status"))
user, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := feedback.AdminFilter{
Status: status,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
AccountID: user,
}
total, _ := s.feedback.AdminCount(ctx, filter)
rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
q.Set("status", status)
if filter.AccountID != uuid.Nil {
q.Set("user", filter.AccountID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
view := adminconsole.FeedbackView{
Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask, ExtMask: filter.ExtMask,
FilterQuery: template.URL(q.Encode()),
}
if filter.AccountID != uuid.Nil {
view.UserID = filter.AccountID.String()
}
for _, r := range rows {
view.Items = append(view.Items, adminconsole.FeedbackRow{
ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName,
Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment,
Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt),
})
}
s.renderConsole(c, "feedback", "feedback", "Feedback", view)
}
// consoleFeedbackDetail renders one feedback message with its actions. Opening it
// does NOT mark it read (only the explicit actions do).
func (s *Server) consoleFeedbackDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body,
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
}
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned
}
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
}
// consoleFeedbackAttachment serves a message's attachment. It is always sent with
// X-Content-Type-Options: nosniff; images are served with their type for an inline
// <img> preview (which never executes), everything else as an octet-stream download
// (so a non-image — including a renamed one — is downloaded, never rendered).
func (s *Server) consoleFeedbackAttachment(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
name, data, ok, err := s.feedback.Attachment(c.Request.Context(), id)
if err != nil {
s.consoleError(c, err)
return
}
if !ok {
s.renderConsoleMessage(c, "No attachment", "this message has no attachment", "/_gm/feedback/"+id.String())
return
}
ctype := "application/octet-stream"
disposition := `attachment; filename="` + sanitizeFilename(name) + `"`
if feedback.IsImage(name) {
ctype = feedback.ContentType(name)
disposition = "inline"
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", disposition)
c.Data(http.StatusOK, ctype, data)
}
// consoleFeedbackRead marks a message dealt-with without any other action.
func (s *Server) consoleFeedbackRead(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
if err := s.feedback.MarkRead(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Marked read", "the message is marked read", back)
}
// consoleFeedbackReply sends the operator's reply to the player (marking the
// message read and notifying the player's app).
func (s *Server) consoleFeedbackReply(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
back := "/_gm/feedback/" + id.String()
body := trimForm(c, "reply")
if body == "" {
s.renderConsoleMessage(c, "Empty reply", "enter a reply before sending", back)
return
}
if err := s.feedback.Reply(c.Request.Context(), id, body); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Replied", "the reply was sent to the player", back)
}
// consoleFeedbackArchive files a handled message away (marking it read).
func (s *Server) consoleFeedbackArchive(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
if err := s.feedback.Archive(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Archived", "the message was archived", "/_gm/feedback")
}
// consoleFeedbackDelete physically deletes one message, optionally banning the
// sender from feedback (the "block" checkbox).
func (s *Server) consoleFeedbackDelete(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.Delete(ctx, id); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the message was deleted"+extra, "/_gm/feedback")
}
// consoleFeedbackDeleteAll physically deletes every message from the sender of the
// given message, optionally banning the sender from feedback.
func (s *Server) consoleFeedbackDeleteAll(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/feedback")
if !ok {
return
}
m, err := s.feedback.AdminGet(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
if err := s.feedback.DeleteAllByAccount(ctx, m.AccountID); err != nil {
s.consoleError(c, err)
return
}
extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "")
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "all messages from the player were deleted"+extra, "/_gm/feedback")
}
// maybeBan grants the feedback-ban role to accountID when ban is set, returning a
// suffix for the result message.
func (s *Server) maybeBan(ctx context.Context, accountID uuid.UUID, ban bool) (string, error) {
if !ban {
return "", nil
}
if err := s.accounts.GrantRole(ctx, accountID, account.RoleFeedbackBanned); err != nil {
return "", err
}
return " and the player was banned from feedback", nil
}
// consoleGrantRole grants a known role to an account (the /users role panel).
func (s *Server) consoleGrantRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if !account.IsKnownRole(role) {
s.renderConsoleMessage(c, "Invalid role", "the selected role is not recognised", back)
return
}
if err := s.accounts.GrantRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
}
// consoleRevokeRole removes a role from an account (the /users role panel).
func (s *Server) consoleRevokeRole(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
role := trimForm(c, "role")
if role == "" {
s.renderConsoleMessage(c, "Invalid role", "no role given", back)
return
}
if err := s.accounts.RevokeRole(c.Request.Context(), id, role); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
}
// normalizeFeedbackStatus keeps only a recognised feedback status filter, else
// "unread" (the default queue).
func normalizeFeedbackStatus(s string) string {
switch s {
case "read", "archived":
return s
default:
return "unread"
}
}
// sanitizeFilename strips characters unsafe in a Content-Disposition filename
// (control chars, quotes and path separators) to prevent header injection.
func sanitizeFilename(name string) string {
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == '"' || r == '\\' || r == '/' {
return -1
}
return r
}, name)
if name == "" {
return "attachment"
}
return name
}
+8 -1
View File
@@ -176,7 +176,14 @@ func (s *Server) handleResolveSession(c *gin.Context) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
// is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest
}
c.JSON(http.StatusOK, resp)
}
// handleRevokeSession revokes the session for a token (idempotent).
@@ -0,0 +1,105 @@
package server
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
)
// feedbackSubmitRequest is the player's feedback submission. Attachment is the
// base64-encoded file bytes (empty for none); AttachmentName carries the original
// file name (its extension is the allow-list key); Channel is the submitting
// platform (telegram/ios/android/web).
type feedbackSubmitRequest struct {
Body string `json:"body"`
Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"`
}
// feedbackReplyDTO is the operator's reply shown back to the player.
type feedbackReplyDTO struct {
Body string `json:"body"`
RepliedAtUnix int64 `json:"replied_at_unix"`
}
// feedbackStateResponse is the player's feedback screen state. BlockedReason is
// "" (can send), "pending" or "banned"; Reply is omitted when there is none.
type feedbackStateResponse struct {
CanSend bool `json:"can_send"`
BlockedReason string `json:"blocked_reason"`
Reply *feedbackReplyDTO `json:"reply,omitempty"`
}
// feedbackUnreadResponse reports whether the player has an undelivered reply, for
// the lobby/Info badge.
type feedbackUnreadResponse struct {
ReplyUnread bool `json:"reply_unread"`
}
// handleFeedbackSubmit stores a feedback message from the authenticated player.
// The sender IP comes from the gateway-forwarded X-Forwarded-For header. Guests
// and feedback-banned accounts are refused (also gated at the gateway).
func (s *Server) handleFeedbackSubmit(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req feedbackSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
var attachment []byte
if req.Attachment != "" {
data, err := base64.StdEncoding.DecodeString(req.Attachment)
if err != nil {
abortBadRequest(c, "invalid attachment encoding")
return
}
attachment = data
}
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFeedbackState returns the player's feedback screen state and marks any
// pending operator replies delivered (clearing the badge).
func (s *Server) handleFeedbackState(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
st, err := s.feedback.State(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
resp := feedbackStateResponse{CanSend: st.CanSend, BlockedReason: st.BlockedReason}
if st.Reply != nil {
resp.Reply = &feedbackReplyDTO{Body: st.Reply.Body, RepliedAtUnix: st.Reply.RepliedAt.Unix()}
}
c.JSON(http.StatusOK, resp)
}
// handleFeedbackUnread reports whether the player has an undelivered operator
// reply, for the lobby/Info badge. It has no side effect.
func (s *Server) handleFeedbackUnread(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
unread, err := s.feedback.ReplyUnread(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, feedbackUnreadResponse{ReplyUnread: unread})
}
+6
View File
@@ -21,6 +21,7 @@ import (
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
@@ -54,6 +55,9 @@ type Deps struct {
Sessions *session.Service
Accounts *account.Store
Games *game.Service
// Feedback is the user-feedback domain service (the /api/v1/user/feedback
// endpoints and the console section). A nil Feedback disables them.
Feedback *feedback.Service
// Social, Matchmaker, Invitations and Emails are the domain services
// the REST handlers route to.
Social *social.Service
@@ -91,6 +95,7 @@ type Server struct {
sessions *session.Service
accounts *account.Store
games *game.Service
feedback *feedback.Service
social *social.Service
matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService
@@ -132,6 +137,7 @@ func New(addr string, deps Deps) *Server {
sessions: deps.Sessions,
accounts: deps.Accounts,
games: deps.Games,
feedback: deps.Feedback,
social: deps.Social,
matchmaker: deps.Matchmaker,
invitations: deps.Invitations,