Files
rideaware-api/internal/user/service.go
2026-05-17 20:39:47 -05:00

183 lines
4.0 KiB
Go

package user
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"regexp"
"time"
"rideaware/internal/config"
"rideaware/internal/email"
"rideaware/pkg/database"
)
type Service struct {
repo *Repository
email *email.Service
}
func NewService() *Service {
return &Service{
repo: NewRepository(),
email: email.NewService(),
}
}
func (s *Service) CreateUser(username, password, email, firstName, lastName string) (*User, error) {
if username == "" || password == "" || email == "" {
return nil, errors.New("username, password, and email are required")
}
// Username: 3-30 chars, alphanumeric + underscores/hyphens, must start with a letter
if !isValidUsername(username) {
return nil, errors.New("username must be 3-30 characters, start with a letter, and contain only letters, numbers, underscores, or hyphens")
}
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
exists, err := s.repo.UserExists(username, email)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("username or email already exists")
}
user := &User{
Username: username,
Email: email,
}
if err := user.SetPassword(password); err != nil {
return nil, err
}
if err := s.repo.CreateUser(user); err != nil {
return nil, err
}
_ = s.email.SendWelcomeEmail(email, username)
return user, nil
}
func (s *Service) VerifyUser(username, password string) (*User, error) {
user, err := s.repo.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid username or password")
}
if !user.CheckPassword(password) {
return nil, errors.New("invalid username or password")
}
return user, nil
}
func (s *Service) RequestPasswordReset(email string) error {
user, err := s.repo.GetUserByEmail(email)
if err != nil {
return nil
}
token, err := generateSecureToken(32)
if err != nil {
return err
}
resetToken := &PasswordReset{
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(config.JWT.ResetTokenDuration),
}
if err := database.DB.Create(resetToken).Error; err != nil {
return err
}
appURL := os.Getenv("APP_URL")
if appURL == "" {
appURL = "https://dev.rideaware.org"
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", appURL, token)
return s.email.SendPasswordResetEmail(user.Email, user.Username, resetLink)
}
func (s *Service) ResetPassword(token, newPassword string) error {
if len(newPassword) < 8 {
return errors.New("password must be at least 8 characters long")
}
var resetToken PasswordReset
if err := database.DB.Where("token = ?", token).First(&resetToken).Error; err != nil {
return errors.New("invalid or expired reset token")
}
if !resetToken.IsValid() {
return errors.New("reset token has expired")
}
user, err := s.repo.GetUserByID(resetToken.UserID)
if err != nil {
return err
}
if err := user.SetPassword(newPassword); err != nil {
return err
}
now := time.Now()
tx := database.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Model(user).Update("password", user.Password).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Model(&resetToken).Update("used_at", now).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
func (s *Service) GetUserByID(userID uint) (*User, error) {
return s.repo.GetUserByID(userID)
}
func (s *Service) UpdateUser(user *User) error {
return s.repo.UpdateUser(user)
}
func isValidUsername(username string) bool {
if len(username) < 3 || len(username) > 30 {
return false
}
regex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{2,29}$`)
return regex.MatchString(username)
}
func isValidEmail(email string) bool {
regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return regex.MatchString(email)
}
func generateSecureToken(length int) (string, error) {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}