Files
apocapoc-api/internal/application/commands/register_user.go
T
david 5d92820591 Remove timezone from User model and pass as request parameter
Timezone is now sent from the client on each request that needs it,
instead of storing it in the database. This simplifies the model and
allows timezone to be dynamic (useful for traveling users).

Changes:
- Remove timezone field from User entity
- Remove timezone from user registration
- GET /habits/today now requires ?timezone= query param
- Add migration to drop timezone column from database
- Update related tests
2025-11-29 16:18:04 +01:00

136 lines
3.5 KiB
Go

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"
"apocapoc-api/internal/shared/validation"
)
type RegisterUserCommand struct {
Email string
Password string
}
type RegisterUserResult struct {
UserID string
EmailVerificationRequired bool
}
type RegisterUserHandler struct {
userRepo repositories.UserRepository
passwordHasher services.PasswordHasher
emailService services.EmailService
appURL string
registrationMode string
sendWelcomeEmail bool
}
func NewRegisterUserHandler(
userRepo repositories.UserRepository,
passwordHasher services.PasswordHasher,
emailService services.EmailService,
appURL string,
registrationMode string,
sendWelcomeEmail bool,
) *RegisterUserHandler {
return &RegisterUserHandler{
userRepo: userRepo,
passwordHasher: passwordHasher,
emailService: emailService,
appURL: appURL,
registrationMode: registrationMode,
sendWelcomeEmail: sendWelcomeEmail,
}
}
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) {
if h.registrationMode == "closed" {
return nil, errors.ErrRegistrationClosed
}
if err := validation.ValidateRegistration(cmd.Email, cmd.Password); err != nil {
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
}
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
if existing != nil {
return nil, errors.ErrAlreadyExists
}
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
if err != nil {
return nil, err
}
user := entities.NewUser(cmd.Email, hashedPassword)
emailVerificationRequired := false
if h.emailService != nil {
token, err := h.generateVerificationToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
}
expiry := time.Now().Add(24 * time.Hour)
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
}
if err := h.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return &RegisterUserResult{
UserID: user.ID,
EmailVerificationRequired: emailVerificationRequired,
}, nil
}
func (h *RegisterUserHandler) generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error {
if user.EmailVerificationToken == nil {
return nil
}
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken)
emailBody := fmt.Sprintf(`
<h2>Welcome! Please verify your email</h2>
<p>Thank you for registering. Please click the link below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`, verificationLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Verify your email address",
Body: emailBody,
IsHTML: true,
}
return h.emailService.Send(message)
}