diff --git a/deploy/migrations/0088_users_signup_email.down.sql b/deploy/migrations/0088_users_signup_email.down.sql new file mode 100644 index 00000000..b9c5dd4f --- /dev/null +++ b/deploy/migrations/0088_users_signup_email.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS users_signup_email_lower_unique_idx; +ALTER TABLE public.users DROP COLUMN signup_email; diff --git a/deploy/migrations/0088_users_signup_email.up.sql b/deploy/migrations/0088_users_signup_email.up.sql new file mode 100644 index 00000000..79f82be7 --- /dev/null +++ b/deploy/migrations/0088_users_signup_email.up.sql @@ -0,0 +1,13 @@ +-- Email-signup accounts (see internal/domain.EncodeEmailPhone) now get a +-- short, normal-looking random "888" phone number in users.phone instead of +-- storing the email-derived string there directly (that string only ever +-- travels on the wire during auth.sendCode/signIn/signUp, exactly like a real +-- phone number would). signup_email is the durable email->user reverse +-- lookup that replaces the old "phone IS the email encoding" identity: it is +-- what SendCode/SignIn use to find a returning email-signup account by the +-- (deterministic) wire value decoded back to an email. +ALTER TABLE public.users ADD COLUMN signup_email character varying(200) NOT NULL DEFAULT ''; + +-- Case-insensitive, ignores the empty default so real phone-number accounts +-- (the overwhelming majority) never touch this index. +CREATE UNIQUE INDEX users_signup_email_lower_unique_idx ON public.users (lower(signup_email)) WHERE signup_email <> ''; diff --git a/internal/app/account/email_signup_phone_change_test.go b/internal/app/account/email_signup_phone_change_test.go index 159d0937..72e1dd07 100644 --- a/internal/app/account/email_signup_phone_change_test.go +++ b/internal/app/account/email_signup_phone_change_test.go @@ -75,8 +75,14 @@ func TestEmailSignupChangePhoneRoutesCodeToDecodedEmail(t *testing.T) { if err != nil { t.Fatalf("ChangePhone: %v", err) } - if result.User.Phone != newPhone { - t.Fatalf("result.User.Phone = %q, want %q", result.User.Phone, newPhone) + // Rebinding to a new email assigns a fresh short "888" display number + // (see auth.Service.SignUp's identical treatment) rather than storing + // the long wire value directly; SignupEmail carries the association. + if result.User.Phone == newPhone || !domain.ValidPhone(result.User.Phone) || domain.IsEmailSignupPhone(result.User.Phone) { + t.Fatalf("result.User.Phone = %q, want a short all-digit 888 display number distinct from the wire value %q", result.User.Phone, newPhone) + } + if result.User.SignupEmail != "newmail@owpengram.local" { + t.Fatalf("result.User.SignupEmail = %q, want newmail@owpengram.local", result.User.SignupEmail) } } diff --git a/internal/app/account/phone_change.go b/internal/app/account/phone_change.go index 292cb7f1..e2894bf8 100644 --- a/internal/app/account/phone_change.go +++ b/internal/app/account/phone_change.go @@ -81,6 +81,15 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64, if !ok { return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid } + // The wire value never equals any stored users.phone once assigned (see + // ChangePhone), so the generic ByPhone occupied check above is a no-op + // for this path; the real "is this email already someone else's + // account" guard is by SignupEmail instead. + if existing, found, err := s.users.ByEmail(ctx, email); err != nil { + return "", domain.AuthCodeDelivery{}, err + } else if found && existing.ID != userID { + return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied + } if s.loginEmailSender == nil { return "", domain.AuthCodeDelivery{}, fmt.Errorf("email signup sender is not configured") } @@ -162,7 +171,7 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig if date == 0 { date = int(time.Now().Unix()) } - result, err := s.phoneChanges.ChangePhone(ctx, domain.PhoneChangeRequest{ + req := domain.PhoneChangeRequest{ UserID: userID, Phone: phone, Date: date, @@ -171,7 +180,31 @@ func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID, orig // echoes updateUserPhone back to the initiating device and suppresses the wrong session. ExcludeAuthKeyID: originRawAuthKeyID, ExcludeSessionID: sessionID, - }) + } + // Rebinding to a different email: the account keeps its short "888" + // display number (or gets a fresh one), never the long wire value — + // same reasoning as SignUp. ByPhone above is a no-op for this case since + // the wire value never equals a stored users.phone; the real + // already-bound-elsewhere guard is by SignupEmail. + if s.emailSignupEnabled && domain.IsEmailSignupPhone(phone) { + email, ok := domain.DecodeEmailPhone(phone) + if !ok { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid + } + email = domain.NormalizeEmailForPhone(email) + if existing, found, err := s.users.ByEmail(ctx, email); err != nil { + return domain.PhoneChangeResult{}, err + } else if found && existing.ID != userID { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied + } + displayPhone, err := s.assignEmailSignupDisplayPhone(ctx) + if err != nil { + return domain.PhoneChangeResult{}, err + } + req.Phone = displayPhone + req.SignupEmail = email + } + result, err := s.phoneChanges.ChangePhone(ctx, req) if err != nil { return domain.PhoneChangeResult{}, err } @@ -205,6 +238,30 @@ func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID return u, nil } +// maxEmailSignupPhoneAttempts bounds assignEmailSignupDisplayPhone's +// collision-retry loop so a store failure can't spin forever. +const maxEmailSignupPhoneAttempts = 20 + +// assignEmailSignupDisplayPhone generates a short "888" display number for +// an email-signup account rebinding to a new email (see +// domain.NewEmailSignupDisplayPhone / auth.Service's SignUp counterpart), +// re-rolling on the astronomically unlikely collision with an existing +// account's phone. +func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) { + for i := 0; i < maxEmailSignupPhoneAttempts; i++ { + candidate, err := domain.NewEmailSignupDisplayPhone() + if err != nil { + return "", err + } + if _, found, err := s.users.ByPhone(ctx, candidate); err != nil { + return "", err + } else if !found { + return candidate, nil + } + } + return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts) +} + func phoneChangeHash() (string, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { diff --git a/internal/app/auth/email_signup_test.go b/internal/app/auth/email_signup_test.go index d22b13a9..8316b7a2 100644 --- a/internal/app/auth/email_signup_test.go +++ b/internal/app/auth/email_signup_test.go @@ -47,14 +47,20 @@ func TestEmailSignupSendCodeRoutesFreshSignupToEmail(t *testing.T) { t.Fatalf("needSignUp = false, want true for a brand-new email-signup account") } - // SignUp itself is completely untouched by email-signup: same call, same - // phone (the 888-encoded value), no email-specific parameter anywhere. + // SignUp itself is called exactly like the plain-phone path (same phone + // argument, no email-specific parameter anywhere), but the account it + // creates gets a short, normal-looking "888" display number instead of + // storing the long email-encoded wire value as its permanent phone; the + // email->user association lives in SignupEmail instead. created, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "New", "User") if err != nil { t.Fatalf("SignUp: %v", err) } - if created.Phone != phone { - t.Fatalf("created.Phone = %q, want %q", created.Phone, phone) + if created.Phone == phone || !domain.ValidPhone(created.Phone) || domain.IsEmailSignupPhone(created.Phone) { + t.Fatalf("created.Phone = %q, want a short all-digit 888 display number distinct from the wire value %q", created.Phone, phone) + } + if created.SignupEmail != "newuser@owpengram.local" { + t.Fatalf("created.SignupEmail = %q, want newuser@owpengram.local", created.SignupEmail) } } @@ -88,8 +94,11 @@ func TestEmailSignupSignUpSucceedsWithLoginEmailRequireSetupAlsoOn(t *testing.T) if err != nil { t.Fatalf("SignUp: %v (this is the loop bug if it fails with ErrCodeInvalid)", err) } - if created.Phone != phone { - t.Fatalf("created.Phone = %q, want %q", created.Phone, phone) + if created.Phone == phone || !domain.ValidPhone(created.Phone) || domain.IsEmailSignupPhone(created.Phone) { + t.Fatalf("created.Phone = %q, want a short all-digit 888 display number distinct from the wire value %q", created.Phone, phone) + } + if created.SignupEmail != "requiresetup@owpengram.local" { + t.Fatalf("created.SignupEmail = %q, want requiresetup@owpengram.local", created.SignupEmail) } } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index c603f4b0..49002bf4 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -298,7 +298,7 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) // deliberately skipped the welcome message while password_pending. if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found { if u, found, err := s.users.ByID(ctx, a.UserID); err == nil && found { - s.recordWelcomeMessage(ctx, u.ID, u.Phone) + s.recordWelcomeMessage(ctx, u) } } return nil @@ -345,10 +345,25 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { return s.createPhoneCode(ctx, phone, issuedUserID) } +// currentPhoneOwner resolves the account currently identified by a wire +// phone value. For email-signup phones (see domain.EncodeEmailPhone) the +// account's real users.phone is a short, unrelated "888" display number +// (domain.NewEmailSignupDisplayPhone) assigned at SignUp — the wire value +// itself is never stored — so lookup goes through the decoded email and +// User.SignupEmail instead of ByPhone. Every owner-drift/idempotency +// invariant elsewhere in this file (issuedOwnerMatches, verifyLoginCode, +// SignUp's occupied checks, ...) is expressed purely in terms of "the +// account currentPhoneOwner resolves to", so fixing this one lookup keeps +// all of them correct for email-signup phones with no further changes. func (s *Service) currentPhoneOwner(ctx context.Context, phone string) (domain.User, bool, error) { if s == nil || s.users == nil { return domain.User{}, false, fmt.Errorf("user store is not configured") } + if s.emailSignupEnabled { + if email, ok := domain.DecodeEmailPhone(phone); ok { + return s.users.ByEmail(ctx, email) + } + } return s.users.ByPhone(ctx, phone) } @@ -906,7 +921,7 @@ func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, e // 2FA accounts only really finish authorizing in CompletePasswordSignIn; // firing the welcome message here too would notify about an attempt that // never actually got past the password check. - s.recordWelcomeMessage(ctx, existing.ID, existing.Phone) + s.recordWelcomeMessage(ctx, existing) return existing, domain.Message{}, false, nil } @@ -993,6 +1008,23 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, FirstName: firstName, LastName: lastName, } + // Email-signup accounts don't keep the long email-encoded wire value as + // their permanent phone — that only ever needed to travel on the wire to + // get here. Assign a short, normal-looking "888" number instead and + // record the email separately (SignupEmail), which is what + // currentPhoneOwner uses to find this account again on a later login. + if s.emailSignupEnabled && domain.IsEmailSignupPhone(phone) { + email, ok := domain.DecodeEmailPhone(phone) + if !ok { + return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid + } + displayPhone, err := s.assignEmailSignupDisplayPhone(ctx) + if err != nil { + return domain.User{}, domain.Message{}, err + } + newUser.Phone = displayPhone + newUser.SignupEmail = domain.NormalizeEmailForPhone(email) + } // 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量 // 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。 if s.premiumGrantMonths > 0 { @@ -1021,7 +1053,7 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, return domain.User{}, domain.Message{}, err } } - s.recordWelcomeMessage(ctx, u.ID, phone) + s.recordWelcomeMessage(ctx, u) return u, loginMessage, nil } @@ -1329,11 +1361,11 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str // SignIn/SignInWithEmail), regardless of channel. Best-effort: a failure here // must never fail the sign-in itself, since unlike recordLoginMessage it // carries no secret the caller needs. -func (s *Service) recordWelcomeMessage(ctx context.Context, userID int64, phone string) { +func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) { if s == nil || s.messages == nil || s.dialogs == nil { return } - msg, err := domain.OfficialWelcomeMessage(userID, domain.SignInMethodLabel(phone), int(time.Now().Unix())) + msg, err := domain.OfficialWelcomeMessage(u.ID, domain.SignInMethodLabel(u), int(time.Now().Unix())) if err != nil { return } @@ -1341,7 +1373,7 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, userID int64, phone if err != nil { return } - _ = s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{ + _ = s.dialogs.UpsertInbox(ctx, u.ID, domain.Dialog{ Peer: created.Peer, TopMessage: created.ID, TopMessageDate: created.Date, @@ -1445,6 +1477,28 @@ func normalizePhone(phone string) string { return domain.NormalizePhone(phone) } +// assignEmailSignupDisplayPhone generates a short "888" display number for a +// new email-signup account (see domain.NewEmailSignupDisplayPhone), +// re-rolling on the astronomically unlikely collision with an existing +// account's phone. maxEmailSignupPhoneAttempts bounds the loop so a store +// failure can't spin forever. +const maxEmailSignupPhoneAttempts = 20 + +func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) { + for i := 0; i < maxEmailSignupPhoneAttempts; i++ { + candidate, err := domain.NewEmailSignupDisplayPhone() + if err != nil { + return "", err + } + if _, found, err := s.users.ByPhone(ctx, candidate); err != nil { + return "", err + } else if !found { + return candidate, nil + } + } + return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts) +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/domain/emailphone.go b/internal/domain/emailphone.go index 23f92454..a17a51d0 100644 --- a/internal/domain/emailphone.go +++ b/internal/domain/emailphone.go @@ -1,6 +1,10 @@ package domain -import "strings" +import ( + "crypto/rand" + "fmt" + "strings" +) // EmailPhonePrefix marks a "phone number" as a synthetic identity encoding an // email address, not a real phone. It reuses Telegram's own +888 "Anonymous @@ -24,11 +28,11 @@ const MaxEmailSignupPhoneLen = 200 const emailPhoneEscape = 'q' var emailPhoneEscapeEncode = map[rune]byte{ - '@': '0', - '.': '1', - '-': '2', - '_': '3', - '+': '4', + '@': '0', + '.': '1', + '-': '2', + '_': '3', + '+': '4', emailPhoneEscape: '5', } @@ -135,6 +139,33 @@ func NormalizeEmailForPhone(email string) string { return strings.ToLower(strings.TrimSpace(email)) } +// emailSignupDisplayPhoneDigits is how many random digits follow the "888" +// prefix in a NewEmailSignupDisplayPhone result, e.g. "888" + 8 digits = +// "88812345678" (formats on-screen as something like "+888 1234 5678"). +const emailSignupDisplayPhoneDigits = 8 + +// NewEmailSignupDisplayPhone generates a short, all-digit "888" phone number +// for an email-signup account's users.phone column. Unlike EncodeEmailPhone +// this carries no information about the email — it is purely a +// normal-looking display/identity number — so the caller must separately +// persist the email->user association (see User.SignupEmail) for returning +// logins to be found. Because the result is all digits, IsEmailSignupPhone +// on it is always false: once assigned, it behaves exactly like a real phone +// number everywhere else in the system (contacts, search, ByPhone lookups). +func NewEmailSignupDisplayPhone() (string, error) { + b := make([]byte, emailSignupDisplayPhoneDigits) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate email signup display phone: %w", err) + } + var out strings.Builder + out.Grow(len(EmailPhonePrefix) + emailSignupDisplayPhoneDigits) + out.WriteString(EmailPhonePrefix) + for _, v := range b { + out.WriteByte('0' + v%10) + } + return out.String(), nil +} + // IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone. // Every encoded value contains at least one letter (the mandatory '@' // escape's 'q' marker byte), which real, all-digit phone numbers — even diff --git a/internal/domain/emailphone_test.go b/internal/domain/emailphone_test.go index b24a9903..8a8560b1 100644 --- a/internal/domain/emailphone_test.go +++ b/internal/domain/emailphone_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "strings" + "testing" +) func TestEncodeDecodeEmailPhoneRoundTrip(t *testing.T) { for _, email := range []string{ @@ -62,3 +65,32 @@ func TestDecodeEmailPhoneRejectsNonEmailNumbers(t *testing.T) { } } } + +func TestNewEmailSignupDisplayPhoneLooksLikeARealPhoneNumber(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 200; i++ { + phone, err := NewEmailSignupDisplayPhone() + if err != nil { + t.Fatalf("NewEmailSignupDisplayPhone: %v", err) + } + if !strings.HasPrefix(phone, EmailPhonePrefix) { + t.Fatalf("phone %q missing %q prefix", phone, EmailPhonePrefix) + } + if !ValidPhone(phone) { + t.Fatalf("phone %q fails ValidPhone", phone) + } + // Must be indistinguishable from a real phone number: no letters, so + // IsEmailSignupPhone/DecodeEmailPhone never mistake it for a wire + // email-signup value once it's assigned as an account's real phone. + if IsEmailSignupPhone(phone) { + t.Fatalf("IsEmailSignupPhone(%q) = true, want false (must look like a real number)", phone) + } + if _, ok := DecodeEmailPhone(phone); ok { + t.Fatalf("DecodeEmailPhone(%q) unexpectedly succeeded", phone) + } + seen[phone] = struct{}{} + } + if len(seen) < 190 { + t.Fatalf("only %d distinct values out of 200 draws, generator looks non-random", len(seen)) + } +} diff --git a/internal/domain/phone_change.go b/internal/domain/phone_change.go index 6ccb7605..4ab69297 100644 --- a/internal/domain/phone_change.go +++ b/internal/domain/phone_change.go @@ -9,6 +9,11 @@ type PhoneChangeRequest struct { Date int ExcludeAuthKeyID [8]byte ExcludeSessionID int64 + // SignupEmail, when non-empty, is written to users.signup_email in the + // same transaction as Phone. Only email-signup phone changes set this + // (see account.Service.ChangePhone); ordinary phone-number changes leave + // it as the zero value and the column untouched. + SignupEmail string } type PhoneChangeResult struct { diff --git a/internal/domain/user.go b/internal/domain/user.go index 7070f084..fb8d5e02 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -22,9 +22,14 @@ func (c PeerColor) Empty() bool { // User 是一个账号。第一阶段仅保留登录链路必须字段; // access_hash 为任何 InputUser 校验所必须,不可省。 type User struct { - ID int64 - AccessHash int64 - Phone string + ID int64 + AccessHash int64 + Phone string + // SignupEmail is set only for email-signup accounts (see + // domain.NewEmailSignupDisplayPhone): it is the durable email->user + // reverse lookup key, since Phone itself no longer encodes the email. + // Empty for every ordinary phone-number account. + SignupEmail string FirstName string LastName string About string diff --git a/internal/domain/welcome_message.go b/internal/domain/welcome_message.go index d6a912bf..53ef2762 100644 --- a/internal/domain/welcome_message.go +++ b/internal/domain/welcome_message.go @@ -30,10 +30,12 @@ func OfficialWelcomeMessage(userID int64, method string, date int) (Message, err } // SignInMethodLabel returns the human-readable method name embedded in -// OfficialWelcomeMessage, derived from whether phone is an email-signup -// synthetic number (see EncodeEmailPhone) or a real phone number. -func SignInMethodLabel(phone string) string { - if IsEmailSignupPhone(phone) { +// OfficialWelcomeMessage. Email-signup accounts are identified by +// SignupEmail (see NewEmailSignupDisplayPhone) rather than by their stored +// Phone, which — once assigned — is an ordinary-looking short number that +// carries no information about the signup method. +func SignInMethodLabel(u User) string { + if u.SignupEmail != "" { return "email" } return "phone number" diff --git a/internal/store/memory/phone_change.go b/internal/store/memory/phone_change.go index 7bccaaa8..6642d0bb 100644 --- a/internal/store/memory/phone_change.go +++ b/internal/store/memory/phone_change.go @@ -42,7 +42,11 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan } } currentPhone := u.Phone + currentSignupEmail := u.SignupEmail u.Phone = req.Phone + if req.SignupEmail != "" { + u.SignupEmail = req.SignupEmail + } s.users.byID[req.UserID] = u date := req.Date @@ -62,6 +66,7 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan if err != nil { // 保持内存替身与 PG 的 user+event 原子可见语义。 u.Phone = currentPhone + u.SignupEmail = currentSignupEmail s.users.byID[req.UserID] = u s.users.mu.Unlock() return domain.PhoneChangeResult{}, err diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index 33f65060..f9814a18 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -2,6 +2,7 @@ package memory import ( "context" + "fmt" "sort" "strings" "sync" @@ -72,6 +73,23 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, return domain.User{}, false, nil } +// ByEmail looks up an email-signup account by its signup_email (see +// domain.NewEmailSignupDisplayPhone). Mirrors postgres.UserStore.ByEmail. +func (s *UserStore) ByEmail(_ context.Context, email string) (domain.User, bool, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return domain.User{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.byID { + if u.SignupEmail != "" && strings.ToLower(u.SignupEmail) == email { + return u, true, nil + } + } + return domain.User{}, false, nil +} + func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) { if len(phones) == 0 { return nil, nil @@ -377,6 +395,14 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error } } } + signupEmail := strings.ToLower(strings.TrimSpace(u.SignupEmail)) + if signupEmail != "" { + for _, existing := range s.byID { + if existing.SignupEmail != "" && strings.ToLower(existing.SignupEmail) == signupEmail { + return domain.User{}, fmt.Errorf("create user: signup email occupied") + } + } + } u.ID = s.nextID s.nextID++ s.byID[u.ID] = u diff --git a/internal/store/postgres/phone_change.go b/internal/store/postgres/phone_change.go index 8dd26719..7553510e 100644 --- a/internal/store/postgres/phone_change.go +++ b/internal/store/postgres/phone_change.go @@ -64,9 +64,14 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan return domain.PhoneChangeResult{User: userFromModel(row)}, nil } - row, err := qtx.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ID: req.UserID, Phone: req.Phone}) + var row sqlcgen.User + if req.SignupEmail != "" { + row, err = qtx.UpdateUserPhoneAndSignupEmail(ctx, sqlcgen.UpdateUserPhoneAndSignupEmailParams{ID: req.UserID, Phone: req.Phone, SignupEmail: req.SignupEmail}) + } else { + row, err = qtx.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ID: req.UserID, Phone: req.Phone}) + } if err != nil { - if isUniqueConstraint(err, "users_phone_unique_idx") { + if isUniqueConstraint(err, "users_phone_unique_idx") || isUniqueConstraint(err, "users_signup_email_lower_unique_idx") { return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied } if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index 11d93d77..1dc126ea 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -10,6 +10,9 @@ ORDER BY id; -- name: GetUserByPhone :one SELECT * FROM users WHERE phone = $1; +-- name: GetUserBySignupEmail :one +SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> ''; + -- name: GetUsersByPhones :many SELECT * FROM users @@ -99,8 +102,8 @@ ORDER BY contact DESC, rank, id LIMIT sqlc.arg(limit_count); -- name: CreateUser :one -INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- name: UpdateUserUsername :one @@ -132,6 +135,14 @@ SET phone = sqlc.arg(phone)::text, WHERE id = sqlc.arg(id)::bigint RETURNING *; +-- name: UpdateUserPhoneAndSignupEmail :one +UPDATE users +SET phone = sqlc.arg(phone)::text, + signup_email = sqlc.arg(signup_email)::text, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + -- name: SetUserPremiumUntil :one UPDATE users SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go index d2be43e7..b4d2dea6 100644 --- a/internal/store/postgres/sqlcgen/bot.sql.go +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { const insertBotUser = `-- name: InsertBotUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) VALUES ($1, '', $2, '', $3, '', TRUE, 1) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type InsertBotUserParams struct { @@ -209,6 +209,7 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index 71e73d9d..89456f19 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -1579,6 +1579,7 @@ type User struct { BirthdayMonth int32 BirthdayYear int32 PersonalChannelID int64 + SignupEmail string } type UserBusinessProfile struct { diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 8b04ac8f..3d9d52f1 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -12,14 +12,15 @@ import ( ) const createUser = `-- name: CreateUser :one -INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +INSERT INTO users (access_hash, phone, signup_email, first_name, last_name, username, country_code, premium_expires_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type CreateUserParams struct { AccessHash int64 Phone string + SignupEmail string FirstName string LastName string Username string @@ -31,6 +32,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e row := q.db.QueryRow(ctx, createUser, arg.AccessHash, arg.Phone, + arg.SignupEmail, arg.FirstName, arg.LastName, arg.Username, @@ -68,12 +70,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -109,12 +112,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } const getUserByPhone = `-- name: GetUserByPhone :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE phone = $1 ` func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { @@ -150,12 +154,55 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, + ) + return i, err +} + +const getUserBySignupEmail = `-- name: GetUserBySignupEmail :one +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '' +` + +func (q *Queries) GetUserBySignupEmail(ctx context.Context, lower string) (User, error) { + row := q.db.QueryRow(ctx, getUserBySignupEmail, lower) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE lower(username) = lower($1) AND username <> '' +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE lower(username) = lower($1) AND username <> '' ` func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { @@ -191,12 +238,13 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE id = ANY($1::bigint[]) ORDER BY id @@ -241,6 +289,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ); err != nil { return nil, err } @@ -253,7 +302,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } const getUsersByPhones = `-- name: GetUsersByPhones :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email FROM users WHERE phone = ANY($1::text[]) ORDER BY id @@ -298,6 +347,7 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ); err != nil { return nil, err } @@ -480,7 +530,7 @@ UPDATE users SET premium_expires_at = $1::timestamptz, updated_at = now() WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type SetUserPremiumUntilParams struct { @@ -521,6 +571,7 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -530,7 +581,7 @@ UPDATE users SET verified = $1::boolean, updated_at = now() WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type SetUserVerifiedParams struct { @@ -571,6 +622,7 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -586,7 +638,7 @@ WHERE id IN ( ORDER BY premium_expires_at LIMIT $2::int ) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type SweepExpiredPremiumParams struct { @@ -633,6 +685,7 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ); err != nil { return nil, err } @@ -651,7 +704,7 @@ SET birthday_day = $1::int, birthday_year = $3::int, updated_at = now() WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserBirthdayParams struct { @@ -699,6 +752,7 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -710,7 +764,7 @@ SET color_set = $1::boolean, color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserColorParams struct { @@ -758,6 +812,7 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -768,7 +823,7 @@ SET emoji_status_document_id = $1::bigint, emoji_status_until = $2::bigint, updated_at = now() WHERE id = $3::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserEmojiStatusParams struct { @@ -810,6 +865,7 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -836,7 +892,7 @@ UPDATE users SET personal_channel_id = $1::bigint, updated_at = now() WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserPersonalChannelParams struct { @@ -877,6 +933,7 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -886,7 +943,7 @@ UPDATE users SET phone = $1::text, updated_at = now() WHERE id = $2::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserPhoneParams struct { @@ -927,6 +984,60 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, + ) + return i, err +} + +const updateUserPhoneAndSignupEmail = `-- name: UpdateUserPhoneAndSignupEmail :one +UPDATE users +SET phone = $1::text, + signup_email = $2::text, + updated_at = now() +WHERE id = $3::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email +` + +type UpdateUserPhoneAndSignupEmailParams struct { + Phone string + SignupEmail string + ID int64 +} + +func (q *Queries) UpdateUserPhoneAndSignupEmail(ctx context.Context, arg UpdateUserPhoneAndSignupEmailParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserPhoneAndSignupEmail, arg.Phone, arg.SignupEmail, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -938,7 +1049,7 @@ SET first_name = $2, about = $4, updated_at = now() WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserProfileParams struct { @@ -986,6 +1097,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -997,7 +1109,7 @@ SET profile_color_set = $1::boolean, profile_color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserProfileColorParams struct { @@ -1045,6 +1157,7 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } @@ -1054,7 +1167,7 @@ UPDATE users SET username = $2, updated_at = now() WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, signup_email ` type UpdateUserUsernameParams struct { @@ -1095,6 +1208,7 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.SignupEmail, ) return i, err } diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index b5b944cd..9f87a507 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -66,6 +66,24 @@ func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, boo return userFromModel(row), true, nil } +// ByEmail looks up an email-signup account by its signup_email (see +// domain.NewEmailSignupDisplayPhone). Ordinary phone accounts never match +// since signup_email is '' for them and the index excludes empty values. +func (s *UserStore) ByEmail(ctx context.Context, email string) (domain.User, bool, error) { + email = strings.TrimSpace(email) + if email == "" { + return domain.User{}, false, nil + } + row, err := s.q.GetUserBySignupEmail(ctx, email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by signup email: %w", err) + } + return userFromModel(row), true, nil +} + func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) { filtered := make([]string, 0, len(phones)) for _, phone := range phones { @@ -262,6 +280,7 @@ func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, err row, err := qtx.CreateUser(ctx, sqlcgen.CreateUserParams{ AccessHash: u.AccessHash, Phone: u.Phone, + SignupEmail: u.SignupEmail, FirstName: u.FirstName, LastName: u.LastName, Username: u.Username, @@ -448,6 +467,7 @@ func userFromModel(r sqlcgen.User) domain.User { ID: r.ID, AccessHash: r.AccessHash, Phone: r.Phone, + SignupEmail: r.SignupEmail, FirstName: r.FirstName, LastName: r.LastName, About: r.About, diff --git a/internal/store/user.go b/internal/store/user.go index fe2eabfa..bde26518 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -12,6 +12,10 @@ type UserStore interface { ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) + // ByEmail looks up an email-signup account by its signup_email (see + // domain.NewEmailSignupDisplayPhone). Ordinary phone accounts never + // match: signup_email is empty for them. + ByEmail(ctx context.Context, email string) (domain.User, bool, error) ByUsername(ctx context.Context, username string) (domain.User, bool, error) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)