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
+6 -4
View File
@@ -207,14 +207,16 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
return out, err
}
// ResolveSession maps a token to its account id (gateway session-cache miss).
func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) {
// ResolveSession maps a token to its account id and guest flag (gateway
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
var out struct {
UserID string `json:"user_id"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
}
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
map[string]string{"token": token}, &out)
return out.UserID, err
return out.UserID, out.IsGuest, err
}
// Profile returns the authenticated account's profile.
@@ -0,0 +1,56 @@
package backendclient
import (
"context"
"encoding/base64"
"net/http"
)
// FeedbackReplyResp is the operator reply shown back to the player.
type FeedbackReplyResp struct {
Body string `json:"body"`
RepliedAtUnix int64 `json:"replied_at_unix"`
}
// FeedbackStateResp is the player's feedback screen state. Reply is nil when there
// is none to show.
type FeedbackStateResp struct {
CanSend bool `json:"can_send"`
BlockedReason string `json:"blocked_reason"`
Reply *FeedbackReplyResp `json:"reply,omitempty"`
}
// FeedbackUnreadResp reports whether the player has an undelivered operator reply.
type FeedbackUnreadResp struct {
ReplyUnread bool `json:"reply_unread"`
}
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
payload := map[string]string{
"body": body,
"attachment": "",
"attachment_name": attachmentName,
"channel": channel,
}
if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
}
return c.do(ctx, http.MethodPost, "/api/v1/user/feedback", userID, clientIP, payload, nil)
}
// FeedbackGet returns the player's feedback screen state, marking any pending reply
// delivered (clearing the badge).
func (c *Client) FeedbackGet(ctx context.Context, userID string) (FeedbackStateResp, error) {
var out FeedbackStateResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback", userID, "", nil, &out)
return out, err
}
// FeedbackUnread reports whether the player has an undelivered operator reply.
func (c *Client) FeedbackUnread(ctx context.Context, userID string) (FeedbackUnreadResp, error) {
var out FeedbackUnreadResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback/unread", userID, "", nil, &out)
return out, err
}
+19 -9
View File
@@ -207,11 +207,20 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, err := s.resolve(ctx, req.Header())
uid, isGuest, err := s.resolve(ctx, req.Header())
if err != nil {
result = "unauthenticated"
return nil, err
}
// A guest may not perform a non-guest operation: reject it here as a domain
// outcome, before any backend call.
if op.NonGuest && isGuest {
result = "domain"
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: "guest_forbidden",
}), nil
}
// A valid session proving an authenticated request is an "action" for the
// active_users gauge, counted before the rate-limit/domain outcome.
s.metrics.recordActive(uid)
@@ -254,7 +263,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, err := s.resolve(ctx, req.Header())
uid, _, err := s.resolve(ctx, req.Header())
if err != nil {
return err
}
@@ -332,14 +341,15 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
})
}
// resolve extracts and resolves the Authorization bearer token to an account id,
// returning a Connect Unauthenticated error when it is missing or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
uid, err := s.sessions.Resolve(ctx, token)
uid, isGuest, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
// stays silent; anything else — a resolve timeout, a refused connection, a
@@ -350,9 +360,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err))
}
return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, nil
return uid, isGuest, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
@@ -69,6 +69,45 @@ func TestExecuteGuestAuthOK(t *testing.T) {
}
}
// TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the
// guest_forbidden result code before any backend call, while a guest may still run
// an authenticated op that is not guest-gated.
func TestExecuteGuestGate(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`))
case "/api/v1/user/feedback/unread":
_, _ = w.Write([]byte(`{"reply_unread":false}`))
case "/api/v1/user/feedback":
t.Errorf("guest feedback.submit must not reach the backend")
default:
t.Errorf("unexpected backend path %s", r.URL.Path)
}
})
defer cleanup()
submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"})
submit.Header().Set("Authorization", "Bearer tok")
resp, err := client.Execute(context.Background(), submit)
if err != nil {
t.Fatalf("execute submit: %v", err)
}
if resp.Msg.GetResultCode() != "guest_forbidden" {
t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode())
}
unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread})
unread.Header().Set("Authorization", "Bearer tok")
resp, err = client.Execute(context.Background(), unread)
if err != nil {
t.Fatalf("execute unread: %v", err)
}
if resp.Msg.GetResultCode() != "ok" {
t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode())
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
+22 -20
View File
@@ -11,10 +11,10 @@ import (
"time"
)
// Resolver resolves a token to an account id at the backend (the cache miss
// path). backendclient.Client satisfies it.
// Resolver resolves a token to an account id and its guest flag at the backend
// (the cache miss path). backendclient.Client satisfies it.
type Resolver interface {
ResolveSession(ctx context.Context, token string) (string, error)
ResolveSession(ctx context.Context, token string) (string, bool, error)
}
// Cache resolves session tokens to account ids, caching hits for ttl.
@@ -30,6 +30,7 @@ type Cache struct {
type entry struct {
userID string
isGuest bool
expires time.Time
}
@@ -47,19 +48,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
}
}
// Resolve returns the account id for token, consulting the cache first and the
// backend on a miss (caching the result). An empty token is rejected by the
// backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, error) {
if uid, ok := c.lookup(token); ok {
return uid, nil
// Resolve returns the account id and guest flag for token, consulting the cache
// first and the backend on a miss (caching the result). An empty token is rejected
// by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
if uid, guest, ok := c.lookup(token); ok {
return uid, guest, nil
}
uid, err := c.backend.ResolveSession(ctx, token)
uid, guest, err := c.backend.ResolveSession(ctx, token)
if err != nil {
return "", err
return "", false, err
}
c.store(token, uid)
return uid, nil
c.store(token, uid, guest)
return uid, guest, nil
}
// Invalidate drops a token from the cache (e.g. after a revoke).
@@ -69,25 +70,26 @@ func (c *Cache) Invalidate(token string) {
delete(c.entries, token)
}
// lookup returns a live cached account id for token.
func (c *Cache) lookup(token string) (string, bool) {
// lookup returns a live cached account id and guest flag for token.
func (c *Cache) lookup(token string) (string, bool, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[token]
if !ok || !c.now().Before(e.expires) {
return "", false
return "", false, false
}
return e.userID, true
return e.userID, e.isGuest, true
}
// store caches token -> userID, sweeping expired entries and bounding the size.
func (c *Cache) store(token, userID string) {
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
// the size.
func (c *Cache) store(token, userID string, isGuest bool) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.evictLocked()
}
c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)}
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
}
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
+24 -9
View File
@@ -9,16 +9,17 @@ import (
type fakeResolver struct {
uid string
guest bool
err error
calls int
}
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, error) {
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
f.calls++
if f.err != nil {
return "", f.err
return "", false, f.err
}
return f.uid, nil
return f.uid, f.guest, nil
}
func TestResolveCachesBackendHit(t *testing.T) {
@@ -26,7 +27,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
c := NewCache(r, time.Minute, 10)
for i := 0; i < 3; i++ {
uid, err := c.Resolve(context.Background(), "tok")
uid, _, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "user-1" {
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
}
@@ -36,10 +37,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
}
}
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true}
c := NewCache(r, time.Minute, 10)
for i := 0; i < 2; i++ {
uid, guest, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest {
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
}
}
if r.calls != 1 {
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
}
}
func TestResolvePropagatesBackendError(t *testing.T) {
r := &fakeResolver{err: errors.New("nope")}
c := NewCache(r, time.Minute, 10)
if _, err := c.Resolve(context.Background(), "tok"); err == nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
t.Fatal("expected backend error to propagate")
}
}
@@ -50,11 +65,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
base := time.Now()
c.now = func() time.Time { return base }
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
if r.calls != 2 {
@@ -65,9 +80,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
func TestInvalidateForcesReResolve(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
_, _ = c.Resolve(context.Background(), "tok")
_, _, _ = c.Resolve(context.Background(), "tok")
c.Invalidate("tok")
_, _ = c.Resolve(context.Background(), "tok")
_, _, _ = c.Resolve(context.Background(), "tok")
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
}
+31
View File
@@ -324,6 +324,37 @@ func encodeChatList(r backendclient.ChatListResp) []byte {
return b.FinishedBytes()
}
// encodeFeedbackState builds a FeedbackState payload, nesting the reply when present.
func encodeFeedbackState(st backendclient.FeedbackStateResp) []byte {
b := flatbuffers.NewBuilder(128)
var reply flatbuffers.UOffsetT
if st.Reply != nil {
body := b.CreateString(st.Reply.Body)
fb.FeedbackReplyStart(b)
fb.FeedbackReplyAddBody(b, body)
fb.FeedbackReplyAddRepliedAtUnix(b, st.Reply.RepliedAtUnix)
reply = fb.FeedbackReplyEnd(b)
}
reason := b.CreateString(st.BlockedReason)
fb.FeedbackStateStart(b)
fb.FeedbackStateAddCanSend(b, st.CanSend)
fb.FeedbackStateAddBlockedReason(b, reason)
if st.Reply != nil {
fb.FeedbackStateAddReply(b, reply)
}
b.Finish(fb.FeedbackStateEnd(b))
return b.FinishedBytes()
}
// encodeFeedbackUnread builds a FeedbackUnread payload.
func encodeFeedbackUnread(u backendclient.FeedbackUnreadResp) []byte {
b := flatbuffers.NewBuilder(16)
fb.FeedbackUnreadStart(b)
fb.FeedbackUnreadAddReplyUnread(b, u.ReplyUnread)
b.Finish(fb.FeedbackUnreadEnd(b))
return b.FinishedBytes()
}
// buildGameView builds a GameView table and returns its offset.
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
return wire.BuildGameView(b, toWireGame(g))
+40
View File
@@ -43,6 +43,9 @@ const (
MsgDraftGet = "draft.get"
MsgDraftSave = "draft.save"
MsgGameHide = "game.hide"
MsgFeedbackSubmit = "feedback.submit"
MsgFeedbackGet = "feedback.get"
MsgFeedbackUnread = "feedback.unread"
)
// Request is one decoded Execute call.
@@ -62,6 +65,10 @@ type Op struct {
Auth bool
// Email marks the costly email-code path that gets a stricter rate sub-limit.
Email bool
// NonGuest marks an operation a guest account may not perform; the gateway
// rejects it with the guest_forbidden result code after resolving the session,
// before any backend call.
NonGuest bool
}
// Registry maps message types to their operations.
@@ -117,6 +124,9 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
r.ops[MsgFeedbackSubmit] = Op{Handler: feedbackSubmitHandler(backend), Auth: true, NonGuest: true}
r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true}
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend)
registerLinkOps(r, backend, tg, defaultLanguages)
return r
@@ -477,3 +487,33 @@ func hideGameHandler(backend *backendclient.Client) Handler {
return encodeAck(true), nil
}
}
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func feedbackGetHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
st, err := backend.FeedbackGet(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackState(st), nil
}
}
func feedbackUnreadHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
u, err := backend.FeedbackUnread(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackUnread(u), nil
}
}