package account import ( "context" "errors" "fmt" "regexp" "strings" "time" "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 // 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 or trailing separator and no two adjacent separators, // except " ". So "Name_P. Last" is 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) } return name, nil } // 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 }