diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml
index 6d217b9..d6e0937 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 · browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}N/A{{end}} · user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}N/A{{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..4908ebe 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -554,5 +554,17 @@ 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
+ // The Filed time is shown in three zones so the operator can tell what is certainly known from
+ // what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
+ // instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
+ // when the client reported none (an older build). CreatedAtUser is that instant in the sender's
+ // saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
+ // default — the template then shows "N/A" so the missing datum is explicit.
+ CreatedAtBrowser string
+ BrowserTZ string
+ CreatedAtUser string
+ UserTZ string
+ Banned bool
}
diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go
index 824cff9..25e71f4 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, browserTZ, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
@@ -112,9 +112,10 @@ 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, the client app version and the client's
+ // detected UTC offset 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, browserTZ, parseIP(senderIP))
return err
}
diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go
index 5281978..25a5252 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 (interface language), version (client app build) and
+// browserTZ (the client's detected "±HH:MM" UTC offset) 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, browserTZ 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, browser_tz, sender_ip)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
+ id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,7 +228,15 @@ 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
+ // BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
+ // filed time can be shown in the sender's browser-local zone even before they save a profile.
+ BrowserTZ string
+ // TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
+ // rendering CreatedAt in the sender's own configured time alongside UTC.
+ TimeZone string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -343,7 +351,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, ''), COALESCE(m.browser_tz, ''), 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 +360,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.BrowserTZ, &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..ce3c0e8 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", "+05:00", "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", "+03:00", "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" || m.BrowserTZ != "+03:00" {
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/postgres/migrations/00004_feedback_browser_tz.sql b/backend/internal/postgres/migrations/00004_feedback_browser_tz.sql
new file mode 100644
index 0000000..416243a
--- /dev/null
+++ b/backend/internal/postgres/migrations/00004_feedback_browser_tz.sql
@@ -0,0 +1,11 @@
+-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
+-- operator console can show the filed time in the sender's browser-local zone even before that
+-- player has ever saved a profile (the account zone defaults to UTC until then). 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 browser_tz text;
+
+-- +goose Down
+ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 4e7c1f8..0286a5e 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -1198,6 +1198,17 @@ func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04")
}
+// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
+// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
+// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
+// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
+func fmtTimeIn(t time.Time, tz string) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.In(account.ResolveZone(tz)).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..5f5e348 100644
--- a/backend/internal/server/handlers_admin_feedback.go
+++ b/backend/internal/server/handlers_admin_feedback.go
@@ -77,6 +77,17 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
s.consoleError(c, err)
return
}
+ // Filed time in three zones so the operator can tell what is certainly known from what is
+ // merely defaulted: always UTC; the client's offset detected at submit (when the build
+ // reported one); and the sender's saved profile zone (when set beyond the UTC default). An
+ // empty rendered time makes the template show "N/A" for that line.
+ browserCreated, userCreated := "", ""
+ if m.BrowserTZ != "" {
+ browserCreated = fmtTimeIn(m.CreatedAt, m.BrowserTZ)
+ }
+ if m.TimeZone != "" && m.TimeZone != "UTC" {
+ userCreated = 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 +95,9 @@ 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,
+ CreatedAtBrowser: browserCreated, BrowserTZ: m.BrowserTZ,
+ CreatedAtUser: userCreated, UserTZ: 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..9766654 100644
--- a/backend/internal/server/handlers_feedback.go
+++ b/backend/internal/server/handlers_feedback.go
@@ -16,6 +16,12 @@ 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"`
+ // BrowserTZ is the client's detected UTC offset ("±HH:MM") at submit, so the operator can
+ // see the filed time in the sender's local zone even before they save a profile.
+ BrowserTZ string `json:"browser_tz"`
}
// feedbackReplyDTO is the operator's reply shown back to the player.
@@ -61,7 +67,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, req.BrowserTZ, clientIP(c)); err != nil {
s.abortErr(c, err)
return
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2d2d3cc..2604706 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), the client's **detected UTC offset** at
+submit (`browser_tz`, `±HH:MM`) 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,11 @@ 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 three zones — UTC, the browser offset detected at
+submit, and the sender's saved profile zone, each `N/A` when not known), 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..4b37f1a 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -346,7 +346,9 @@ 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 three zones — UTC, the
+browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) 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..7ece650 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, зоне браузера
+на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
diff --git a/gateway/internal/backendclient/api_feedback.go b/gateway/internal/backendclient/api_feedback.go
index 2894c3d..5a8d01e 100644
--- a/gateway/internal/backendclient/api_feedback.go
+++ b/gateway/internal/backendclient/api_feedback.go
@@ -27,12 +27,14 @@ 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, browserTz, clientIP string) error {
payload := map[string]string{
"body": body,
"attachment": "",
"attachment_name": attachmentName,
"channel": channel,
+ "version": version,
+ "browser_tz": browserTz,
}
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..a7b7aab 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()), string(in.BrowserTz()), 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..93e8091 100644
--- a/pkg/fbs/scrabble.fbs
+++ b/pkg/fbs/scrabble.fbs
@@ -383,6 +383,8 @@ table FeedbackSubmitRequest {
attachment:[ubyte];
attachment_name:string;
channel:string;
+ version:string;
+ browser_tz: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..f4a5c94 100644
--- a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go
+++ b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go
@@ -99,8 +99,24 @@ 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 (rcv *FeedbackSubmitRequest) BrowserTz() []byte {
+ o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
+ if o != 0 {
+ return rcv._tab.ByteVector(o + rcv._tab.Pos)
+ }
+ return nil
+}
+
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
- builder.StartObject(4)
+ builder.StartObject(6)
}
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
@@ -117,6 +133,12 @@ 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 FeedbackSubmitRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
+ builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(browserTz), 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..a51b3da 100644
--- a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts
+++ b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts
@@ -56,8 +56,22 @@ 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;
+}
+
+browserTz():string|null
+browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+browserTz(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 14);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
- builder.startObject(4);
+ builder.startObject(6);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
@@ -88,17 +102,27 @@ 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 addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(5, browserTzOffset, 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, browserTzOffset: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);
+ FeedbackSubmitRequest.addBrowserTz(builder, browserTzOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
}
}
diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts
index 0af414b..cfecf72 100644
--- a/ui/src/lib/codec.test.ts
+++ b/ui/src/lib/codec.test.ts
@@ -76,18 +76,22 @@ 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', '+03:00')),
);
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(req.browserTz()).toBe('+03:00');
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', '+00:00')),
);
expect(req2.body()).toBe('hi');
+ expect(req2.version()).toBe('dev');
+ expect(req2.browserTz()).toBe('+00:00');
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..faf24b8 100644
--- a/ui/src/lib/codec.ts
+++ b/ui/src/lib/codec.ts
@@ -489,17 +489,23 @@ export function encodeFeedbackSubmit(
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
+ version: string,
+ browserTz: 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);
+ const tzOff = b.createString(browserTz);
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);
+ fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts
index 3979d7f..53b5d94 100644
--- a/ui/src/lib/transport.ts
+++ b/ui/src/lib/transport.ts
@@ -10,6 +10,7 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
+import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -147,7 +148,10 @@ 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) and the device's detected UTC offset ride with the report
+ // so the operator sees which version it came from and the local time it was filed; the
+ // caller need not pass them.
+ await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__, browserOffset()));
},
async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));