diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 43998c9..2324a2c 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -534,7 +534,7 @@ const guestDisplayName = "Guest" // 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) { +func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new guest id: %w", err) @@ -543,9 +543,17 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, if tz == "" { tz = "UTC" } + // Seed the interface language from the client's detected locale (validated to a supported + // one), so a fresh guest's language-dependent server content — the ad banner, bot messages — + // is right from first contact rather than the 'en' column default until the client's later + // language reconcile catches up. An unsupported or absent code keeps the 'en' default. + lang := supportedLanguage(language) + if lang == "" { + lang = "en" + } stmt := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone). - VALUES(accountID, guestDisplayName, true, tz). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage). + VALUES(accountID, guestDisplayName, true, tz, lang). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 9e0e1f4..5527741 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -229,21 +229,36 @@ func TestProvisionSeedsTimeZone(t *testing.T) { 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") + // A guest is seeded its detected offset and interface language; an empty offset keeps the + // UTC default and an empty/unsupported language keeps the 'en' default. + guest, err := store.ProvisionGuest(ctx, "-05:30", "ru") 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 guest.PreferredLanguage != "ru" { + t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage) + } + 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) } + if plainGuest.PreferredLanguage != "en" { + t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage) + } + // An unsupported locale falls back to the 'en' default rather than persisting a junk value. + frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR") + if err != nil { + t.Fatalf("provision fr guest: %v", err) + } + if frGuest.PreferredLanguage != "en" { + t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage) + } } // TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 802e0e4..984321c 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) { mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") - guest, err := store.ProvisionGuest(ctx, "") + guest, err := store.ProvisionGuest(ctx, "", "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 9d4235a..2dcd1b3 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) { store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") - guest, err := store.ProvisionGuest(ctx, "") + guest, err := store.ProvisionGuest(ctx, "", "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go index 6493cc4..fdf2e60 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -154,7 +154,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/userlist_test.go b/backend/internal/inttest/userlist_test.go index cfb7ac7..2d6df1e 100644 --- a/backend/internal/inttest/userlist_test.go +++ b/backend/internal/inttest/userlist_test.go @@ -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/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index e9e073d..a24c6a6 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -160,16 +160,20 @@ type guestAuthRequest struct { // Subtype is the client-reported device family (ios/android/web) of this direct // (web/native) session; there is no external signer, so it is recorded best-effort. Subtype string `json:"subtype"` + // Language is the client's detected interface locale, seeding the fresh guest's + // interface language (best-effort; an unsupported/absent value keeps the 'en' default). + Language string `json:"language"` } // handleGuestAuth provisions a fresh ephemeral guest account and mints a session, -// seeding its time zone from the optional detected browser offset. +// seeding its time zone from the optional detected browser offset and its interface +// language from the optional detected locale. func (s *Server) handleGuestAuth(c *gin.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. + // The body is optional: an absent or malformed one simply yields no time-zone/language seed + // (the account keeps the UTC / 'en' defaults), 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) + acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language) if err != nil { s.abortErr(c, err) return diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 7610e07..e4bf934 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -312,11 +312,12 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces } // 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, subtype string) (SessionResp, error) { +// from browserTz (the client's detected "±HH:MM" UTC offset) and its interface +// language from language (the client's detected locale; best-effort). +func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype, language string) (SessionResp, error) { var out SessionResp err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", - map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out) + map[string]string{"browser_tz": browserTz, "subtype": subtype, "language": language}, &out) return out, err } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 6fc60b5..f030405 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -270,13 +270,14 @@ func authGuestHandler(backend *backendclient.Client) Handler { // 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, subtype string + var browserTz, subtype, locale string if len(req.Payload) > 0 { g := fb.GetRootAsGuestLoginRequest(req.Payload, 0) browserTz = string(g.BrowserTz()) subtype = string(g.Subtype()) + locale = string(g.Locale()) } - sess, err := backend.GuestAuth(ctx, browserTz, subtype) + sess, err := backend.GuestAuth(ctx, browserTz, subtype, locale) if err != nil { return nil, err }