package service import ( "context" "errors" "strings" "github.com/user-management-system/internal/domain" ) func (s *AuthService) SendEmailBindCode(ctx context.Context, userID int64, email string) error { if s == nil || s.userRepo == nil || s.emailCodeSvc == nil { return errors.New("email binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return err } if err := s.ensureUserActive(user); err != nil { return err } normalizedEmail := strings.TrimSpace(email) if normalizedEmail == "" { return errors.New("email is required") } if strings.EqualFold(strings.TrimSpace(domain.DerefStr(user.Email)), normalizedEmail) { return errors.New("email is already bound to the current account") } exists, err := s.userRepo.ExistsByEmail(ctx, normalizedEmail) if err != nil { return err } if exists { return errors.New("email already in use") } return s.emailCodeSvc.SendEmailCode(ctx, normalizedEmail, "bind") } func (s *AuthService) BindEmail( ctx context.Context, userID int64, email string, code string, currentPassword string, totpCode string, ) error { if s == nil || s.userRepo == nil || s.emailCodeSvc == nil { return errors.New("email binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return err } if err := s.ensureUserActive(user); err != nil { return err } normalizedEmail := strings.TrimSpace(email) if normalizedEmail == "" { return errors.New("email is required") } if strings.EqualFold(strings.TrimSpace(domain.DerefStr(user.Email)), normalizedEmail) { return errors.New("email is already bound to the current account") } exists, err := s.userRepo.ExistsByEmail(ctx, normalizedEmail) if err != nil { return err } if exists { return errors.New("email already in use") } if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil { return err } if err := s.emailCodeSvc.VerifyEmailCode(ctx, normalizedEmail, "bind", strings.TrimSpace(code)); err != nil { return err } user.Email = domain.StrPtr(normalizedEmail) if err := s.userRepo.Update(ctx, user); err != nil { return err } s.cacheUserInfo(ctx, user) s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{ "user_id": user.ID, "email": normalizedEmail, "action": "bind_email", }) return nil } func (s *AuthService) UnbindEmail(ctx context.Context, userID int64, currentPassword, totpCode string) error { if s == nil || s.userRepo == nil { return errors.New("email binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return err } if err := s.ensureUserActive(user); err != nil { return err } if strings.TrimSpace(domain.DerefStr(user.Email)) == "" { return errors.New("email is not bound") } if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil { return err } accounts, err := s.GetSocialAccounts(ctx, userID) if err != nil { return err } if s.availableLoginMethodCountAfterContactRemoval(user, accounts, true, false) == 0 { return errors.New("at least one login method must remain after unbinding") } user.Email = nil if err := s.userRepo.Update(ctx, user); err != nil { return err } s.cacheUserInfo(ctx, user) s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{ "user_id": user.ID, "action": "unbind_email", }) return nil } func (s *AuthService) SendPhoneBindCode(ctx context.Context, userID int64, phone string) (*SendCodeResponse, error) { if s == nil || s.userRepo == nil || s.smsCodeSvc == nil { return nil, errors.New("phone binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return nil, err } if err := s.ensureUserActive(user); err != nil { return nil, err } normalizedPhone := strings.TrimSpace(phone) if normalizedPhone == "" { return nil, errors.New("phone is required") } if strings.TrimSpace(domain.DerefStr(user.Phone)) == normalizedPhone { return nil, errors.New("phone is already bound to the current account") } exists, err := s.userRepo.ExistsByPhone(ctx, normalizedPhone) if err != nil { return nil, err } if exists { return nil, errors.New("phone already in use") } return s.smsCodeSvc.SendCode(ctx, &SendCodeRequest{ Phone: normalizedPhone, Purpose: "bind", }) } func (s *AuthService) BindPhone( ctx context.Context, userID int64, phone string, code string, currentPassword string, totpCode string, ) error { if s == nil || s.userRepo == nil || s.smsCodeSvc == nil { return errors.New("phone binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return err } if err := s.ensureUserActive(user); err != nil { return err } normalizedPhone := strings.TrimSpace(phone) if normalizedPhone == "" { return errors.New("phone is required") } if strings.TrimSpace(domain.DerefStr(user.Phone)) == normalizedPhone { return errors.New("phone is already bound to the current account") } exists, err := s.userRepo.ExistsByPhone(ctx, normalizedPhone) if err != nil { return err } if exists { return errors.New("phone already in use") } if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil { return err } if err := s.smsCodeSvc.VerifyCode(ctx, normalizedPhone, "bind", strings.TrimSpace(code)); err != nil { return err } user.Phone = domain.StrPtr(normalizedPhone) if err := s.userRepo.Update(ctx, user); err != nil { return err } s.cacheUserInfo(ctx, user) s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{ "user_id": user.ID, "phone": normalizedPhone, "action": "bind_phone", }) return nil } func (s *AuthService) UnbindPhone(ctx context.Context, userID int64, currentPassword, totpCode string) error { if s == nil || s.userRepo == nil { return errors.New("phone binding is not configured") } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return err } if err := s.ensureUserActive(user); err != nil { return err } if strings.TrimSpace(domain.DerefStr(user.Phone)) == "" { return errors.New("phone is not bound") } if err := s.verifySensitiveAction(ctx, user, currentPassword, totpCode); err != nil { return err } accounts, err := s.GetSocialAccounts(ctx, userID) if err != nil { return err } if s.availableLoginMethodCountAfterContactRemoval(user, accounts, false, true) == 0 { return errors.New("at least one login method must remain after unbinding") } user.Phone = nil if err := s.userRepo.Update(ctx, user); err != nil { return err } s.cacheUserInfo(ctx, user) s.publishEvent(ctx, domain.EventUserUpdated, map[string]interface{}{ "user_id": user.ID, "action": "unbind_phone", }) return nil } func (s *AuthService) availableLoginMethodCountAfterContactRemoval( user *domain.User, accounts []*domain.SocialAccount, removeEmail bool, removePhone bool, ) int { if user == nil { return 0 } count := 0 if strings.TrimSpace(user.Password) != "" { count++ } if !removeEmail && s.emailCodeSvc != nil && strings.TrimSpace(domain.DerefStr(user.Email)) != "" { count++ } if !removePhone && s.smsCodeSvc != nil && strings.TrimSpace(domain.DerefStr(user.Phone)) != "" { count++ } for _, account := range accounts { if account == nil || account.Status != domain.SocialAccountStatusActive { continue } count++ } return count }