package account import ( "context" "errors" "fmt" "math/rand/v2" "regexp" "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 // " "). The name may end with EITHER a single trailing "." // (an initial, "Anna B.") OR a run of 1–5 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"} // 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 } // 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 }