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" "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 (the "." / "_" separators — // every name rune that is neither a letter nor a space) an editable display name may // carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17). 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 (Stage 8): 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 // " "); a single trailing "." is allowed (Stage 17), so // "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not. var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`) // 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 } // 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 } 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, ).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()), ).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) { 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 }