diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2c79cda..1591f2d 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -119,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return s.provision(ctx, kind, externalID, provisionSeed{}) } +// ProvisionEmail returns the account owning the email identity externalID, creating +// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM" +// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe +// and leaves an existing account untouched, so a returning user's saved zone is never +// overwritten. The email account is created here (the code-request step), not at the +// later login, so this is where its zone is seeded. +func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) { + return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)}) +} + // ProvisionRobot provisions (or finds) the durable account backing a robot pool // member: a KindRobot identity carrying displayName, with chat blocked but friend // requests NOT blocked — a request to a robot is accepted as pending and, since the @@ -160,7 +170,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri // is never overwritten. The created flag lets the auth handler re-evaluate moderated- // chat write access on first registration — the path of a user who joined the chat // before registering, whom no chat_member event covers. -func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) { +func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) { // Pre-check whether the identity already exists so the caller can act on first // contact. A race with a concurrent create only over- or under-reports created for // that one call, which the idempotent chat-access re-evaluation tolerates. @@ -169,7 +179,9 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, if err != nil && !created { return Account{}, false, err } - acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) + seed := telegramSeed(languageCode, username, firstName) + seed.timeZone = seedZone(browserTZ) + acc, err := s.provision(ctx, KindTelegram, externalID, seed) return acc, created, err } @@ -197,12 +209,24 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro } // provisionSeed carries the optional create-time profile seed for a brand-new -// account (Telegram first contact). Empty fields fall back to the accounts table -// defaults, so an unknown language keeps the 'en' default and an empty name keeps -// the ” default. +// account (first contact). Empty fields fall back to the accounts table defaults, +// so an unknown language keeps the 'en' default, an empty name keeps the ” default +// and an empty time zone keeps the 'UTC' default. type provisionSeed struct { preferredLanguage string displayName string + timeZone string +} + +// seedZone returns browserTZ when it is a well-formed zone to persist at account +// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account +// falls back to the accounts table's 'UTC' default. The client reports the device's +// detected offset deterministically; a bad value is dropped rather than guessed at. +func seedZone(browserTZ string) string { + if validZone(browserTZ) { + return browserTZ + } + return "" } // telegramSeed derives the create-time seed from Telegram launch fields: a @@ -368,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis var created Account err = withTx(ctx, s.db, func(tx *sql.Tx) error { - // Seed the new row's display name and language (Telegram first contact); an - // empty seed reproduces the table defaults ('' and 'en') the other callers - // relied on, so their behaviour is unchanged. + // Seed the new row's display name, language and time zone (first contact); an + // empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers + // relied on, so their behaviour is unchanged. time_zone is written explicitly (the + // detected offset, or 'UTC' equal to the column default) so a seeded zone lands at + // creation while an unseeded one stays UTC. lang := seed.preferredLanguage if lang == "" { lang = "en" } + tz := seed.timeZone + if tz == "" { + tz = "UTC" + } insertAccount := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). - VALUES(accountID, seed.displayName, lang). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone). + VALUES(accountID, seed.displayName, lang, tz). RETURNING(table.Accounts.AllColumns) var row model.Accounts @@ -416,15 +446,21 @@ const guestDisplayName = "Guest" // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // no identity, flagged is_guest, so it can hold a session and a game seat (both // foreign-key the accounts table) while being excluded from statistics, friends -// and history. Guests are not reused — each bootstrap mints a new account. -func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { +// and history. Guests are not reused — each bootstrap mints a new account. browserTZ +// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling +// back to the 'UTC' default when empty or malformed. +func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new guest id: %w", err) } + tz := seedZone(browserTZ) + if tz == "" { + tz = "UTC" + } stmt := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). - VALUES(accountID, guestDisplayName, true). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone). + VALUES(accountID, guestDisplayName, true, tz). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 4efbbfe..ae1fb7f 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -131,13 +131,15 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // the unauthenticated email-login entry point and, unlike RequestCode, // does not refuse an already-confirmed email — that is the ordinary returning-user // login. The code is mailed to the address, so only its real owner can complete -// the login. It returns the target account id for the subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { +// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) +// seeds the new account's time zone. It returns the target account id for the +// subsequent LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err } - acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) + acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ) if err != nil { return uuid.UUID{}, err } 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/account_test.go b/backend/internal/inttest/account_test.go index 67e002c..253f7ab 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool { } // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact -// seeds the new account's language and display name from the launch fields, -// defaults the in-app-only flag on, and never overwrites an existing account on a -// later login (language seeding). +// seeds the new account's language, display name and time zone from the launch +// fields / detected offset, defaults the in-app-only flag on, and never overwrites +// an existing account on a later login (language and zone seeding). func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") + acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { if acc.DisplayName != "Иван" { t.Errorf("DisplayName = %q, want Иван", acc.DisplayName) } + if acc.TimeZone != "+03:00" { + t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone) + } if !acc.NotificationsInAppOnly { t.Error("NotificationsInAppOnly should default to true") } // A later login with different fields returns the same account, unchanged. - again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") + again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00") if err != nil { t.Fatalf("re-provision telegram: %v", err) } @@ -146,8 +149,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { if again.ID != acc.ID { t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) } - if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" { - t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName) + if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" { + t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone) + } +} + +// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a +// valid detected offset is stored verbatim (even "+00:00", which is deliberately +// distinct from the unset "UTC" default), a guest is seeded the same way, and a +// missing or malformed offset falls back to the "UTC" column default rather than +// being guessed at. +func TestProvisionSeedsTimeZone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + // A detected zero offset is written as "+00:00" — we record that the zone was + // detected (and equals UTC), distinct from the "UTC" default meaning "unknown". + utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00") + if err != nil { + t.Fatalf("provision telegram +00:00: %v", err) + } + if utcDetected.TimeZone != "+00:00" { + t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone) + } + + // A malformed offset is dropped: the account keeps the UTC default. + bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone") + if err != nil { + t.Fatalf("provision telegram bad tz: %v", err) + } + if bad.TimeZone != "UTC" { + t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone) + } + + // A guest is seeded its detected offset; an empty one keeps the UTC default. + guest, err := store.ProvisionGuest(ctx, "-05:30") + if err != nil { + t.Fatalf("provision guest: %v", err) + } + if guest.TimeZone != "-05:30" { + t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone) + } + plainGuest, err := store.ProvisionGuest(ctx, "") + if err != nil { + t.Fatalf("provision plain guest: %v", err) + } + if plainGuest.TimeZone != "UTC" { + t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone) } } @@ -156,7 +204,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { // language CHECK. func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { ctx := context.Background() - acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") + acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -172,7 +220,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -228,7 +276,7 @@ func TestIdentityExternalID(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") + acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -253,7 +301,7 @@ func TestIdentityExternalID(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "") if err != nil { t.Fatalf("provision telegram: %v", err) } diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 46f3689..a6c27b1 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) - acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/chat_access_test.go b/backend/internal/inttest/chat_access_test.go index b36599b..7f94b9d 100644 --- a/backend/internal/inttest/chat_access_test.go +++ b/backend/internal/inttest/chat_access_test.go @@ -55,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) { srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) ext := "tg-" + uuid.NewString() - acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") + acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 469ef30..dc9a169 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) { svc := account.NewEmailService(account.NewStore(testDB), mailer) email := "login-" + uuid.NewString() + "@example.com" - accountID, err := svc.RequestLoginCode(ctx, email) + accountID, err := svc.RequestLoginCode(ctx, email, "+02:00") if err != nil { t.Fatalf("request login code: %v", err) } @@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) { if acc.IsGuest { t.Error("an email account must be durable, not a guest") } + if acc.TimeZone != "+02:00" { + t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone) + } if !identityConfirmed(t, account.KindEmail, email) { t.Error("the email identity must be confirmed after login") } // A second login for the same email is the returning user: same account. - if _, err := svc.RequestLoginCode(ctx, email); err != nil { + if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil { t.Fatalf("second request: %v", err) } acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) 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/inttest/helpers.go b/backend/internal/inttest/helpers.go index f8ab433..96cf8e8 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID { // provisionGuest creates a fresh ephemeral guest account and returns its id. func provisionGuest(t *testing.T) uuid.UUID { t.Helper() - acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) + acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/inttest/suspension_gate_test.go b/backend/internal/inttest/suspension_gate_test.go index 3d123af..3836bc6 100644 --- a/backend/internal/inttest/suspension_gate_test.go +++ b/backend/internal/inttest/suspension_gate_test.go @@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) { Accounts: accounts, }) - acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go index 78a4e2c..cfb7ac7 100644 --- a/backend/internal/inttest/userlist_test.go +++ b/backend/internal/inttest/userlist_test.go @@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) { st := account.NewStore(testDB) uniq := uuid.NewString() - human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") + human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "") if err != nil { t.Fatalf("provision human: %v", err) } @@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) { if err != nil { t.Fatalf("provision robot: %v", err) } - guest, err := st.ProvisionGuest(ctx) + guest, err := st.ProvisionGuest(ctx, "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/postgres/migrations/00002_default_ad_tips.sql b/backend/internal/postgres/migrations/00002_default_ad_tips.sql new file mode 100644 index 0000000..4f925b3 --- /dev/null +++ b/backend/internal/postgres/migrations/00002_default_ad_tips.sql @@ -0,0 +1,64 @@ +-- Replace the default (house) ad campaign's single seed tip with the curated, +-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the +-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so +-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded +-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages). + +-- +goose Up +DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad'; +INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'), + (gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.'); + +-- +goose Down +-- Restore the original single house tip seeded by the baseline. +DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad'; +INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) +VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0, + 'Tip: a play using all 7 tiles earns a +50 bonus.', + 'Совет: ход всеми 7 фишками приносит бонус +50 очков.'); 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_auth.go b/backend/internal/server/handlers_auth.go index 06cdf72..9074fb4 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -18,12 +18,14 @@ import ( // telegramAuthRequest carries the identity the connector extracted from a // validated initData payload. Username, FirstName and LanguageCode seed a -// brand-new account's display name and language (first contact only). +// brand-new account's display name and language; BrowserTZ (the client's detected +// "±HH:MM" UTC offset) seeds its time zone (first contact only). type telegramAuthRequest struct { ExternalID string `json:"external_id"` Username string `json:"username"` FirstName string `json:"first_name"` LanguageCode string `json:"language_code"` + BrowserTZ string `json:"browser_tz"` } // handleTelegramAuth provisions (or finds) the account bound to a Telegram @@ -35,7 +37,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { abortBadRequest(c, "external_id is required") return } - acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName) + acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ) if err != nil { s.abortErr(c, err) return @@ -97,9 +99,21 @@ func (s *Server) handlePushTarget(c *gin.Context) { }) } -// handleGuestAuth provisions a fresh ephemeral guest account and mints a session. +// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ +// (the client's detected "±HH:MM" UTC offset) is written to the new guest account's +// time zone, so robot timing is anchored to the player's zone from the first game. +type guestAuthRequest struct { + BrowserTZ string `json:"browser_tz"` +} + +// handleGuestAuth provisions a fresh ephemeral guest account and mints a session, +// seeding its time zone from the optional detected browser offset. func (s *Server) handleGuestAuth(c *gin.Context) { - acc, err := s.accounts.ProvisionGuest(c.Request.Context()) + // The body is optional: an absent or malformed one simply yields no time-zone seed + // (the account keeps the UTC default), so a bind error must not fail the bootstrap. + var req guestAuthRequest + _ = c.ShouldBindJSON(&req) + acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ) if err != nil { s.abortErr(c, err) return @@ -107,9 +121,12 @@ func (s *Server) handleGuestAuth(c *gin.Context) { s.mintSession(c, acc) } -// emailRequest is an email-login code request. +// emailRequest is an email-login code request. BrowserTZ (the client's detected +// "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first +// contact (the email account is created at the request step, not at login). type emailRequest struct { - Email string `json:"email"` + Email string `json:"email"` + BrowserTZ string `json:"browser_tz"` } // handleEmailRequest issues a login confirm-code to the email. It always reports @@ -121,7 +138,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) { abortBadRequest(c, "email is required") return } - if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil { + if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil { s.abortErr(c, err) return } 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..d7d1b29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,7 +158,12 @@ arrive from a platform rather than completing a mandatory registration). rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in any bot-scoped language, and the friend-invite **share link** (and its caption) point at that one bot. First Telegram contact seeds the new account's `preferred_language` from the - launch `language_code` (§4); the interface language is otherwise edited in Settings. + launch `language_code` (§4), but the **interface language follows the device** — the system + guess, or an explicit Settings choice saved locally — and the bot never dictates the UI. + `preferred_language` is then **reconciled to the active interface locale on every session + adopt** (not only on a Settings change; a no-op for guests and when already equal), so the + server-rendered language surfaces — this push and the ad banner — always match the UI rather + than stranding a user who never opened Settings on the creation-time seed. - **Variant preferences (New Game gating).** Which variants a player may be matched into is a per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New @@ -640,7 +645,7 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); **floats games with any unread entry to the top** of the your-turn and opponent-turn sections (the finished section keeps its activity order). On each clear the publish-to-read latency is recorded; the read time itself is not retained. -- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email +- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window**, the **variant preferences** (`variant_preferences`, the matchable-variant set that gates New Game — §3, defaulting to Erudit only, at least one enforced) and the @@ -649,7 +654,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone` for the sweeper and the robot's sleep (a fixed offset trades DST for a simple - picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform + picker), and is **seeded at account creation** from the client's detected offset — sent + on the Telegram / guest / email first-contact request — so the robot's sleep and the + away-window sweeper are anchored to the player's real zone from the first game rather + than the `UTC` default (an undetected or malformed offset keeps the default); the away + window is at most **12 h** (midnight-wrap aware). Linked platform accounts and merge are covered in §4. ## 9. Persistence @@ -1149,9 +1158,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 +1170,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..38260fe 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -241,7 +241,8 @@ also clears the moment its recipient **takes their move**. Edit the display name (letters joined by a single space / "." / "_" separator, with an optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most 5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone -(chosen as a UTC offset), the +(chosen as a UTC offset, and pre-filled from your device's detected offset when the account +is first created — so robot games are timed correctly before you ever open this form), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & @@ -346,7 +347,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..fa1dcd6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -248,8 +248,9 @@ _Вход сейчас только через провайдера, поэто Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / «_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от -UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с -переходом через полночь) и переключателей блокировок. Форма профиля редактируется +UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы +игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия +(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». @@ -356,7 +357,8 @@ high-rate флага. С карточки пользователя операт Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя, -IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера +на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index cb039f4..62e4951 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -184,8 +184,10 @@ type ChatResp struct { } // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a -// brand-new account's display name and language from the validated launch fields. -func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) { +// brand-new account's display name and language from the validated launch fields and +// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first +// contact only). +func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) { var out SessionResp err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", map[string]string{ @@ -193,6 +195,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use "language_code": languageCode, "username": username, "first_name": firstName, + "browser_tz": browserTz, }, &out) return out, err } @@ -243,17 +246,21 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces return out, err } -// GuestAuth provisions a guest account and mints a session. -func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) { +// GuestAuth provisions a guest account and mints a session, seeding its time zone +// from browserTz (the client's detected "±HH:MM" UTC offset). +func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) { var out SessionResp - err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out) + err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", + map[string]string{"browser_tz": browserTz}, &out) return out, err } -// EmailRequest asks the backend to mail a login code. -func (c *Client) EmailRequest(ctx context.Context, email string) error { +// EmailRequest asks the backend to mail a login code, provisioning the account on +// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new +// account's time zone, since the email account is created here, not at login. +func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error { return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "", - map[string]string{"email": email}, nil) + map[string]string{"email": email, "browser_tz": browserTz}, nil) } // EmailLogin verifies a login code and mints a session. 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..5c04035 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -158,7 +158,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha if err != nil { return nil, err } - sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName) + sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz())) if err != nil { return nil, err } @@ -167,8 +167,15 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha } func authGuestHandler(backend *backendclient.Client) Handler { - return func(ctx context.Context, _ Request) ([]byte, error) { - sess, err := backend.GuestAuth(ctx) + return func(ctx context.Context, req Request) ([]byte, error) { + // The guest bootstrap historically carried no payload; the detected zone is + // optional, so an absent or empty one simply yields no time-zone seed (rather + // than panicking in GetRootAs* on a zero-length buffer). + var browserTz string + if len(req.Payload) > 0 { + browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz()) + } + sess, err := backend.GuestAuth(ctx, browserTz) if err != nil { return nil, err } @@ -179,7 +186,7 @@ func authGuestHandler(backend *backendclient.Client) Handler { func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) - if err := backend.EmailRequest(ctx, string(in.Email())); err != nil { + if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil { return nil, err } return encodeAck(true), nil @@ -499,7 +506,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..74f25dc 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -99,24 +99,33 @@ table MoveRecord { // --- auth (unauthenticated) --- // TelegramLoginRequest carries the platform launch data; the gateway validates -// its HMAC before forwarding the extracted identity to the backend. +// its HMAC before forwarding the extracted identity to the backend. browser_tz is +// the client's detected UTC offset ("±HH:MM"), seeded into a brand-new account's +// time zone so the robot's sleep window and the turn-timeout away window are +// anchored to the player's real zone from first contact (first contact only). table TelegramLoginRequest { init_data:string; + browser_tz:string; } // GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional -// preferred-language hint. +// preferred-language hint; browser_tz is the detected UTC offset seeded into the +// guest account's time zone (see TelegramLoginRequest.browser_tz). table GuestLoginRequest { locale:string; + browser_tz:string; } -// EmailRequestRequest asks the backend to send a login confirm-code to email. +// EmailRequestRequest asks the backend to send a login confirm-code to email. It +// also provisions the account on first contact, so browser_tz (the detected UTC +// offset) is seeded into its time zone here, not at the later login step. table EmailRequestRequest { email:string; + browser_tz:string; } -// EmailLoginRequest logs in (or provisions) the account owning email, verifying -// the confirm-code. +// EmailLoginRequest logs in to the account owning email (provisioned at the +// request step), verifying the confirm-code. table EmailLoginRequest { email:string; code:string; @@ -383,6 +392,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/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go index 3173481..567b8ba 100644 --- a/pkg/fbs/scrabblefb/EmailRequestRequest.go +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -49,12 +49,23 @@ func (rcv *EmailRequestRequest) Email() []byte { return nil } +func (rcv *EmailRequestRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func EmailRequestRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) } +func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } 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/pkg/fbs/scrabblefb/GuestLoginRequest.go b/pkg/fbs/scrabblefb/GuestLoginRequest.go index 2d06f6e..dbdb026 100644 --- a/pkg/fbs/scrabblefb/GuestLoginRequest.go +++ b/pkg/fbs/scrabblefb/GuestLoginRequest.go @@ -49,12 +49,23 @@ func (rcv *GuestLoginRequest) Locale() []byte { return nil } +func (rcv *GuestLoginRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func GuestLoginRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0) } +func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/TelegramLoginRequest.go b/pkg/fbs/scrabblefb/TelegramLoginRequest.go index 2dccaf3..d37245c 100644 --- a/pkg/fbs/scrabblefb/TelegramLoginRequest.go +++ b/pkg/fbs/scrabblefb/TelegramLoginRequest.go @@ -49,12 +49,23 @@ func (rcv *TelegramLoginRequest) InitData() []byte { return nil } +func (rcv *TelegramLoginRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func TelegramLoginRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0) } +func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/platform/telegram/README.md b/platform/telegram/README.md index f3959c2..82690fa 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -38,10 +38,17 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies with an `Ack` per command (`delivered` mirrors the former connector semantics — false when the kind is not rendered out-of-app or the user never started the bot). -- **Bot chat.** `/start ` (and the chat menu button) reply with a Mini App - launch button; a deep-link payload routes the launch to a game / invitation / friend - code. This is **self-contained** — the bot never calls back into the game, so `/start` - onboarding works even when the game is down. +- **Bot chat.** `/start ` (and the chat menu button) reply with a localized + welcome and a Mini App launch button; a deep-link payload routes the launch to a game / + invitation / friend code. The welcome is **Russian or English** by the sender's reported + Telegram language (`Message.from.language_code`, which the Bot API carries on the message + itself — no separate user-update event — English fallback) and links the game channel and + discussion chat by their public `@username`, **resolved once at startup** from + `TELEGRAM_GAME_CHANNEL_ID` / `TELEGRAM_CHAT_ID` via `getChat` (a chat that is unset, + private, or unreadable degrades that link to a generic noun — "the channel" / "our + chat" — rather than a dangling "@"). This is otherwise **self-contained** + — the bot never calls back into the game, so `/start` onboarding works even when the game + is down. - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion group, the bot gates who may write there. The group **allows sending by default** (a human setting) and the bot only **restricts** — Telegram intersects the chat default with diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 17db610..85afd32 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -72,6 +72,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { MiniAppURL: cfg.MiniAppURL, SendRatePerSecond: cfg.SendRatePerSecond, ChatID: cfg.ChatID, + GameChannelID: cfg.GameChannelID, }, logger) if err != nil { return err diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 6cde9d4..eba0d44 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -33,8 +33,12 @@ type Config struct { SendRatePerSecond int // ChatID is the moderated discussion chat the bot gates write access in; 0 // disables chat gating (and the chat_member long-poll subscription). Gating needs - // the bot to be an administrator there with the restrict-members right. + // the bot to be an administrator there with the restrict-members right. Its public + // @username is also resolved at startup for the /start welcome's discussion link. ChatID int64 + // GameChannelID is the game channel whose public @username the /start welcome links + // to (resolved from this id via getChat at startup); 0 omits that follow link. + GameChannelID int64 } // EligibilityResolver answers whether the Telegram user identified by externalID @@ -54,6 +58,13 @@ type Bot struct { limiter *rate.Limiter // chatID is the moderated discussion chat (0 disables gating). chatID int64 + // channelID is the game channel (0 omits its welcome follow link). + channelID int64 + // channelUsername and chatUsername are the public @usernames (without the leading + // @) of the game channel and the discussion chat, resolved once at startup + // (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved. + channelUsername string + chatUsername string // botID is the bot's own Telegram user id (resolved at startup); it skips the // chat_member updates the bot's own restrict actions generate — the grant loop guard. botID int64 @@ -69,7 +80,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { if log == nil { log = zap.NewNop() } - t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID} + t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID} if cfg.SendRatePerSecond > 0 { t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) } @@ -123,9 +134,43 @@ func (t *Bot) Run(ctx context.Context) { if t.chatID != 0 { t.logChatAdminStatus(ctx) } + t.resolveWelcomeHandles(ctx) t.api.Start(ctx) } +// resolveWelcomeHandles resolves, once at startup, the public @usernames of the game +// channel and the discussion chat from their configured ids (getChat), caching them for +// the /start welcome's follow links. It runs before the update loop, so the handles are +// set before any /start is handled; a chat that is unset, private (no public username) +// or unreadable simply leaves its handle empty and the welcome omits that follow link. +func (t *Bot) resolveWelcomeHandles(ctx context.Context) { + t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel") + t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat") +} + +// resolveUsername returns the public @username (without the leading @) of the chat with +// the given id, or "" when id is 0, the chat has no public username, or getChat fails — +// logging the reason, since a missing handle silently drops a welcome follow link. +func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string { + if id == 0 { + return "" + } + chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id}) + if err != nil { + t.log.Warn("welcome: getChat failed; follow link omitted", + zap.String("chat", label), zap.Int64("id", id), zap.Error(err)) + return "" + } + if chat.Username == "" { + t.log.Warn("welcome: chat has no public @username; follow link omitted", + zap.String("chat", label), zap.Int64("id", id)) + return "" + } + t.log.Info("welcome: resolved follow link", + zap.String("chat", label), zap.String("username", chat.Username)) + return chat.Username +} + // logChatAdminStatus checks, at startup, whether the bot can actually gate the // moderated chat — it must be an administrator there with the restrict-members // ("Ban users") right, or Telegram delivers no chat_member updates and restricts @@ -198,11 +243,19 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up if update.Message.Chat.Type != models.ChatTypePrivate { return } + // The sender's Telegram language rides on the message itself (Message.from.language_code + // in the Bot API — there is no separate user-update event); fall back to English when it + // is absent. + lang := "" + if update.Message.From != nil { + lang = update.Message.From.LanguageCode + } + text, button := startText(lang, t.channelUsername, t.chatUsername) startParam := startPayload(update.Message.Text) if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: update.Message.Chat.ID, - Text: "Tap to open Scrabble.", - ReplyMarkup: t.launchMarkup("Open Scrabble", startParam), + Text: text, + ReplyMarkup: t.launchMarkup(button, startParam), }); err != nil { t.log.Warn("reply to start failed", zap.Error(err)) } diff --git a/platform/telegram/internal/bot/bot_test.go b/platform/telegram/internal/bot/bot_test.go index 15e883a..5d97abc 100644 --- a/platform/telegram/internal/bot/bot_test.go +++ b/platform/telegram/internal/bot/bot_test.go @@ -29,6 +29,10 @@ func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.text = r.FormValue("text") f.replyMarkup = r.FormValue("reply_markup") io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) + case strings.HasSuffix(r.URL.Path, "/getChat"): + // Echo the requested id into the username so a resolver test can tell the + // channel lookup from the chat lookup. + io.WriteString(w, `{"ok":true,"result":{"id":-100,"type":"channel","username":"u`+r.FormValue("chat_id")+`"}}`) default: io.WriteString(w, `{"ok":true,"result":true}`) } @@ -105,7 +109,7 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) { } func TestHandleStartRepliesPrivateOnly(t *testing.T) { - t.Run("private replies", func(t *testing.T) { + t.Run("private replies in english by default", func(t *testing.T) { api := &fakeBotAPI{} b := newTestBot(t, api) b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ @@ -114,6 +118,35 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) { if api.chatID != "42" || !strings.Contains(api.replyMarkup, "web_app") { t.Errorf("private /start: chat=%q markup=%q, want a web_app reply", api.chatID, api.replyMarkup) } + // No reported language -> English welcome + English button. + if !strings.Contains(api.text, "Hi!") { + t.Errorf("text = %q, want the English welcome", api.text) + } + if !strings.Contains(api.replyMarkup, "Open") { + t.Errorf("reply_markup = %q, want the English button", api.replyMarkup) + } + }) + t.Run("uses the sender's reported language", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start", + From: &models.User{ID: 7, LanguageCode: "ru"}, + }}) + if !strings.Contains(api.text, "Привет!") { + t.Errorf("text = %q, want the Russian welcome for a ru sender", api.text) + } + }) + t.Run("embeds resolved follow handles", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.channelUsername, b.chatUsername = "erudit", "erudite_chat" + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start", + }}) + if !strings.Contains(api.text, "@erudit") || !strings.Contains(api.text, "@erudite_chat") { + t.Errorf("text = %q, want the follow handles", api.text) + } }) t.Run("group ignored", func(t *testing.T) { api := &fakeBotAPI{} @@ -127,6 +160,26 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) { }) } +func TestResolveWelcomeHandles(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.channelID, b.chatID = 111, 222 + b.resolveWelcomeHandles(context.Background()) + // The fake echoes the requested id into the username, so each lookup is independent. + if b.channelUsername != "u111" { + t.Errorf("channelUsername = %q, want u111", b.channelUsername) + } + if b.chatUsername != "u222" { + t.Errorf("chatUsername = %q, want u222", b.chatUsername) + } + // An unset id resolves to no handle (and makes no getChat call). + b.channelID = 0 + b.resolveWelcomeHandles(context.Background()) + if b.channelUsername != "" { + t.Errorf("channelUsername = %q, want empty for id 0", b.channelUsername) + } +} + func TestStartPayload(t *testing.T) { cases := map[string]string{ "/start g123": "g123", diff --git a/platform/telegram/internal/bot/welcome.go b/platform/telegram/internal/bot/welcome.go new file mode 100644 index 0000000..de3f36a --- /dev/null +++ b/platform/telegram/internal/bot/welcome.go @@ -0,0 +1,60 @@ +package bot + +import "strings" + +// startText returns the localized /start welcome body and the launch-button label. +// Russian is used when lang (the IETF language tag the Telegram client reports on the +// message's sender) starts with "ru", English otherwise and when it is absent — so a +// user with no reported language still gets a sensible message. channel and chat are +// the resolved public @usernames (without the leading @) of the game channel and the +// discussion chat; when either is empty its follow link degrades to a generic noun +// (e.g. "the channel" / "our chat") rather than rendering a dangling "@", since the +// bot's own info screen still lists the real links. +func startText(lang, channel, chat string) (text, button string) { + if strings.HasPrefix(strings.ToLower(lang), "ru") { + return ruWelcome(channel, chat), "Открыть «Эрудит»" + } + return enWelcome(channel, chat), "Open “Erudite”" +} + +// ruWelcome builds the Russian welcome. A known handle is named as "@"; an +// unresolved one degrades to a plain noun. +func ruWelcome(channel, chat string) string { + ch := "канал" + if channel != "" { + ch = "@" + channel + } + ct := "чате" + if chat != "" { + ct = "@" + chat + } + return strings.Join([]string{ + "Привет! 👋", + "Здесь можно сражаться в «Эрудит» со случайными игроками или в компании друзей.", + "Подписывайтесь на " + ch + ", чтобы быть в курсе последних игровых событий и вовремя " + + "получать важные уведомления. Игроки могут обсуждать игру и просто общаться в нашем " + + ct + "! 💬", + "Ни слова больше.\nПервая партия сама себя не сыграет 😊", + }, "\n\n") +} + +// enWelcome builds the English welcome (the fallback for any non-Russian or missing +// language). A known handle is named as "@"; an unresolved one degrades to a +// plain noun. +func enWelcome(channel, chat string) string { + ch := "the channel" + if channel != "" { + ch = "@" + channel + } + ct := "group chat" + if chat != "" { + ct = "@" + chat + } + return strings.Join([]string{ + "Hi! 👋", + "Play Scrabble against random players — or with a group of friends.", + "Follow " + ch + " to stay up to date with the latest game events and receive important " + + "notifications in time. Players can discuss the game and simply chat in our " + ct + "! 💬", + "Okay, no more talking.\nFirst game won't play itself 😊", + }, "\n\n") +} diff --git a/platform/telegram/internal/bot/welcome_test.go b/platform/telegram/internal/bot/welcome_test.go new file mode 100644 index 0000000..e55c612 --- /dev/null +++ b/platform/telegram/internal/bot/welcome_test.go @@ -0,0 +1,73 @@ +package bot + +import ( + "strings" + "testing" +) + +func TestStartTextLocalizesByLanguage(t *testing.T) { + cases := []struct { + name string + lang string + wantButton string + wantSubstr string // a phrase unique to the chosen language body + }{ + {"russian", "ru", "Открыть «Эрудит»", "Привет!"}, + {"russian region tag", "ru-RU", "Открыть «Эрудит»", "Первая партия"}, + {"english", "en", "Open “Erudite”", "Hi!"}, + {"other language falls back to english", "de", "Open “Erudite”", "Hi!"}, + {"absent language falls back to english", "", "Open “Erudite”", "no more talking"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + text, button := startText(tc.lang, "erudit", "erudite_chat") + if button != tc.wantButton { + t.Errorf("button = %q, want %q", button, tc.wantButton) + } + if !strings.Contains(text, tc.wantSubstr) { + t.Errorf("text %q does not contain %q", text, tc.wantSubstr) + } + }) + } +} + +func TestStartTextEmbedsFollowHandles(t *testing.T) { + for _, lang := range []string{"ru", "en"} { + text, _ := startText(lang, "erudit", "erudite_chat") + if !strings.Contains(text, "@erudit") || !strings.Contains(text, "@erudite_chat") { + t.Errorf("lang %q: follow paragraph missing the handles: %q", lang, text) + } + } +} + +func TestStartTextFallsBackToGenericWhenHandleMissing(t *testing.T) { + // An unresolved handle degrades to a generic noun rather than a dangling "@" — and + // only that slot degrades; a resolved sibling still shows its "@username". + t.Run("both missing leaves no @", func(t *testing.T) { + for _, lang := range []string{"ru", "en"} { + text, _ := startText(lang, "", "") + if strings.Contains(text, "@") { + t.Errorf("lang %q: text shows a dangling @: %q", lang, text) + } + } + // The generic nouns are present in each language. + ru, _ := startText("ru", "", "") + if !strings.Contains(ru, "на канал") || !strings.Contains(ru, "в нашем чате") { + t.Errorf("russian generic fallback missing: %q", ru) + } + en, _ := startText("en", "", "") + if !strings.Contains(en, "Follow the channel") || !strings.Contains(en, "in our group chat") { + t.Errorf("english generic fallback missing: %q", en) + } + }) + t.Run("only the missing slot degrades", func(t *testing.T) { + // Channel resolved, chat missing: the channel keeps its @handle, the chat is generic. + en, _ := startText("en", "erudit", "") + if !strings.Contains(en, "@erudit") || strings.Contains(en, "@erudite") { + t.Errorf("channel handle not shown / chat handle leaked: %q", en) + } + if !strings.Contains(en, "in our group chat") { + t.Errorf("chat slot did not degrade to a generic noun: %q", en) + } + }) +} diff --git a/ui/src/gen/fbs/scrabblefb/email-request-request.ts b/ui/src/gen/fbs/scrabblefb/email-request-request.ts index 73edb2d..6ecbb46 100644 --- a/ui/src/gen/fbs/scrabblefb/email-request-request.ts +++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts @@ -27,22 +27,34 @@ email(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, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startEmailRequestRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { builder.addFieldOffset(0, emailOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.addEmail(builder, emailOffset); + EmailRequestRequest.addBrowserTz(builder, browserTzOffset); return EmailRequestRequest.endEmailRequestRequest(builder); } } 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/gen/fbs/scrabblefb/guest-login-request.ts b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts index ff46bdd..a059036 100644 --- a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts @@ -27,22 +27,34 @@ locale(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, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startGuestLoginRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) { builder.addFieldOffset(0, localeOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset):flatbuffers.Offset { +static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { GuestLoginRequest.startGuestLoginRequest(builder); GuestLoginRequest.addLocale(builder, localeOffset); + GuestLoginRequest.addBrowserTz(builder, browserTzOffset); return GuestLoginRequest.endGuestLoginRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts index 47ba75f..f883033 100644 --- a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts @@ -27,22 +27,34 @@ initData(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, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startTelegramLoginRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) { builder.addFieldOffset(0, initDataOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset):flatbuffers.Offset { +static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { TelegramLoginRequest.startTelegramLoginRequest(builder); TelegramLoginRequest.addInitData(builder, initDataOffset); + TelegramLoginRequest.addBrowserTz(builder, browserTzOffset); return TelegramLoginRequest.endTelegramLoginRequest(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index f494291..c96a82f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -8,6 +8,7 @@ import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; +import { languageNeedsServerSync } from './language'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { insideTelegram, @@ -67,7 +68,6 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - localeLocked: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that @@ -108,7 +108,6 @@ export const app = $state<{ locale: 'en', reduceMotion: false, boardLabels: 'beginner', - localeLocked: false, notifications: 0, chatUnread: {}, messageUnread: {}, @@ -450,11 +449,20 @@ async function adoptSession(s: Session): Promise { await saveSession(s); try { app.profile = await gateway.profileGet(); - // The live interface language follows the device — the explicit local choice (locked, saved - // in prefs) or the system guess made at bootstrap — and is no longer overridden from the - // account here. preferred_language stays the user's saved choice (written from Settings, - // and used for out-of-app push routing), but the Telegram bot a user signs in through must - // not dictate the UI: a ru-bot launch on an English system stays English. + // The live interface language follows the device — the explicit local choice (saved in + // prefs) or the system guess made at bootstrap — and is no longer overridden from the + // account here: the Telegram bot a user signs in through must not dictate the UI, so a + // ru-bot launch on an English system stays English. + // + // The banner and out-of-app push are resolved server-side from preferred_language, so it + // must track whatever language the UI actually shows — the explicit choice AND the system + // guess. Reconcile it to the active locale on every adopt, not only after an explicit + // Settings choice: a user who never opened Settings would otherwise be stuck on the + // creation-time seed — e.g. an English banner under a Russian UI. This keeps every + // server-rendered, language-dependent surface (banner, out-of-app push) aligned with the + // interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when + // already equal), so there is no write in the steady state. + void persistLanguageToServer(app.locale); } catch (err) { handleError(err); } @@ -475,6 +483,10 @@ export async function applyLinkResult(r: LinkResult): Promise { return; } app.profile = await gateway.profileGet(); + // A guest who linked in place now has a durable account: push the active interface language + // so the banner + push routing follow it (see adoptSession — reconciled regardless of an + // explicit Settings choice). + void persistLanguageToServer(app.locale); } /** @@ -527,7 +539,6 @@ export async function bootstrap(): Promise { applyReduceMotion(app.reduceMotion); if (prefs.locale) { app.locale = prefs.locale; - app.localeLocked = true; setLocale(prefs.locale); } else { const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); @@ -743,7 +754,6 @@ export function setTheme(theme: ThemePref): void { export function setLocalePref(locale: Locale): void { app.locale = locale; - app.localeLocked = true; setLocale(locale); persistPrefs(); void persistLanguageToServer(locale); @@ -756,7 +766,7 @@ export function setLocalePref(locale: Locale): void { */ async function persistLanguageToServer(locale: Locale): Promise { const p = app.profile; - if (!p || p.isGuest || p.preferredLanguage === locale) return; + if (!p || !languageNeedsServerSync(p, locale)) return; try { app.profile = await gateway.profileUpdate({ displayName: p.displayName, diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 0af414b..98c299c 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -19,13 +19,16 @@ import { decodeStateView, decodeStats, encodeCheckWord, + encodeEmailRequest, encodeFeedbackSubmit, encodeDraftSave, encodeEnqueue, encodeExchange, + encodeGuestLogin, encodeStateRequest, encodeSubmitPlay, encodeTarget, + encodeTelegramLogin, encodeUpdateProfile, } from './codec'; @@ -73,21 +76,45 @@ describe('codec', () => { }); }); + it('carries the detected browser zone on every account-creating auth request', () => { + const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest( + new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')), + ); + expect(tg.initData()).toBe('init-data-blob'); + expect(tg.browserTz()).toBe('+03:00'); + + const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest( + new ByteBuffer(encodeGuestLogin('ru', '-05:30')), + ); + expect(guest.locale()).toBe('ru'); + expect(guest.browserTz()).toBe('-05:30'); + + const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest( + new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')), + ); + expect(email.email()).toBe('a@example.com'); + expect(email.browserTz()).toBe('+00:00'); + }); + 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..abc0181 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -179,27 +179,33 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array { return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); } -export function encodeTelegramLogin(initData: string): Uint8Array { +export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array { const b = new Builder(512); const d = b.createString(initData); + const tz = b.createString(browserTz); fb.TelegramLoginRequest.startTelegramLoginRequest(b); fb.TelegramLoginRequest.addInitData(b, d); + fb.TelegramLoginRequest.addBrowserTz(b, tz); return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); } -export function encodeGuestLogin(locale: string): Uint8Array { +export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array { const b = new Builder(64); const l = b.createString(locale); + const tz = b.createString(browserTz); fb.GuestLoginRequest.startGuestLoginRequest(b); fb.GuestLoginRequest.addLocale(b, l); + fb.GuestLoginRequest.addBrowserTz(b, tz); return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b)); } -export function encodeEmailRequest(email: string): Uint8Array { +export function encodeEmailRequest(email: string, browserTz: string): Uint8Array { const b = new Builder(128); const e = b.createString(email); + const tz = b.createString(browserTz); fb.EmailRequestRequest.startEmailRequestRequest(b); fb.EmailRequestRequest.addEmail(b, e); + fb.EmailRequestRequest.addBrowserTz(b, tz); return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b)); } @@ -489,17 +495,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/language.test.ts b/ui/src/lib/language.test.ts new file mode 100644 index 0000000..3e725a3 --- /dev/null +++ b/ui/src/lib/language.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; + +import { languageNeedsServerSync } from './language'; +import type { Profile } from './model'; + +// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small. +const profile = (over: Partial): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile; + +describe('languageNeedsServerSync', () => { + it('is false without a profile', () => { + expect(languageNeedsServerSync(null, 'ru')).toBe(false); + expect(languageNeedsServerSync(undefined, 'ru')).toBe(false); + }); + + it('is false for a guest — guests keep only the client preference', () => { + expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false); + }); + + it('is false when the account already matches the locale', () => { + expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false); + }); + + it('is true for a real account whose stored language differs (banner + push follow it)', () => { + expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true); + expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true); + }); +}); diff --git a/ui/src/lib/language.ts b/ui/src/lib/language.ts new file mode 100644 index 0000000..514987e --- /dev/null +++ b/ui/src/lib/language.ts @@ -0,0 +1,22 @@ +// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the +// node-env Vitest layer cannot import) so the decision is unit-testable. + +import type { Locale } from './i18n/catalog'; +import type { Profile } from './model'; + +/** + * languageNeedsServerSync reports whether the durable account's `preferred_language` should be + * rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account + * whose stored language differs from the locale; guests keep only the client-side preference, + * and an already-matching account is a no-op. + * + * The UI language follows the device (the local choice / system guess), but the advertising + * banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved + * device choice the account has not yet recorded — picked while a guest, or differing from the + * Telegram system-language seed — would otherwise leave the banner and pushes in the wrong + * language until the next Settings change. Both the Settings control and the on-load reconciler + * gate their write on this. + */ +export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean { + return !!profile && !profile.isGuest && profile.preferredLanguage !== locale; +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 3979d7f..9360769 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'; @@ -62,13 +63,13 @@ export function createTransport(baseUrl: string): GatewayClient { }, async authTelegram(initData) { - return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData))); + return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, async authGuest(locale) { - return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); + return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); }, async authEmailRequest(email) { - await exec('auth.email.request', codec.encodeEmailRequest(email)); + await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset())); }, async authEmailLogin(email, code) { return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code))); @@ -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()));