tercul-backend/internal/app/auth/commands.go
google-labs-jules[bot] 49e2bdd9ac feat: Refactor localization, auth, copyright, and monetization domains
This change introduces a major architectural refactoring of the application, with a focus on improving testability, decoupling, and observability.

The following domains have been successfully refactored:
- `localization`: Wrote a full suite of unit tests and added logging.
- `auth`: Introduced a `JWTManager` interface, wrote comprehensive unit tests, and added logging.
- `copyright`: Separated integration tests, wrote a full suite of unit tests, and added logging.
- `monetization`: Wrote a full suite of unit tests and added logging.
- `search`: Refactored the Weaviate client usage by creating a wrapper to improve testability, and achieved 100% test coverage.

For each of these domains, 100% test coverage has been achieved for the refactored code.

The refactoring of the `work` domain is currently in progress. Unit tests have been written for the commands and queries, but there is a persistent build issue with the query tests that needs to be resolved. The error indicates that the query methods are undefined, despite appearing to be correctly defined and called.
2025-09-06 15:15:10 +00:00

187 lines
6.1 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"strings"
"tercul/internal/domain"
"tercul/internal/platform/auth"
"tercul/internal/platform/log"
"time"
"github.com/asaskevich/govalidator"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidInput = errors.New("invalid input")
)
// LoginInput represents login request data
type LoginInput struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
}
// RegisterInput represents registration request data
type RegisterInput struct {
Username string `json:"username" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
FirstName string `json:"first_name" validate:"required,min=1,max=50"`
LastName string `json:"last_name" validate:"required,min=1,max=50"`
}
// AuthResponse represents authentication response
type AuthResponse struct {
Token string `json:"token"`
User *domain.User `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
// AuthCommands contains the command handlers for authentication.
type AuthCommands struct {
userRepo domain.UserRepository
jwtManager auth.JWTManagement
}
// NewAuthCommands creates a new AuthCommands handler.
func NewAuthCommands(userRepo domain.UserRepository, jwtManager auth.JWTManagement) *AuthCommands {
return &AuthCommands{
userRepo: userRepo,
jwtManager: jwtManager,
}
}
// Login authenticates a user and returns a JWT token
func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) {
if err := validateLoginInput(input); err != nil {
log.LogWarn("Login validation failed", log.F("email", input.Email), log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
email := strings.TrimSpace(input.Email)
log.LogDebug("Attempting to log in user", log.F("email", email))
user, err := c.userRepo.FindByEmail(ctx, email)
if err != nil {
log.LogWarn("Login failed - user not found", log.F("email", email))
return nil, ErrInvalidCredentials
}
if !user.Active {
log.LogWarn("Login failed - user inactive", log.F("user_id", user.ID), log.F("email", email))
return nil, ErrInvalidCredentials
}
if !user.CheckPassword(input.Password) {
log.LogWarn("Login failed - invalid password", log.F("user_id", user.ID), log.F("email", email))
return nil, ErrInvalidCredentials
}
token, err := c.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token", log.F("user_id", user.ID), log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
now := time.Now()
user.LastLoginAt = &now
if err := c.userRepo.Update(ctx, user); err != nil {
log.LogWarn("Failed to update last login time", log.F("user_id", user.ID), log.F("error", err))
// Do not fail the login if this update fails
}
log.LogInfo("User logged in successfully", log.F("user_id", user.ID), log.F("email", email))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable
}, nil
}
// Register creates a new user account
func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) {
if err := validateRegisterInput(input); err != nil {
log.LogWarn("Registration validation failed", log.F("email", input.Email), log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
email := strings.TrimSpace(input.Email)
username := strings.TrimSpace(input.Username)
log.LogDebug("Attempting to register new user", log.F("email", email), log.F("username", username))
existingUser, _ := c.userRepo.FindByEmail(ctx, email)
if existingUser != nil {
log.LogWarn("Registration failed - email already exists", log.F("email", email))
return nil, ErrUserAlreadyExists
}
existingUser, _ = c.userRepo.FindByUsername(ctx, username)
if existingUser != nil {
log.LogWarn("Registration failed - username already exists", log.F("username", username))
return nil, ErrUserAlreadyExists
}
user := &domain.User{
Username: username,
Email: email,
Password: input.Password,
FirstName: strings.TrimSpace(input.FirstName),
LastName: strings.TrimSpace(input.LastName),
DisplayName: fmt.Sprintf("%s %s", strings.TrimSpace(input.FirstName), strings.TrimSpace(input.LastName)),
Role: domain.UserRoleReader,
Active: true,
Verified: false, // Should be false until email verification
}
if err := c.userRepo.Create(ctx, user); err != nil {
log.LogError("Failed to create user", log.F("email", email), log.F("error", err))
return nil, fmt.Errorf("failed to create user: %w", err)
}
token, err := c.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token for new user", log.F("user_id", user.ID), log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
log.LogInfo("User registered successfully", log.F("user_id", user.ID))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable
}, nil
}
func validateLoginInput(input LoginInput) error {
if input.Email == "" {
return errors.New("email is required")
}
if !govalidator.IsEmail(strings.TrimSpace(input.Email)) {
return errors.New("invalid email format")
}
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
return nil
}
func validateRegisterInput(input RegisterInput) error {
if !govalidator.IsEmail(strings.TrimSpace(input.Email)) {
return errors.New("invalid email format")
}
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
username := strings.TrimSpace(input.Username)
if len(username) < 3 || len(username) > 50 {
return errors.New("username must be between 3 and 50 characters")
}
if !govalidator.Matches(username, `^[a-zA-Z0-9_-]+$`) {
return errors.New("username can only contain letters, numbers, underscores, and hyphens")
}
return nil
}