setup user/auth repository and service

This commit is contained in:
juancwu 2025-12-12 11:54:20 -05:00
commit 0ae9cd7133
7 changed files with 249 additions and 4 deletions

View file

@ -5,12 +5,16 @@ import (
"git.juancwu.dev/juancwu/budgething/internal/config"
"git.juancwu.dev/juancwu/budgething/internal/db"
"git.juancwu.dev/juancwu/budgething/internal/repository"
"git.juancwu.dev/juancwu/budgething/internal/service"
"github.com/jmoiron/sqlx"
)
type App struct {
Cfg *config.Config
DB *sqlx.DB
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
}
func New(cfg *config.Config) (*App, error) {
@ -24,9 +28,16 @@ func New(cfg *config.Config) (*App, error) {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
userRepository := repository.NewUserRepository(database)
userService := service.NewUserService(userRepository)
authService := service.NewAuthService(userRepository)
return &App{
Cfg: cfg,
DB: database,
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
}, nil
}

View file

@ -0,0 +1,15 @@
package exception
import "fmt"
type Exception struct {
Operation string
}
func New(operation string) *Exception {
return &Exception{Operation: operation}
}
func (e *Exception) WithError(err error) error {
return fmt.Errorf("%s: %w", e.Operation, err)
}

View file

@ -0,0 +1,98 @@
package repository
import (
"database/sql"
"errors"
"strings"
"git.juancwu.dev/juancwu/budgething/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("email already exists")
)
type UserRepository interface {
Create(user *model.User) error
ByID(id string) (*model.User, error)
ByEmail(email string) (*model.User, error)
Update(user *model.User) error
Delete(id string) error
}
type userRepository struct {
db *sqlx.DB
}
func NewUserRepository(db *sqlx.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *model.User) error {
query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateEmail
}
return err
}
return nil
}
func (r *userRepository) ByID(id string) (*model.User, error) {
user := &model.User{}
query := `SELECT * FROM users WHERE id = $1;`
err := r.db.Get(user, query, id)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return user, err
}
func (r *userRepository) ByEmail(email string) (*model.User, error) {
user := &model.User{}
query := `SELECT * FROM users WHERE email = $1;`
err := r.db.Get(user, query, email)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return user, err
}
func (r *userRepository) Update(user *model.User) error {
query := `UPDATE users SET email = $1, password_hash = $2, pending_email = $3, email_verified_at = $4 WHERE id = $5;`
_, err := r.db.Exec(query, user.Email, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.ID)
return err
}
func (r *userRepository) Delete(id string) error {
query := `DELETE FROM users WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}

69
internal/service/auth.go Normal file
View file

@ -0,0 +1,69 @@
package service
import (
"errors"
"strings"
"git.juancwu.dev/juancwu/budgething/internal/exception"
"git.juancwu.dev/juancwu/budgething/internal/model"
"git.juancwu.dev/juancwu/budgething/internal/repository"
"github.com/alexedwards/argon2id"
)
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrNoPassword = errors.New("account uses passwordless login. Use magic link")
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
)
type AuthService struct {
userRepository repository.UserRepository
}
func NewAuthService(userRepository repository.UserRepository) *AuthService {
return &AuthService{
userRepository: userRepository,
}
}
func (s *AuthService) LoginWithPassword(email, password string) (*model.User, error) {
e := exception.New("AuthService.LoginWithPassword")
email = strings.TrimSpace(strings.ToLower(email))
user, err := s.userRepository.ByEmail(email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, e.WithError(ErrInvalidCredentials)
}
return nil, e.WithError(err)
}
if !user.HasPassword() {
return nil, e.WithError(ErrNoPassword)
}
return user, nil
}
func (s *AuthService) HashPassword(password string) (string, error) {
e := exception.New("AuthService.HashPassword")
hashed, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
return "", e.WithError(err)
}
return hashed, nil
}
func (s *AuthService) ComparePassword(password, hash string) error {
e := exception.New("AuthService.ComparePassword")
match, err := argon2id.ComparePasswordAndHash(password, hash)
if err != nil {
return e.WithError(err)
}
if !match {
return e.WithError(ErrPasswordsDoNotMatch)
}
return nil
}

25
internal/service/user.go Normal file
View file

@ -0,0 +1,25 @@
package service
import (
"git.juancwu.dev/juancwu/budgething/internal/model"
"git.juancwu.dev/juancwu/budgething/internal/repository"
)
type UserService struct {
userRepository repository.UserRepository
}
func NewUserService(userRepository repository.UserRepository) *UserService {
return &UserService{
userRepository: userRepository,
}
}
func (s *UserService) ByID(id string) (*model.User, error) {
user, err := s.userRepository.ByID(id)
if err != nil {
return nil, err
}
return user, nil
}