feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit). No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
This commit is contained in:
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||
// router falls through to the lobby for it.
|
||||
const variantSeedPrefix = "v"
|
||||
|
||||
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||
// leaves the account on its default preferences rather than failing the login.
|
||||
func SeedVariantsFromStartParam(startParam string) []string {
|
||||
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||
// without disturbing its other profile fields.
|
||||
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||
clean, err := validateVariantPreferences(prefs)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||
// UpdateProfile.
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||
// timezone or display name and ErrNotFound when no account matches id.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
param string
|
||||
want []string
|
||||
}{
|
||||
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||
{"empty", "", nil},
|
||||
{"prefix only", "v", nil},
|
||||
{"routing game link is not a seed", "g0190abcd", nil},
|
||||
{"friend code link is not a seed", "f123456", nil},
|
||||
{"unknown variant rejected", "vscrabble_de", nil},
|
||||
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SeedVariantsFromStartParam(tc.param)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||
// re-seeded on a later login (the new-user-only contract).
|
||||
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
Accounts: account.NewStore(testDB),
|
||||
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
|
||||
Notifier: &captureNotifier{},
|
||||
})
|
||||
h := srv.Handler()
|
||||
|
||||
post := func(ext, startParam string) {
|
||||
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||
if startParam != "" {
|
||||
body += `,"start_param":"` + startParam + `"`
|
||||
}
|
||||
body += `}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
store := account.NewStore(testDB)
|
||||
reload := func(ext string) []string {
|
||||
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup %s: %v", ext, err)
|
||||
}
|
||||
return acc.VariantPreferences
|
||||
}
|
||||
|
||||
// A brand-new account reached through a promo deep-link is seeded with English
|
||||
// Scrabble alongside the default Erudit.
|
||||
promoExt := "tg-" + uuid.NewString()
|
||||
post(promoExt, "verudit_ru-scrabble_en")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||
plainExt := "tg-" + uuid.NewString()
|
||||
post(plainExt, "")
|
||||
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||
// the seed is first-contact only.
|
||||
post(promoExt, "vscrabble_ru")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
@@ -19,13 +20,16 @@ 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; BrowserTZ (the client's detected
|
||||
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
|
||||
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||
// deep-link payload, which may seed the new account's variant preferences (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"`
|
||||
StartParam string `json:"start_param"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
@@ -47,6 +51,16 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
// joined the chat before registering is granted on the spot (no chat_member
|
||||
// event fires on registration).
|
||||
s.publishChatAccessChange(acc.ID)
|
||||
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
||||
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
||||
// malformed payload leaves the account on its defaults, and a write failure must
|
||||
// not block the session mint.
|
||||
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
||||
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
||||
s.log.Warn("telegram: seed variant preferences failed",
|
||||
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user