mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 04:01:34 +00:00
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.
187 lines
6.1 KiB
Go
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
|
|
}
|