Improve email verification flow and error handling
- Send verification email before creating user to prevent orphaned accounts - Detect SMTP authentication errors and fail fast without retries - Add field-level validation errors for better frontend UX - Configure docker-compose with explicit environment variables - Document dollar sign escaping in .env.example (use $$) - Implement welcome email on successful verification - Clean up unnecessary comments
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type DeleteUserCommand struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
type DeleteUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewDeleteUserHandler(userRepo repositories.UserRepository) *DeleteUserHandler {
|
||||
return &DeleteUserHandler{
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeleteUserHandler) Handle(ctx context.Context, cmd DeleteUserCommand) error {
|
||||
if cmd.UserID == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(ctx, cmd.UserID)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if err := h.userRepo.Delete(ctx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type RegisterUserHandler struct {
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
registrationMode string
|
||||
sendWelcomeEmail bool
|
||||
}
|
||||
|
||||
func NewRegisterUserHandler(
|
||||
@@ -39,6 +40,7 @@ func NewRegisterUserHandler(
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
registrationMode string,
|
||||
sendWelcomeEmail bool,
|
||||
) *RegisterUserHandler {
|
||||
return &RegisterUserHandler{
|
||||
userRepo: userRepo,
|
||||
@@ -46,6 +48,7 @@ func NewRegisterUserHandler(
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
registrationMode: registrationMode,
|
||||
sendWelcomeEmail: sendWelcomeEmail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +58,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
}
|
||||
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
|
||||
return nil, errors.ErrInvalidInput
|
||||
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
@@ -81,6 +84,10 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
emailVerificationRequired = true
|
||||
|
||||
if err := h.sendVerificationEmail(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
} else {
|
||||
user.EmailVerified = true
|
||||
}
|
||||
@@ -89,15 +96,6 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.emailService != nil && user.EmailVerificationToken != nil {
|
||||
if err := h.sendVerificationEmail(user); err != nil {
|
||||
return &RegisterUserResult{
|
||||
UserID: user.ID,
|
||||
EmailVerificationRequired: emailVerificationRequired,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &RegisterUserResult{
|
||||
UserID: user.ID,
|
||||
EmailVerificationRequired: emailVerificationRequired,
|
||||
|
||||
@@ -40,6 +40,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPasswordHasher struct {
|
||||
hashFunc func(password string) (string, error)
|
||||
}
|
||||
@@ -65,7 +69,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -102,7 +106,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -125,7 +129,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -135,7 +139,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -160,7 +164,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -170,7 +174,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -191,7 +195,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -206,7 +210,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -215,7 +219,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrAlreadyExists {
|
||||
if !errors.Is(err, appErrors.ErrAlreadyExists) {
|
||||
t.Errorf("expected ErrAlreadyExists, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -228,7 +232,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
return "", expectedErr
|
||||
},
|
||||
}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -250,7 +254,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -267,7 +271,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -333,7 +337,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -342,7 +346,7 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrRegistrationClosed {
|
||||
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
|
||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type RequestPasswordResetCommand struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
type RequestPasswordResetHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewRequestPasswordResetHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
) *RequestPasswordResetHandler {
|
||||
return &RequestPasswordResetHandler{
|
||||
userRepo: userRepo,
|
||||
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RequestPasswordResetHandler) Handle(ctx context.Context, cmd RequestPasswordResetCommand) error {
|
||||
if cmd.Email == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return errors.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
tokenStr, err := generatePasswordResetToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
resetToken := entities.NewPasswordResetToken(user.ID, tokenStr, expiresAt)
|
||||
|
||||
if err := h.passwordResetTokenRepo.Create(ctx, resetToken); err != nil {
|
||||
return fmt.Errorf("failed to save reset token: %w", err)
|
||||
}
|
||||
|
||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", h.appURL, tokenStr)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested to reset your password. Click the link below to reset it:</p>
|
||||
<p><a href="%s">Reset Password</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`, resetLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Password Reset Request",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
if err := h.emailService.Send(message); err != nil {
|
||||
return fmt.Errorf("failed to send reset email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatePasswordResetToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/validation"
|
||||
)
|
||||
|
||||
type ResetPasswordCommand struct {
|
||||
Token string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type ResetPasswordHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
}
|
||||
|
||||
func NewResetPasswordHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||
passwordHasher services.PasswordHasher,
|
||||
) *ResetPasswordHandler {
|
||||
return &ResetPasswordHandler{
|
||||
userRepo: userRepo,
|
||||
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd ResetPasswordCommand) error {
|
||||
if cmd.Token == "" || cmd.NewPassword == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(cmd.NewPassword); err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
resetToken, err := h.passwordResetTokenRepo.FindByToken(ctx, cmd.Token)
|
||||
if err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if resetToken.IsExpired() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if resetToken.IsUsed() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(ctx, resetToken.UserID)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
hashedPassword, err := h.passwordHasher.Hash(cmd.NewPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user.PasswordHash = hashedPassword
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
resetToken.MarkAsUsed()
|
||||
if err := h.passwordResetTokenRepo.Update(ctx, resetToken); err != nil {
|
||||
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
@@ -14,12 +16,20 @@ type VerifyEmailCommand struct {
|
||||
}
|
||||
|
||||
type VerifyEmailHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
userRepo repositories.UserRepository
|
||||
emailService services.EmailService
|
||||
sendWelcomeEmail bool
|
||||
}
|
||||
|
||||
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
|
||||
func NewVerifyEmailHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
emailService services.EmailService,
|
||||
sendWelcomeEmail bool,
|
||||
) *VerifyEmailHandler {
|
||||
return &VerifyEmailHandler{
|
||||
userRepo: userRepo,
|
||||
userRepo: userRepo,
|
||||
emailService: emailService,
|
||||
sendWelcomeEmail: sendWelcomeEmail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +60,27 @@ func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand)
|
||||
return fmt.Errorf("failed to verify email: %w", err)
|
||||
}
|
||||
|
||||
if h.sendWelcomeEmail && h.emailService != nil {
|
||||
h.sendWelcomeEmailToUser(user)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error {
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Welcome to Apocapoc!</h2>
|
||||
<p>Your email has been successfully verified.</p>
|
||||
<p>You can now start tracking your habits and building better routines.</p>
|
||||
<p>If you have any questions or need help, please don't hesitate to contact us.</p>
|
||||
`)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Welcome to Apocapoc!",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return h.emailService.Send(message)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entitie
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRefreshTokenHandler_Success(t *testing.T) {
|
||||
refreshTokenRepo := &mockRefreshTokenRepository{
|
||||
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
||||
|
||||
Reference in New Issue
Block a user