diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 6d217b9..4faf437 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -7,8 +7,9 @@
  • From {{.SenderName}} ({{.Source}})
  • Channel {{.Channel}}
  • Interface language {{.InterfaceLanguage}}
  • +
  • App version {{if .Version}}{{.Version}}{{else}}unknown{{end}}
  • IP {{if .IP}}{{.IP}}{{else}}none{{end}}
  • -
  • Filed {{.CreatedAt}}
  • +
  • Filed {{.CreatedAt}} UTC{{if .CreatedAtLocal}} · {{.CreatedAtLocal}} ({{.TimeZone}}){{end}}
  • State {{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}
  • {{if .Banned}}
  • Feedback sender is banned from feedback
  • {{end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 48a85bb..23d6d34 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -554,5 +554,11 @@ type FeedbackDetailView struct { ReplyBody string RepliedAt string CreatedAt string - Banned bool + // Version is the client app build the report was sent from (empty for rows that predate it). + Version string + // CreatedAtLocal is CreatedAt rendered in the sender's time zone, with TimeZone its label. + // Empty when the sender's zone is UTC or unknown — then only the UTC CreatedAt is shown. + CreatedAtLocal string + TimeZone string + Banned bool } diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go index 824cff9..0942766 100644 --- a/backend/internal/feedback/service.go +++ b/backend/internal/feedback/service.go @@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) { // validates the body (non-empty, within the rune limit) and the optional // attachment (size and extension allow-list). senderIP is the gateway-forwarded // client IP (validated); channel is the submitting platform. -func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error { +func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, senderIP string) error { acc, err := svc.accounts.GetByID(ctx, accountID) if err != nil { return err @@ -112,9 +112,9 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string attachmentName = "" // a name without bytes carries no attachment } ch := normalizeChannel(channel) - // Snapshot the sender's interface language at submit time (acc is already loaded - // for the guest check) so the operator later sees the state as it was. - _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP)) + // Snapshot the sender's interface language and the client app version at submit time (acc + // is already loaded for the guest check) so the operator later sees the state as it was. + _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, parseIP(senderIP)) return err } diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 5281978..176c0e9 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store { // Insert stores one feedback message from accountID and returns its id. attachment // is the raw file bytes (nil for none); attachmentName, ip and a non-default -// channel are stored as given. lang (the sender's interface language) is a snapshot -// taken now, so the operator later sees the state at submit time. created_at defaults -// to now() in the database. -func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) { +// channel are stored as given. lang (the sender's interface language) and version (the client +// app build) are snapshots taken now, so the operator later sees the state at submit time. +// created_at defaults to now() in the database. +func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version string, ip *string) (uuid.UUID, error) { id, err := uuid.NewV7() if err != nil { return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) @@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at } if _, err := s.db.ExecContext(ctx, `INSERT INTO backend.feedback_messages - (message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil { + (message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, sender_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), ip); err != nil { return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) } return id, nil @@ -228,7 +228,12 @@ type AdminMessage struct { Body string Channel string // Lang is the sender's interface language, snapshotted at submit time. - Lang string + Lang string + // Version is the client app build the report was sent from, snapshotted at submit time. + Version string + // TimeZone is the sender account's IANA zone, for rendering CreatedAt in the sender's + // local time alongside UTC. + TimeZone string SenderIP string HasAttachment bool AttachmentName string @@ -343,7 +348,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error var m AdminMessage var repliedAt sql.NullTime q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, - COALESCE(m.lang, ''), + COALESCE(m.lang, ''), COALESCE(m.app_version, ''), a.time_zone, COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), COALESCE(m.reply_body, ''), m.replied_at, m.created_at @@ -352,7 +357,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error WHERE m.message_id = $1` err := s.db.QueryRowContext(ctx, q, id).Scan( &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, - &m.Lang, + &m.Lang, &m.Version, &m.TimeZone, &m.SenderIP, &m.HasAttachment, &m.AttachmentName, &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) if errors.Is(err, sql.ErrNoRows) { diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 9d323a1..e35088e 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U func TestFeedbackGuestRejected(t *testing.T) { svc := newFeedbackService() guest := provisionGuest(t) - if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { + if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err) } } @@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { svc := newFeedbackService() acc := provisionAccount(t) - if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil { + if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "9.9.9.9"); err != nil { t.Fatalf("submit: %v", err) } // Anti-spam gate: a second message is refused while the first is unreviewed. - if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) { + if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", ""); !errors.Is(err, feedback.ErrPendingReview) { t.Fatalf("second submit err = %v, want ErrPendingReview", err) } if st, err := svc.State(ctx, acc); err != nil { @@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { if m.Body != "please fix the board" { // trimmed t.Fatalf("body = %q, want trimmed", m.Body) } - if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" { + if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" { t.Fatalf("admin message = %+v", m) } if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { @@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { acc := provisionAccount(t) // msg1, replied → the player can send again and currently sees the reply. - if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", ""); err != nil { t.Fatalf("submit msg1: %v", err) } if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { @@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { // Sending a new message immediately drops the previous reply (it now belongs to an // older message), even though it is well within the one-week window. - if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", ""); err != nil { t.Fatalf("submit msg2: %v", err) } st, err := svc.State(ctx, acc) @@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) { t.Fatalf("set language: %v", err) } // A message snapshots the sender's interface language at submit time. - if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil { + if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", ""); err != nil { t.Fatalf("submit: %v", err) } id := latestFeedbackID(t, svc, acc) @@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) { if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { t.Fatalf("grant role: %v", err) } - if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) { + if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", ""); !errors.Is(err, feedback.ErrBanned) { t.Fatalf("banned submit err = %v, want ErrBanned", err) } if st, err := svc.State(ctx, acc); err != nil { @@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) { if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil { t.Fatalf("revoke role: %v", err) } - if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", ""); err != nil { t.Fatalf("submit after unban: %v", err) } } @@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { acc := provisionAccount(t) // fresh account so the pending gate never fires first - if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) { + if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", ""); !errors.Is(err, tt.want) { t.Fatalf("submit err = %v, want %v", err, tt.want) } }) @@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) { svc := newFeedbackService() acc := provisionAccount(t) - if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", ""); err != nil { t.Fatalf("submit: %v", err) } id := latestFeedbackID(t, svc, acc) @@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) { svc := newFeedbackService() acc := provisionAccount(t) - if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", ""); err != nil { t.Fatalf("submit: %v", err) } if err := svc.DeleteAllByAccount(ctx, acc); err != nil { @@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) { if has, err := svc.ReplyUnread(ctx, acc); err != nil || has { t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) } - if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil { + if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", ""); err != nil { t.Fatalf("submit after delete-all: %v", err) } } diff --git a/backend/internal/postgres/migrations/00003_feedback_app_version.sql b/backend/internal/postgres/migrations/00003_feedback_app_version.sql new file mode 100644 index 0000000..9ade6c8 --- /dev/null +++ b/backend/internal/postgres/migrations/00003_feedback_app_version.sql @@ -0,0 +1,10 @@ +-- Capture the client app version (the build a report was sent from) with each feedback +-- message, so the operator console can show which version a player was on. Nullable, so the +-- rows that predate this keep working — additive and backward-compatible, so a backend image +-- rollback stays DB-safe (older code simply ignores the column). + +-- +goose Up +ALTER TABLE backend.feedback_messages ADD COLUMN app_version text; + +-- +goose Down +ALTER TABLE backend.feedback_messages DROP COLUMN app_version; diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 4e7c1f8..bd649f1 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -1198,6 +1198,19 @@ func fmtTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") } +// fmtTimeIn formats a timestamp in the named IANA zone (falling back to UTC when the zone is +// empty or unknown), or "" when zero — for showing a time in a user's local zone beside UTC. +func fmtTimeIn(t time.Time, tz string) string { + if t.IsZero() { + return "" + } + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + } + return t.In(loc).Format("2006-01-02 15:04") +} + // fmtTimePtr formats an optional timestamp for display, or "" when nil. func fmtTimePtr(t *time.Time) string { if t == nil { diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 664bf10..e5ff6f5 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -77,6 +77,11 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { s.consoleError(c, err) return } + // Filed time: always UTC; plus the sender's local zone when it is set and not UTC. + localCreated := "" + if m.TimeZone != "" && m.TimeZone != "UTC" { + localCreated = fmtTimeIn(m.CreatedAt, m.TimeZone) + } view := adminconsole.FeedbackDetailView{ ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, @@ -84,6 +89,7 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) { 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), + Version: m.Version, CreatedAtLocal: localCreated, TimeZone: m.TimeZone, } if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { view.Banned = banned diff --git a/backend/internal/server/handlers_feedback.go b/backend/internal/server/handlers_feedback.go index 235c884..46c1ec7 100644 --- a/backend/internal/server/handlers_feedback.go +++ b/backend/internal/server/handlers_feedback.go @@ -16,6 +16,9 @@ type feedbackSubmitRequest struct { Attachment string `json:"attachment"` AttachmentName string `json:"attachment_name"` Channel string `json:"channel"` + // Version is the client's app version (pkg/version / the SPA build), snapshotted so the + // operator sees which build a report came from. + Version string `json:"version"` } // feedbackReplyDTO is the operator's reply shown back to the player. @@ -61,7 +64,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) { } attachment = data } - if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil { + if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, req.Version, clientIP(c)); err != nil { s.abortErr(c, err) return } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d2d3cc..5c7a85e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1149,9 +1149,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts only). A message (≤1024 runes) plus an optional single attachment is stored in -`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting -**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain -is `internal/feedback` (store + service), modelled on the admin chat-moderation surface. +`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting +**channel** (telegram/ios/android/web, client-reported and validated), the **client app version** +(`__APP_VERSION__`, the build a report was sent from) and a snapshot of the sender's interface +language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin +chat-moderation surface. **Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the gate is server-side. Because the operator must act before the next message, this is itself the @@ -1159,8 +1161,10 @@ rate limit — there is no separate per-user feedback limiter. **Operator review** happens in the server-rendered console (`/_gm/feedback`): an unread / read / archived queue with per-user search (the `/users` glob masks), a detail card -(user content rendered as auto-escaped `html/template` text), and the read / reply / archive / -delete / delete-all actions — each marks the message read; merely opening the detail does not. +(user content rendered as auto-escaped `html/template` text; it shows the channel, interface +language and app version, and the filed time in UTC plus the sender's time zone), and the read / +reply / archive / delete / delete-all actions — each marks the message read; merely opening the +detail does not. The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options: nosniff`: images inline (loaded only via ``, which never executes — a renamed non-image is inert), everything else as an `application/octet-stream` download. The UI gates the attachment by diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 30a4875..e2af7e3 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -346,7 +346,8 @@ over-grant cannot be reversed there. The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered **unread / read / archived** with per-user search, each shown with its sender, source, channel (with the bot language — en/ru — for a Telegram message), the sender's interface -language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered +language, the **app version** it was sent from, IP, the filed time (in UTC and the sender's time +zone) and any attachment. The operator can mark a message read, **reply** to the player (delivered in-app), archive it, delete it, or delete every message from that player — and, alongside a delete, **bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 43c47a3..a638cc6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -356,7 +356,8 @@ high-rate флага. С карточки пользователя операт Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя, -IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +**версией приложения**, с которой отправлено, IP, временем подачи (в UTC и в зоне отправителя) +и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке diff --git a/gateway/internal/backendclient/api_feedback.go b/gateway/internal/backendclient/api_feedback.go index 2894c3d..d37b113 100644 --- a/gateway/internal/backendclient/api_feedback.go +++ b/gateway/internal/backendclient/api_feedback.go @@ -27,12 +27,13 @@ type FeedbackUnreadResp struct { // 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 { +func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, version, clientIP string) error { payload := map[string]string{ "body": body, "attachment": "", "attachment_name": attachmentName, "channel": channel, + "version": version, } if len(attachment) > 0 { payload["attachment"] = base64.StdEncoding.EncodeToString(attachment) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index f50d4df..b6a407a 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -499,7 +499,7 @@ func hideGameHandler(backend *backendclient.Client) Handler { 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 { + if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), req.ClientIP); err != nil { return nil, err } return encodeAck(true), nil diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index c0f7972..5814725 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -383,6 +383,7 @@ table FeedbackSubmitRequest { attachment:[ubyte]; attachment_name:string; channel:string; + version:string; } // FeedbackReply is the operator's answer shown back to the player. diff --git a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go index 0b18c4c..b9dde61 100644 --- a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go +++ b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go @@ -99,8 +99,16 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte { return nil } +func (rcv *FeedbackSubmitRequest) Version() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(4) + builder.StartObject(5) } func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) @@ -117,6 +125,9 @@ func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attach func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0) } +func FeedbackSubmitRequestAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(version), 0) +} func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts index db72963..8a5b870 100644 --- a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts +++ b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts @@ -56,8 +56,15 @@ channel(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +version():string|null +version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +version(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startFeedbackSubmitRequest(builder:flatbuffers.Builder) { - builder.startObject(4); + builder.startObject(5); } static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { @@ -88,17 +95,22 @@ static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset) builder.addFieldOffset(3, channelOffset, 0); } +static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) { + builder.addFieldOffset(4, versionOffset, 0); +} + static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset { +static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset { FeedbackSubmitRequest.startFeedbackSubmitRequest(builder); FeedbackSubmitRequest.addBody(builder, bodyOffset); FeedbackSubmitRequest.addAttachment(builder, attachmentOffset); FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset); FeedbackSubmitRequest.addChannel(builder, channelOffset); + FeedbackSubmitRequest.addVersion(builder, versionOffset); return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 0af414b..480b834 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -76,18 +76,20 @@ describe('codec', () => { it('round-trips a feedback submit and decodes state + unread', () => { const att = new Uint8Array([1, 2, 3, 4]); const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( - new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')), + new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3')), ); expect(req.body()).toBe('please fix'); expect(req.attachmentName()).toBe('shot.png'); expect(req.channel()).toBe('ios'); + expect(req.version()).toBe('v1.2.3'); expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]); // No attachment: the vector is empty. const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( - new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')), + new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev')), ); expect(req2.body()).toBe('hi'); + expect(req2.version()).toBe('dev'); expect(req2.attachmentLength()).toBe(0); // State carrying a reply. diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 80b3684..84bee10 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -489,17 +489,20 @@ export function encodeFeedbackSubmit( attachment: Uint8Array | null, attachmentName: string, channel: string, + version: string, ): Uint8Array { const b = new Builder(256); const bodyOff = b.createString(body); const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0; const nameOff = b.createString(attachmentName); const chOff = b.createString(channel); + const verOff = b.createString(version); fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b); fb.FeedbackSubmitRequest.addBody(b, bodyOff); if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff); fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff); fb.FeedbackSubmitRequest.addChannel(b, chOff); + fb.FeedbackSubmitRequest.addVersion(b, verOff); return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b)); } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 3979d7f..24ecfb0 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -147,7 +147,9 @@ export function createTransport(baseUrl: string): GatewayClient { await exec('chat.read', codec.encodeGameAction(id)); }, async feedbackSubmit(body, attachment, attachmentName, channel) { - await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel)); + // The app build (Vite define) rides with the report so the operator sees which version + // it came from; the caller need not pass it. + await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__)); }, async feedbackGet() { return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));