From 57c778f9b265e43448c24638e02106009587868e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 20 Jun 2026 14:08:27 +0200 Subject: [PATCH] feat(telegram,game): single bot + per-user variant preferences Collapse the two per-language Telegram bots into one unified bot and replace language-based variant gating with explicit per-user variant preferences. - Telegram: one bot; drop service_language and the supported_languages set everywhere (DB, account, auth, FlatBuffers Session wire, gateway, connector proto). The single bot renders chat and out-of-app push in the recipient's preferred_language; remove the game-language push routing override (notify Intent.Language / push Event.language). - Preferences: new accounts.variant_preferences (text[], DB default {erudit_ru}, CHECK non-empty + subset of the three variants). Gates the New Game picker, vs-AI and the friend invitation the player creates, enforced server-side (HTTP 400 otherwise); an invited friend may still accept any variant. Edited on the Settings screen; variants are Erudit-first everywhere. - Admin: drop the per-bot language selectors (broadcast / send-to-user) and the feedback channel_lang column/field. - Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU}, VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU} to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES. - Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway, backend, ui, UI_DESIGN, PRERELEASE). The migration squash is deferred to a follow-up PR. --- .gitea/workflows/ci.yaml | 12 +- PLAN.md | 6 + PRERELEASE.md | 1 + backend/README.md | 10 +- backend/internal/account/account.go | 38 +---- backend/internal/account/profile.go | 54 ++++++- backend/internal/account/profile_test.go | 24 ++- backend/internal/adminconsole/render_test.go | 2 +- .../templates/pages/broadcast.gohtml | 1 - .../templates/pages/feedback_detail.gohtml | 2 +- .../templates/pages/user_detail.gohtml | 1 - backend/internal/adminconsole/views.go | 5 +- backend/internal/connector/client.go | 30 ++-- backend/internal/feedback/service.go | 11 +- backend/internal/feedback/store.go | 23 ++- backend/internal/game/emit_test.go | 7 - backend/internal/game/service.go | 15 -- backend/internal/inttest/account_test.go | 41 +---- .../internal/inttest/banner_console_test.go | 3 +- backend/internal/inttest/email_test.go | 20 ++- backend/internal/inttest/feedback_test.go | 35 ++-- backend/internal/inttest/game_limit_test.go | 6 +- .../inttest/seat_display_name_test.go | 4 +- backend/internal/inttest/social_test.go | 30 +--- .../inttest/variant_preference_test.go | 51 ++++++ backend/internal/lobby/matchmaker.go | 1 - backend/internal/notify/notify.go | 5 - .../postgres/jet/backend/model/accounts.go | 3 +- .../jet/backend/model/feedback_messages.go | 3 +- .../postgres/jet/backend/table/accounts.go | 12 +- .../jet/backend/table/feedback_messages.go | 13 +- .../00014_single_bot_variant_preferences.sql | 45 ++++++ backend/internal/pushgrpc/server.go | 9 +- backend/internal/server/banner.go | 8 +- backend/internal/server/dto.go | 23 +-- backend/internal/server/handlers_account.go | 18 ++- .../internal/server/handlers_admin_console.go | 10 +- .../server/handlers_admin_feedback.go | 2 +- backend/internal/server/handlers_auth.go | 34 ++-- .../internal/server/handlers_invitations.go | 3 + backend/internal/server/handlers_user.go | 23 +++ backend/internal/social/chat.go | 3 - backend/internal/social/social.go | 3 - deploy/.env.example | 16 +- deploy/README.md | 16 +- deploy/docker-compose.yml | 22 +-- docs/ARCHITECTURE.md | 86 +++++----- docs/FUNCTIONAL.md | 29 ++-- docs/FUNCTIONAL_ru.md | 31 ++-- docs/UI_DESIGN.md | 6 +- gateway/README.md | 1 - gateway/cmd/gateway/main.go | 15 +- gateway/internal/backendclient/api.go | 47 +++--- gateway/internal/backendclient/api_social.go | 1 + gateway/internal/config/config.go | 41 ----- gateway/internal/connector/client.go | 27 ++-- gateway/internal/transcode/encode.go | 35 +--- gateway/internal/transcode/transcode.go | 26 ++- gateway/internal/transcode/transcode_link.go | 24 ++- .../internal/transcode/transcode_social.go | 5 + .../transcode/transcode_telegram_test.go | 49 +----- pkg/fbs/scrabble.fbs | 13 +- pkg/fbs/scrabblefb/Profile.go | 25 ++- pkg/fbs/scrabblefb/Session.go | 36 +---- pkg/fbs/scrabblefb/UpdateProfileRequest.go | 25 ++- pkg/proto/push/v1/push.pb.go | 27 +--- pkg/proto/push/v1/push.proto | 5 - pkg/proto/telegram/v1/telegram.pb.go | 80 ++------- pkg/proto/telegram/v1/telegram.proto | 34 ++-- pkg/proto/telegram/v1/telegram_grpc.pb.go | 20 +-- platform/telegram/README.md | 46 +++--- platform/telegram/cmd/telegram/main.go | 53 +++--- platform/telegram/internal/config/config.go | 59 +++---- .../telegram/internal/config/config_test.go | 35 ++-- .../telegram/internal/connector/server.go | 152 ++++++------------ .../internal/connector/server_test.go | 108 +++---------- ui/README.md | 9 +- ui/src/Landing.svelte | 2 +- ui/src/components/StaleInviteModal.svelte | 5 +- ui/src/components/WelcomeRedeemModal.svelte | 5 +- ui/src/gen/fbs/scrabblefb/profile.ts | 30 +++- ui/src/gen/fbs/scrabblefb/session.ts | 45 +----- .../fbs/scrabblefb/update-profile-request.ts | 33 +++- ui/src/lib/app.svelte.ts | 1 + ui/src/lib/codec.test.ts | 36 ++++- ui/src/lib/codec.ts | 20 ++- ui/src/lib/deeplink.test.ts | 20 --- ui/src/lib/deeplink.ts | 30 ++-- ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + ui/src/lib/landing.test.ts | 16 +- ui/src/lib/landing.ts | 21 +-- ui/src/lib/mock/data.ts | 5 +- ui/src/lib/model.ts | 12 +- ui/src/lib/variants.test.ts | 25 +-- ui/src/lib/variants.ts | 21 +-- ui/src/screens/Friends.svelte | 14 +- ui/src/screens/NewGame.svelte | 6 +- ui/src/screens/Profile.svelte | 57 ++++++- 99 files changed, 1006 insertions(+), 1256 deletions(-) create mode 100644 backend/internal/inttest/variant_preference_test.go create mode 100644 backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 63f9196..c41b40c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -254,25 +254,19 @@ jobs: AWG_CONF: ${{ secrets.TEST_AWG_CONF }} GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} - TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }} - TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} - TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }} - TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }} + TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} # The test contour always uses Telegram's test environment — pinned here, # not an operator variable. The prod workflow leaves it false. TELEGRAM_TEST_ENV: "true" VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} - VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} - VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }} - VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} - VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} + VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} - GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }} # Unset vars render empty -> the compose ":-" defaults apply. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} diff --git a/PLAN.md b/PLAN.md index 92030a0..10836dd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -281,6 +281,12 @@ back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the g (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`) pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`. +> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the +> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit +> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars, +> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in +> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB. + ### Stage 16 — Deploy infra & test contour *(done)* Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend + gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway diff --git a/PRERELEASE.md b/PRERELEASE.md index 83c1ff7..0c9894f 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -37,6 +37,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | | BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** | | FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | +| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/README.md b/backend/README.md index 22fd1af..57c66ff 100644 --- a/backend/README.md +++ b/backend/README.md @@ -117,15 +117,13 @@ archive, preview the per-variant word diff, then install + activate — `interna `engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the `BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each -broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language` -holds the language tag of the bot a Telegram -user last signed in through, written on every login and returned by -`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes -to the right bot. The console also manages the **advertising banner** (`/_gm/banners` + +broadcast renders through the single bot in an operator-chosen language. There is one bot, +so `/internal/push-target` returns the recipient's `preferred_language` as the render +language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 -&& !no_banner` role, the message language picked by `service_language`); changing those inputs +&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index a0a7a28..14e143a 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -55,12 +55,12 @@ type Account struct { HintBalance int BlockChat bool BlockFriendRequests bool - // ServiceLanguage is the language tag (en/ru) of the bot the account last - // authenticated through (its last Telegram ValidateInitData); it routes the - // account's out-of-app push back through the right bot. Empty when the account - // has never signed in through a tagged bot. Distinct from PreferredLanguage (the - // interface language) and from a game's variant language. - ServiceLanguage string + // VariantPreferences is the set of game variants (engine.Variant stable labels: + // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched + // into. It gates the New Game picker, the matchmaker and the friend-invite the + // player creates; an invited friend may still accept any variant. A new account + // defaults to Erudit only. Never empty — enforced on update and by a DB check. + VariantPreferences []string // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. IsGuest bool @@ -491,36 +491,12 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error { return nil } -// SetServiceLanguage records the service language (en/ru) of the bot a Telegram -// user authenticated through. It is called on every Telegram login — new and -// existing accounts — so it tracks the bot the user last came through (last-login- -// wins), and the out-of-app push routes by it. It is a no-op for an empty language -// (a non-Telegram login carries none) and does not bump updated_at (an infra -// routing field, not a user profile edit). -func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error { - if language == "" { - return nil - } - stmt := table.Accounts. - UPDATE(table.Accounts.ServiceLanguage). - SET(postgres.String(language)). - WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))) - if _, err := stmt.ExecContext(ctx, s.db); err != nil { - return fmt.Errorf("account: set service language %s: %w", id, err) - } - return nil -} - // modelToAccount projects a generated model row into the public Account struct. func modelToAccount(row model.Accounts) Account { var mergedInto uuid.UUID if row.MergedInto != nil { mergedInto = *row.MergedInto } - var serviceLanguage string - if row.ServiceLanguage != nil { - serviceLanguage = *row.ServiceLanguage - } var flaggedHighRateAt time.Time if row.FlaggedHighRateAt != nil { flaggedHighRateAt = *row.FlaggedHighRateAt @@ -529,7 +505,7 @@ func modelToAccount(row model.Accounts) Account { ID: row.AccountID, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, - ServiceLanguage: serviceLanguage, + VariantPreferences: []string(row.VariantPreferences), TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index d2fa936..fbee7a7 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -14,6 +14,7 @@ import ( "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" + "github.com/lib/pq" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" @@ -58,6 +59,46 @@ type ProfileUpdate struct { BlockChat bool BlockFriendRequests bool NotificationsInAppOnly bool + // VariantPreferences is the set of game variants the player allows themselves to + // be matched into (engine.Variant stable labels). UpdateProfile cleans it to a + // deduplicated, canonically ordered subset of the known variants and rejects an + // empty set. + VariantPreferences []string +} + +// knownVariants is the closed set of game-variant labels (engine.Variant stable +// labels) a profile's variant preferences may contain. It lives here so the store +// does not depend on the engine package; the server handler additionally validates +// against engine.ParseVariant, and a DB check enforces the same subset. +var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true} + +// canonicalVariantOrder is the deterministic order variant preferences are stored +// in (Erudit, Russian Scrabble, English), independent of the client's order. +var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"} + +// validateVariantPreferences cleans a profile's variant-preference set: it drops +// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and +// returns the preferences in canonicalVariantOrder so the stored value is +// deterministic regardless of the order the client sent. +func validateVariantPreferences(prefs []string) ([]string, error) { + seen := make(map[string]bool, len(prefs)) + for _, p := range prefs { + p = strings.TrimSpace(p) + if !knownVariants[p] { + return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p) + } + seen[p] = true + } + if len(seen) == 0 { + return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile) + } + out := make([]string, 0, len(seen)) + for _, v := range canonicalVariantOrder { + if seen[v] { + out = append(out, v) + } + } + return out, nil } // UpdateProfile validates and overwrites the editable fields of the account, then @@ -79,17 +120,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil { return Account{}, err } + prefs, err := validateVariantPreferences(p.VariantPreferences) + if err != nil { + return Account{}, err + } stmt := table.Accounts.UPDATE( table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.AwayStart, table.Accounts.AwayEnd, table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, - table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt, + table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences, + table.Accounts.UpdatedAt, ).SET( postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), - postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()), + postgres.Bool(p.NotificationsInAppOnly), + // prefs are validated against the closed knownVariants set; bind as a text[] + // parameter (lib/pq encodes the array, the cast pins the column type). + postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}), + postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). RETURNING(table.Accounts.AllColumns) diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go index b8774f7..5003dd9 100644 --- a/backend/internal/account/profile_test.go +++ b/backend/internal/account/profile_test.go @@ -3,6 +3,7 @@ package account import ( "context" "errors" + "slices" "strings" "testing" "time" @@ -16,7 +17,7 @@ import ( // offset/IANA timezone), not just their unit tests in validate_test.go. func TestUpdateProfileValidation(t *testing.T) { s := &Store{} - base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"} + base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}} hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } tests := []struct { name string @@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) { {"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }}, {"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }}, {"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }}, + {"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }}, + {"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) { }) } } + +// TestValidateVariantPreferences checks the cleaning of a profile's variant set: +// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble, +// English) regardless of input order, and an empty or unknown set is rejected. +func TestValidateVariantPreferences(t *testing.T) { + got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"}) + if err != nil { + t.Fatalf("validate: %v", err) + } + if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("empty err = %v, want ErrInvalidProfile", err) + } + if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("unknown err = %v, want ErrInvalidProfile", err) + } +} diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index f827e3f..2f46a28 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -43,7 +43,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"}, {"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"}, {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, - {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"}, + {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml index 8aa559a..f570231 100644 --- a/backend/internal/adminconsole/templates/pages/broadcast.gohtml +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -5,7 +5,6 @@ {{if .ConnectorEnabled}}
-
{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 3ba3e4d..6d217b9 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -5,7 +5,7 @@

Message