From 004aca4e97944dc0897f943507c543ec93d9529d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 18:05:39 +0200 Subject: [PATCH] feat(feedback): capture the browser UTC offset; Filed time in three zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account time zone defaults to UTC until a player saves a profile, so a report's Filed time could only render in UTC even for a player clearly in another zone. Capture the client's detected "±HH:MM" offset (browser_tz) with each submission and show the Filed time in three zones in the operator console — UTC, the browser offset detected at submit, and the sender's saved profile zone — each shown "N/A" when not known, so the operator can tell what is certainly known from what is merely defaulted. - Thread browser_tz through the fbs envelope (+ Go/TS codegen), the gateway transcode + backend client, and the backend feedback service/store; add the column via migration 00004 (additive, image-rollback-safe). - Fix fmtTimeIn to resolve "±HH:MM" offsets via account.ResolveZone; time.LoadLocation alone silently fell back to UTC for offset zones, which is the second reason a "+02:00" sender showed only UTC. - Update ARCHITECTURE/FUNCTIONAL(+ru) docs and the feedback integration test. --- .../templates/pages/feedback_detail.gohtml | 2 +- backend/internal/adminconsole/views.go | 16 ++++++++---- backend/internal/feedback/service.go | 9 ++++--- backend/internal/feedback/store.go | 25 ++++++++++-------- backend/internal/inttest/feedback_test.go | 26 +++++++++---------- .../migrations/00004_feedback_browser_tz.sql | 11 ++++++++ .../internal/server/handlers_admin_console.go | 12 ++++----- .../server/handlers_admin_feedback.go | 16 +++++++++--- backend/internal/server/handlers_feedback.go | 5 +++- docs/ARCHITECTURE.md | 7 ++--- docs/FUNCTIONAL.md | 5 ++-- docs/FUNCTIONAL_ru.md | 4 +-- .../internal/backendclient/api_feedback.go | 3 ++- gateway/internal/transcode/transcode.go | 2 +- pkg/fbs/scrabble.fbs | 1 + pkg/fbs/scrabblefb/FeedbackSubmitRequest.go | 13 +++++++++- .../fbs/scrabblefb/feedback-submit-request.ts | 16 ++++++++++-- ui/src/lib/codec.test.ts | 6 +++-- ui/src/lib/codec.ts | 3 +++ ui/src/lib/transport.ts | 8 +++--- 20 files changed, 127 insertions(+), 63 deletions(-) create mode 100644 backend/internal/postgres/migrations/00004_feedback_browser_tz.sql diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 4faf437..d6e0937 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -9,7 +9,7 @@
  • Interface language {{.InterfaceLanguage}}
  • App version {{if .Version}}{{.Version}}{{else}}unknown{{end}}
  • IP {{if .IP}}{{.IP}}{{else}}none{{end}}
  • -
  • Filed {{.CreatedAt}} UTC{{if .CreatedAtLocal}} · {{.CreatedAtLocal}} ({{.TimeZone}}){{end}}
  • +
  • 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 23d6d34..4908ebe 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -556,9 +556,15 @@ type FeedbackDetailView struct { CreatedAt string // 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 + // 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 0942766..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, version, 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 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)) + // 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 176c0e9..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) 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) { +// 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, 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 { + (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 @@ -231,8 +231,11 @@ type AdminMessage struct { 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. + // 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 @@ -348,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.app_version, ''), a.time_zone, + 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 @@ -357,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.Version, &m.TimeZone, + &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 e35088e..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", "v1", "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", "v1.2.0", "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" || m.Version != "v1.2.0" { + 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/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 bd649f1..0286a5e 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -1198,17 +1198,15 @@ 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. +// 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 "" } - loc, err := time.LoadLocation(tz) - if err != nil { - loc = time.UTC - } - return t.In(loc).Format("2006-01-02 15:04") + return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04") } // fmtTimePtr formats an optional timestamp for display, or "" when nil. diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index e5ff6f5..5f5e348 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -77,10 +77,16 @@ 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 := "" + // 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" { - localCreated = fmtTimeIn(m.CreatedAt, m.TimeZone) + userCreated = fmtTimeIn(m.CreatedAt, m.TimeZone) } view := adminconsole.FeedbackDetailView{ ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, @@ -89,7 +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, CreatedAtLocal: localCreated, TimeZone: m.TimeZone, + 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 46c1ec7..9766654 100644 --- a/backend/internal/server/handlers_feedback.go +++ b/backend/internal/server/handlers_feedback.go @@ -19,6 +19,9 @@ type feedbackSubmitRequest struct { // 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. @@ -64,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, req.Version, 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 5c7a85e..2604706 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1151,8 +1151,8 @@ Players reach the operators through a **Feedback** screen (Settings → Info, re only). A message (≤1024 runes) plus an optional single attachment is stored in `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 +(`__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 @@ -1162,7 +1162,8 @@ 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; it shows the channel, interface -language and app version, and the filed time in UTC plus the sender's time zone), and the read / +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: diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index e2af7e3..4b37f1a 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -346,8 +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, 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 +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 a638cc6..7ece650 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -356,8 +356,8 @@ high-rate флага. С карточки пользователя операт Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя, -**версией приложения**, с которой отправлено, IP, временем подачи (в UTC и в зоне отправителя) -и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера +на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке diff --git a/gateway/internal/backendclient/api_feedback.go b/gateway/internal/backendclient/api_feedback.go index d37b113..5a8d01e 100644 --- a/gateway/internal/backendclient/api_feedback.go +++ b/gateway/internal/backendclient/api_feedback.go @@ -27,13 +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, version, 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 b6a407a..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()), string(in.Version()), 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 5814725..93e8091 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -384,6 +384,7 @@ table FeedbackSubmitRequest { 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 b9dde61..f4a5c94 100644 --- a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go +++ b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go @@ -107,8 +107,16 @@ func (rcv *FeedbackSubmitRequest) Version() []byte { 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(5) + builder.StartObject(6) } func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) @@ -128,6 +136,9 @@ func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatb 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 8a5b870..a51b3da 100644 --- a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts +++ b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts @@ -63,8 +63,15 @@ version(optionalEncoding?:any):string|Uint8Array|null { 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(5); + builder.startObject(6); } static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { @@ -99,18 +106,23 @@ 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, versionOffset: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 480b834..cfecf72 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -76,20 +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', 'v1.2.3')), + 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', 'dev')), + 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 84bee10..faf24b8 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -490,6 +490,7 @@ export function encodeFeedbackSubmit( attachmentName: string, channel: string, version: string, + browserTz: string, ): Uint8Array { const b = new Builder(256); const bodyOff = b.createString(body); @@ -497,12 +498,14 @@ export function encodeFeedbackSubmit( 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 24ecfb0..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,9 +148,10 @@ export function createTransport(baseUrl: string): GatewayClient { await exec('chat.read', codec.encodeGameAction(id)); }, async feedbackSubmit(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__)); + // 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()));