Files
scrabble-game/backend/internal/account/profile.go
T
Ilia Denisov b6d88da78c
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(variants): default a registered account to Erudit + Russian Scrabble
New Game opened on a single variant for everyone, which reads poorly on VK
where Russian Scrabble is the familiar game. A registered account now starts
with both Russian-alphabet games, so the picker offers a real choice with no
pre-selection.

A guest stays on Erudit alone and is invited to register for the rest: the
device-local guest has no profile at all, so the client-side fallback has to
keep matching the server. Registering promotes the set, but only while the
guest still carries the untouched guest default. Existing accounts are not
backfilled — the set they carry may be a deliberate choice.

The Telegram promo start-param becomes additive rather than a replacement,
with English Scrabble withheld from a Russian-speaking arrival; otherwise the
English campaign link would have taken Russian Scrabble away from every
account it onboarded.
2026-07-27 20:38:09 +02:00

329 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package account
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"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"
)
// maxDisplayName caps an editable display name's length in runes (the column itself
// is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (every name rune that is
// neither a letter, a space, nor a digit — i.e. the "." / "_" separators) an editable
// display name may carry, so a still-well-formed name cannot be made of mostly
// punctuation. A trailing digit run is bounded separately by displayNameRe.
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format: Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
// (an initial, "Anna B.") OR a run of 15 digits (a handle's number or year,
// "Player2007"), but not both; digits never appear elsewhere. So "Name_P. Last",
// "Anna B." and "Аня2007" are valid, while "Name P._Last" and "Dark2Wolf" are not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
var ErrInvalidProfile = errors.New("account: invalid profile")
// ProfileUpdate is the full set of player-editable profile fields. UpdateProfile
// overwrites every field, so callers send the complete desired profile. AwayStart
// and AwayEnd carry only the hour and minute of the daily away window, in the
// account's TimeZone.
type ProfileUpdate struct {
DisplayName string
PreferredLanguage string // "en" or "ru"
TimeZone string // an IANA location name
AwayStart time.Time
AwayEnd time.Time
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"}
// DefaultVariantPreferences is the variant set a newly registered account starts with:
// both Russian-alphabet games. It mirrors the accounts.variant_preferences column
// default, which is what actually seeds a created row — the constant is here so the
// promotion of a guest (ClearGuest) and the tests name the same set as the schema.
var DefaultVariantPreferences = []string{"erudit_ru", "scrabble_ru"}
// GuestVariantPreferences is the variant set an ephemeral guest starts with: Эрудит
// alone. A guest is deliberately narrower than a registered player — the New Game
// screen offers the single variant and invites the guest to register for the rest —
// so ProvisionGuest writes it explicitly instead of taking the column default.
var GuestVariantPreferences = []string{"erudit_ru"}
// 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
}
// 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
}
// MergeVariantSeed resolves the variant-preference set to store for an account that
// arrived on a promo deep link. The seed decoded from the link is *added* to current
// (the set the account already carries, normally the column default) rather than
// replacing it, so a campaign can only ever widen a player's choice. English Scrabble
// is the one variant the language gates: it is dropped from seed when languageCode is
// Russian, because a Russian-speaking arrival is served by the two Russian-alphabet
// games and the English one would only clutter New Game. It returns nil when there is
// nothing to write — an empty or invalid seed, or a merge that leaves current as it is
// — so the caller can skip the update.
func MergeVariantSeed(current, seed []string, languageCode string) []string {
if len(seed) == 0 {
return nil
}
extra := seed
if supportedLanguage(languageCode) == "ru" {
extra = slices.DeleteFunc(slices.Clone(seed), func(v string) bool { return v == "scrabble_en" })
}
merged, err := validateVariantPreferences(append(slices.Clone(current), extra...))
if err != nil {
return nil
}
if base, err := validateVariantPreferences(current); err == nil && slices.Equal(base, merged) {
return nil
}
return merged
}
// 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.
func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (Account, error) {
lang := strings.TrimSpace(p.PreferredLanguage)
if lang != "en" && lang != "ru" {
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
}
tz := strings.TrimSpace(p.TimeZone)
if !validZone(tz) {
return Account{}, fmt.Errorf("%w: time_zone %q", ErrInvalidProfile, p.TimeZone)
}
name, err := ValidateDisplayName(p.DisplayName)
if err != nil {
return Account{}, err
}
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.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),
// 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)
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: update profile %s: %w", id, err)
}
return modelToAccount(row), nil
}
// ValidateDisplayName trims surrounding whitespace and checks the editable
// display-name length (<= maxDisplayName runes) and format (displayNameRe),
// returning the cleaned name or ErrInvalidProfile. It is exported so the gateway
// boundary could reuse it; the UI mirrors the same rule.
func ValidateDisplayName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", fmt.Errorf("%w: display name is empty", ErrInvalidProfile)
}
if utf8.RuneCountInString(name) > maxDisplayName {
return "", fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
}
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
specials := 0
for _, r := range name {
if r != ' ' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
specials++
}
}
if specials > maxDisplayNameSpecials {
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
}
return name, nil
}
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
// first name) to the editable display-name format: it keeps the maximal runs of
// Unicode letters and joins them with a single space, dropping every other rune
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
// result therefore always satisfies ValidateDisplayName, or is empty when the input
// carries no letters — in which case the caller substitutes placeholderDisplayName.
// Mirroring the profile editor's rule means a connector-provisioned name is editable
// later without first failing validation.
func sanitizeDisplayName(raw string) string {
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
if len(fields) == 0 {
return ""
}
name := strings.Join(fields, " ")
if utf8.RuneCountInString(name) > maxDisplayName {
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
}
return name
}
// placeholderDisplayName builds a fallback display name for a platform account whose
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
// carries digits and a hyphen, so it lies outside the editable format and the player
// is expected to rename it; provisioned names bypass that editor validation.
func placeholderDisplayName(lang string) string {
prefix := "Player"
if lang == "ru" {
prefix = "Игрок"
}
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
}
// validateAwayWindow checks that the daily away window's duration, wrapping across
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
// "no away time" and is allowed.
func validateAwayWindow(start, end time.Time) error {
mins := (end.Hour()*60 + end.Minute()) - (start.Hour()*60 + start.Minute())
if mins < 0 {
mins += 24 * 60
}
if time.Duration(mins)*time.Minute > maxAwayWindow {
return fmt.Errorf("%w: away window exceeds %s", ErrInvalidProfile, maxAwayWindow)
}
return nil
}