fix for phone generation on email signup

This commit is contained in:
onysd 2026-07-14 00:30:52 +03:00
parent cee960fea0
commit 3409f190b9
20 changed files with 457 additions and 54 deletions

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS users_signup_email_lower_unique_idx;
ALTER TABLE public.users DROP COLUMN signup_email;

View file

@ -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 <> '';

View file

@ -75,8 +75,14 @@ func TestEmailSignupChangePhoneRoutesCodeToDecodedEmail(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ChangePhone: %v", err) t.Fatalf("ChangePhone: %v", err)
} }
if result.User.Phone != newPhone { // Rebinding to a new email assigns a fresh short "888" display number
t.Fatalf("result.User.Phone = %q, want %q", result.User.Phone, newPhone) // (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)
} }
} }

View file

@ -81,6 +81,15 @@ func (s *Service) sendChangePhoneCodeByEmail(ctx context.Context, userID int64,
if !ok { if !ok {
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid 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 { if s.loginEmailSender == nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("email signup sender is not configured") 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 { if date == 0 {
date = int(time.Now().Unix()) date = int(time.Now().Unix())
} }
result, err := s.phoneChanges.ChangePhone(ctx, domain.PhoneChangeRequest{ req := domain.PhoneChangeRequest{
UserID: userID, UserID: userID,
Phone: phone, Phone: phone,
Date: date, 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. // echoes updateUserPhone back to the initiating device and suppresses the wrong session.
ExcludeAuthKeyID: originRawAuthKeyID, ExcludeAuthKeyID: originRawAuthKeyID,
ExcludeSessionID: sessionID, 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 { if err != nil {
return domain.PhoneChangeResult{}, err return domain.PhoneChangeResult{}, err
} }
@ -205,6 +238,30 @@ func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID
return u, nil 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) { func phoneChangeHash() (string, error) {
var raw [8]byte var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil { if _, err := rand.Read(raw[:]); err != nil {

View file

@ -47,14 +47,20 @@ func TestEmailSignupSendCodeRoutesFreshSignupToEmail(t *testing.T) {
t.Fatalf("needSignUp = false, want true for a brand-new email-signup account") t.Fatalf("needSignUp = false, want true for a brand-new email-signup account")
} }
// SignUp itself is completely untouched by email-signup: same call, same // SignUp itself is called exactly like the plain-phone path (same phone
// phone (the 888-encoded value), no email-specific parameter anywhere. // 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") created, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "New", "User")
if err != nil { if err != nil {
t.Fatalf("SignUp: %v", err) t.Fatalf("SignUp: %v", err)
} }
if created.Phone != phone { if created.Phone == phone || !domain.ValidPhone(created.Phone) || domain.IsEmailSignupPhone(created.Phone) {
t.Fatalf("created.Phone = %q, want %q", created.Phone, 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 { if err != nil {
t.Fatalf("SignUp: %v (this is the loop bug if it fails with ErrCodeInvalid)", err) t.Fatalf("SignUp: %v (this is the loop bug if it fails with ErrCodeInvalid)", err)
} }
if created.Phone != phone { if created.Phone == phone || !domain.ValidPhone(created.Phone) || domain.IsEmailSignupPhone(created.Phone) {
t.Fatalf("created.Phone = %q, want %q", created.Phone, 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)
} }
} }

View file

@ -298,7 +298,7 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte)
// deliberately skipped the welcome message while password_pending. // deliberately skipped the welcome message while password_pending.
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found { 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 { 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 return nil
@ -345,10 +345,25 @@ func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
return s.createPhoneCode(ctx, phone, issuedUserID) 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) { func (s *Service) currentPhoneOwner(ctx context.Context, phone string) (domain.User, bool, error) {
if s == nil || s.users == nil { if s == nil || s.users == nil {
return domain.User{}, false, fmt.Errorf("user store is not configured") 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) 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; // 2FA accounts only really finish authorizing in CompletePasswordSignIn;
// firing the welcome message here too would notify about an attempt that // firing the welcome message here too would notify about an attempt that
// never actually got past the password check. // never actually got past the password check.
s.recordWelcomeMessage(ctx, existing.ID, existing.Phone) s.recordWelcomeMessage(ctx, existing)
return existing, domain.Message{}, false, nil return existing, domain.Message{}, false, nil
} }
@ -993,6 +1008,23 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
FirstName: firstName, FirstName: firstName,
LastName: lastName, 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 对存量 // 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量
// 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。 // 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。
if s.premiumGrantMonths > 0 { 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 return domain.User{}, domain.Message{}, err
} }
} }
s.recordWelcomeMessage(ctx, u.ID, phone) s.recordWelcomeMessage(ctx, u)
return u, loginMessage, nil 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 // SignIn/SignInWithEmail), regardless of channel. Best-effort: a failure here
// must never fail the sign-in itself, since unlike recordLoginMessage it // must never fail the sign-in itself, since unlike recordLoginMessage it
// carries no secret the caller needs. // 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 { if s == nil || s.messages == nil || s.dialogs == nil {
return 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 { if err != nil {
return return
} }
@ -1341,7 +1373,7 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, userID int64, phone
if err != nil { if err != nil {
return return
} }
_ = s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{ _ = s.dialogs.UpsertInbox(ctx, u.ID, domain.Dialog{
Peer: created.Peer, Peer: created.Peer,
TopMessage: created.ID, TopMessage: created.ID,
TopMessageDate: created.Date, TopMessageDate: created.Date,
@ -1445,6 +1477,28 @@ func normalizePhone(phone string) string {
return domain.NormalizePhone(phone) 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) { func randomHex(n int) (string, error) {
b := make([]byte, n) b := make([]byte, n)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {

View file

@ -1,6 +1,10 @@
package domain package domain
import "strings" import (
"crypto/rand"
"fmt"
"strings"
)
// EmailPhonePrefix marks a "phone number" as a synthetic identity encoding an // EmailPhonePrefix marks a "phone number" as a synthetic identity encoding an
// email address, not a real phone. It reuses Telegram's own +888 "Anonymous // email address, not a real phone. It reuses Telegram's own +888 "Anonymous
@ -135,6 +139,33 @@ func NormalizeEmailForPhone(email string) string {
return strings.ToLower(strings.TrimSpace(email)) 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. // IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone.
// Every encoded value contains at least one letter (the mandatory '@' // Every encoded value contains at least one letter (the mandatory '@'
// escape's 'q' marker byte), which real, all-digit phone numbers — even // escape's 'q' marker byte), which real, all-digit phone numbers — even

View file

@ -1,6 +1,9 @@
package domain package domain
import "testing" import (
"strings"
"testing"
)
func TestEncodeDecodeEmailPhoneRoundTrip(t *testing.T) { func TestEncodeDecodeEmailPhoneRoundTrip(t *testing.T) {
for _, email := range []string{ 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))
}
}

View file

@ -9,6 +9,11 @@ type PhoneChangeRequest struct {
Date int Date int
ExcludeAuthKeyID [8]byte ExcludeAuthKeyID [8]byte
ExcludeSessionID int64 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 { type PhoneChangeResult struct {

View file

@ -25,6 +25,11 @@ type User struct {
ID int64 ID int64
AccessHash int64 AccessHash int64
Phone string 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 FirstName string
LastName string LastName string
About string About string

View file

@ -30,10 +30,12 @@ func OfficialWelcomeMessage(userID int64, method string, date int) (Message, err
} }
// SignInMethodLabel returns the human-readable method name embedded in // SignInMethodLabel returns the human-readable method name embedded in
// OfficialWelcomeMessage, derived from whether phone is an email-signup // OfficialWelcomeMessage. Email-signup accounts are identified by
// synthetic number (see EncodeEmailPhone) or a real phone number. // SignupEmail (see NewEmailSignupDisplayPhone) rather than by their stored
func SignInMethodLabel(phone string) string { // Phone, which — once assigned — is an ordinary-looking short number that
if IsEmailSignupPhone(phone) { // carries no information about the signup method.
func SignInMethodLabel(u User) string {
if u.SignupEmail != "" {
return "email" return "email"
} }
return "phone number" return "phone number"

View file

@ -42,7 +42,11 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
} }
} }
currentPhone := u.Phone currentPhone := u.Phone
currentSignupEmail := u.SignupEmail
u.Phone = req.Phone u.Phone = req.Phone
if req.SignupEmail != "" {
u.SignupEmail = req.SignupEmail
}
s.users.byID[req.UserID] = u s.users.byID[req.UserID] = u
date := req.Date date := req.Date
@ -62,6 +66,7 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
if err != nil { if err != nil {
// 保持内存替身与 PG 的 user+event 原子可见语义。 // 保持内存替身与 PG 的 user+event 原子可见语义。
u.Phone = currentPhone u.Phone = currentPhone
u.SignupEmail = currentSignupEmail
s.users.byID[req.UserID] = u s.users.byID[req.UserID] = u
s.users.mu.Unlock() s.users.mu.Unlock()
return domain.PhoneChangeResult{}, err return domain.PhoneChangeResult{}, err

View file

@ -2,6 +2,7 @@ package memory
import ( import (
"context" "context"
"fmt"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@ -72,6 +73,23 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool,
return domain.User{}, false, nil 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) { func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) {
if len(phones) == 0 { if len(phones) == 0 {
return nil, nil 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 u.ID = s.nextID
s.nextID++ s.nextID++
s.byID[u.ID] = u s.byID[u.ID] = u

View file

@ -64,9 +64,14 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
return domain.PhoneChangeResult{User: userFromModel(row)}, nil 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 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 return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
} }
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View file

@ -10,6 +10,9 @@ ORDER BY id;
-- name: GetUserByPhone :one -- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1; SELECT * FROM users WHERE phone = $1;
-- name: GetUserBySignupEmail :one
SELECT * FROM users WHERE lower(signup_email) = lower($1) AND signup_email <> '';
-- name: GetUsersByPhones :many -- name: GetUsersByPhones :many
SELECT * SELECT *
FROM users FROM users
@ -99,8 +102,8 @@ ORDER BY contact DESC, rank, id
LIMIT sqlc.arg(limit_count); LIMIT sqlc.arg(limit_count);
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *; RETURNING *;
-- name: UpdateUserUsername :one -- name: UpdateUserUsername :one
@ -132,6 +135,14 @@ SET phone = sqlc.arg(phone)::text,
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint
RETURNING *; 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 -- name: SetUserPremiumUntil :one
UPDATE users UPDATE users
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1) 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 { type InsertBotUserParams struct {
@ -209,6 +209,7 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }

View file

@ -1579,6 +1579,7 @@ type User struct {
BirthdayMonth int32 BirthdayMonth int32
BirthdayYear int32 BirthdayYear int32
PersonalChannelID int64 PersonalChannelID int64
SignupEmail string
} }
type UserBusinessProfile struct { type UserBusinessProfile struct {

View file

@ -12,14 +12,15 @@ import (
) )
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) 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) 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 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 { type CreateUserParams struct {
AccessHash int64 AccessHash int64
Phone string Phone string
SignupEmail string
FirstName string FirstName string
LastName string LastName string
Username string Username string
@ -31,6 +32,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
row := q.db.QueryRow(ctx, createUser, row := q.db.QueryRow(ctx, createUser,
arg.AccessHash, arg.AccessHash,
arg.Phone, arg.Phone,
arg.SignupEmail,
arg.FirstName, arg.FirstName,
arg.LastName, arg.LastName,
arg.Username, arg.Username,
@ -68,12 +70,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one 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) { 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.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
const getUserByPhone = `-- name: GetUserByPhone :one 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) { 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.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &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 return i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one 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) { 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.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
const getUsersByIDs = `-- name: GetUsersByIDs :many 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 FROM users
WHERE id = ANY($1::bigint[]) WHERE id = ANY($1::bigint[])
ORDER BY id ORDER BY id
@ -241,6 +289,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -253,7 +302,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
} }
const getUsersByPhones = `-- name: GetUsersByPhones :many 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 FROM users
WHERE phone = ANY($1::text[]) WHERE phone = ANY($1::text[])
ORDER BY id ORDER BY id
@ -298,6 +347,7 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -480,7 +530,7 @@ UPDATE users
SET premium_expires_at = $1::timestamptz, SET premium_expires_at = $1::timestamptz,
updated_at = now() updated_at = now()
WHERE id = $2::bigint 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 { type SetUserPremiumUntilParams struct {
@ -521,6 +571,7 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -530,7 +581,7 @@ UPDATE users
SET verified = $1::boolean, SET verified = $1::boolean,
updated_at = now() updated_at = now()
WHERE id = $2::bigint 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 { type SetUserVerifiedParams struct {
@ -571,6 +622,7 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -586,7 +638,7 @@ WHERE id IN (
ORDER BY premium_expires_at ORDER BY premium_expires_at
LIMIT $2::int 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 { type SweepExpiredPremiumParams struct {
@ -633,6 +685,7 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -651,7 +704,7 @@ SET birthday_day = $1::int,
birthday_year = $3::int, birthday_year = $3::int,
updated_at = now() updated_at = now()
WHERE id = $4::bigint 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 { type UpdateUserBirthdayParams struct {
@ -699,6 +752,7 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -710,7 +764,7 @@ SET color_set = $1::boolean,
color_background_emoji_id = $3::bigint, color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint 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 { type UpdateUserColorParams struct {
@ -758,6 +812,7 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -768,7 +823,7 @@ SET emoji_status_document_id = $1::bigint,
emoji_status_until = $2::bigint, emoji_status_until = $2::bigint,
updated_at = now() updated_at = now()
WHERE id = $3::bigint 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 { type UpdateUserEmojiStatusParams struct {
@ -810,6 +865,7 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -836,7 +892,7 @@ UPDATE users
SET personal_channel_id = $1::bigint, SET personal_channel_id = $1::bigint,
updated_at = now() updated_at = now()
WHERE id = $2::bigint 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 { type UpdateUserPersonalChannelParams struct {
@ -877,6 +933,7 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -886,7 +943,7 @@ UPDATE users
SET phone = $1::text, SET phone = $1::text,
updated_at = now() updated_at = now()
WHERE id = $2::bigint 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 { type UpdateUserPhoneParams struct {
@ -927,6 +984,60 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &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 return i, err
} }
@ -938,7 +1049,7 @@ SET first_name = $2,
about = $4, about = $4,
updated_at = now() updated_at = now()
WHERE id = $1 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 { type UpdateUserProfileParams struct {
@ -986,6 +1097,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -997,7 +1109,7 @@ SET profile_color_set = $1::boolean,
profile_color_background_emoji_id = $3::bigint, profile_color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint 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 { type UpdateUserProfileColorParams struct {
@ -1045,6 +1157,7 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }
@ -1054,7 +1167,7 @@ UPDATE users
SET username = $2, SET username = $2,
updated_at = now() updated_at = now()
WHERE id = $1 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 { type UpdateUserUsernameParams struct {
@ -1095,6 +1208,7 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.SignupEmail,
) )
return i, err return i, err
} }

View file

@ -66,6 +66,24 @@ func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, boo
return userFromModel(row), true, nil 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) { func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) {
filtered := make([]string, 0, len(phones)) filtered := make([]string, 0, len(phones))
for _, phone := range 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{ row, err := qtx.CreateUser(ctx, sqlcgen.CreateUserParams{
AccessHash: u.AccessHash, AccessHash: u.AccessHash,
Phone: u.Phone, Phone: u.Phone,
SignupEmail: u.SignupEmail,
FirstName: u.FirstName, FirstName: u.FirstName,
LastName: u.LastName, LastName: u.LastName,
Username: u.Username, Username: u.Username,
@ -448,6 +467,7 @@ func userFromModel(r sqlcgen.User) domain.User {
ID: r.ID, ID: r.ID,
AccessHash: r.AccessHash, AccessHash: r.AccessHash,
Phone: r.Phone, Phone: r.Phone,
SignupEmail: r.SignupEmail,
FirstName: r.FirstName, FirstName: r.FirstName,
LastName: r.LastName, LastName: r.LastName,
About: r.About, About: r.About,

View file

@ -12,6 +12,10 @@ type UserStore interface {
ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error)
ByPhone(ctx context.Context, phone string) (domain.User, bool, error) ByPhone(ctx context.Context, phone string) (domain.User, bool, error)
ByPhones(ctx context.Context, phones []string) ([]domain.User, 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) ByUsername(ctx context.Context, username string) (domain.User, bool, error)
Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, 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) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)