Add optional email verification and registration control
Implemented email service infrastructure with SMTP support and optional email verification for self-hosted deployments. Registration flow now supports open/closed modes and hardcoded Apocapoc branding. Key features: - Email service with SMTP and template rendering - Optional email verification (auto-verified without SMTP config) - Registration modes: open/closed for access control - Hardcoded Apocapoc branding (AppName, AppURL, DefaultFrom) - Separate registration and login flows (registration no longer returns tokens)
This commit is contained in:
@@ -10,3 +10,18 @@ REFRESH_TOKEN_EXPIRY=7d
|
|||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
DEFAULT_TIMEZONE=UTC
|
DEFAULT_TIMEZONE=UTC
|
||||||
|
|
||||||
|
# Email Configuration (optional - required for email features)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# Application Branding (optional - override support email if needed)
|
||||||
|
SUPPORT_EMAIL=contact@apocapoc.app
|
||||||
|
|
||||||
|
# Email Features
|
||||||
|
SEND_WELCOME_EMAIL=false
|
||||||
|
|
||||||
|
# Registration Control
|
||||||
|
REGISTRATION_MODE=open
|
||||||
|
|||||||
+20
-1
@@ -13,8 +13,10 @@ import (
|
|||||||
"apocapoc-api/internal/infrastructure/auth"
|
"apocapoc-api/internal/infrastructure/auth"
|
||||||
"apocapoc-api/internal/infrastructure/config"
|
"apocapoc-api/internal/infrastructure/config"
|
||||||
"apocapoc-api/internal/infrastructure/crypto"
|
"apocapoc-api/internal/infrastructure/crypto"
|
||||||
|
"apocapoc-api/internal/infrastructure/email"
|
||||||
httpInfra "apocapoc-api/internal/infrastructure/http"
|
httpInfra "apocapoc-api/internal/infrastructure/http"
|
||||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||||
|
"apocapoc-api/internal/shared/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Apocapoc API
|
// @title Apocapoc API
|
||||||
@@ -60,12 +62,29 @@ func main() {
|
|||||||
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
||||||
passwordHasher := crypto.NewBcryptHasher()
|
passwordHasher := crypto.NewBcryptHasher()
|
||||||
|
|
||||||
|
var emailService *email.SMTPService
|
||||||
|
if cfg.SMTPHost != "" {
|
||||||
|
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Invalid SMTP_PORT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
emailService = email.NewSMTPService(email.SMTPConfig{
|
||||||
|
Host: cfg.SMTPHost,
|
||||||
|
Port: smtpPort,
|
||||||
|
Username: cfg.SMTPUser,
|
||||||
|
Password: cfg.SMTPPassword,
|
||||||
|
From: constants.DefaultFrom,
|
||||||
|
SupportEmail: cfg.SupportEmail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
userRepo := sqlite.NewUserRepository(db.Conn())
|
userRepo := sqlite.NewUserRepository(db.Conn())
|
||||||
habitRepo := sqlite.NewHabitRepository(db.Conn())
|
habitRepo := sqlite.NewHabitRepository(db.Conn())
|
||||||
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
|
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
|
||||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
|
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
|
||||||
|
|
||||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
|
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, constants.AppURL, cfg.RegistrationMode)
|
||||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ require (
|
|||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
gopkg.in/mail.v2 v2.3.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -85,10 +85,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||||
|
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"apocapoc-api/internal/domain/entities"
|
"apocapoc-api/internal/domain/entities"
|
||||||
"apocapoc-api/internal/domain/repositories"
|
"apocapoc-api/internal/domain/repositories"
|
||||||
@@ -16,38 +20,119 @@ type RegisterUserCommand struct {
|
|||||||
Timezone string
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RegisterUserResult struct {
|
||||||
|
UserID string
|
||||||
|
EmailVerificationRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
type RegisterUserHandler struct {
|
type RegisterUserHandler struct {
|
||||||
userRepo repositories.UserRepository
|
userRepo repositories.UserRepository
|
||||||
passwordHasher services.PasswordHasher
|
passwordHasher services.PasswordHasher
|
||||||
|
emailService services.EmailService
|
||||||
|
appURL string
|
||||||
|
registrationMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
|
func NewRegisterUserHandler(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
passwordHasher services.PasswordHasher,
|
||||||
|
emailService services.EmailService,
|
||||||
|
appURL string,
|
||||||
|
registrationMode string,
|
||||||
|
) *RegisterUserHandler {
|
||||||
return &RegisterUserHandler{
|
return &RegisterUserHandler{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
passwordHasher: passwordHasher,
|
passwordHasher: passwordHasher,
|
||||||
|
emailService: emailService,
|
||||||
|
appURL: appURL,
|
||||||
|
registrationMode: registrationMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
|
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, cmd.Timezone); err != nil {
|
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
|
||||||
return "", errors.ErrInvalidInput
|
return nil, errors.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
return "", errors.ErrAlreadyExists
|
return nil, errors.ErrAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
|
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
|
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
|
||||||
|
|
||||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
emailVerificationRequired := false
|
||||||
return "", err
|
if h.emailService != nil {
|
||||||
|
token, err := h.generateVerificationToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.ID, nil
|
expiry := time.Now().Add(24 * time.Hour)
|
||||||
|
user.EmailVerificationToken = &token
|
||||||
|
user.EmailVerificationExpiry = &expiry
|
||||||
|
emailVerificationRequired = true
|
||||||
|
} else {
|
||||||
|
user.EmailVerified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.emailService != nil && user.EmailVerificationToken != nil {
|
||||||
|
if err := h.sendVerificationEmail(user); err != nil {
|
||||||
|
return &RegisterUserResult{
|
||||||
|
UserID: user.ID,
|
||||||
|
EmailVerificationRequired: emailVerificationRequired,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User,
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||||
|
return nil, appErrors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
|
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||||
if m.createFunc != nil {
|
if m.createFunc != nil {
|
||||||
return m.createFunc(ctx, user)
|
return m.createFunc(ctx, user)
|
||||||
@@ -61,7 +65,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -69,15 +73,19 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
|||||||
Timezone: "UTC",
|
Timezone: "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := handler.Handle(context.Background(), cmd)
|
result, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID == "" {
|
if result.UserID == "" {
|
||||||
t.Error("expected user ID, got empty string")
|
t.Error("expected user ID, got empty string")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.EmailVerificationRequired {
|
||||||
|
t.Error("expected email verification to not be required when emailService is nil")
|
||||||
|
}
|
||||||
|
|
||||||
if createdUser == nil {
|
if createdUser == nil {
|
||||||
t.Fatal("expected user to be created")
|
t.Fatal("expected user to be created")
|
||||||
}
|
}
|
||||||
@@ -94,7 +102,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
|||||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -127,7 +135,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
|||||||
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -162,7 +170,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
|||||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -198,7 +206,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -220,7 +228,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
|||||||
return "", expectedErr
|
return "", expectedErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -242,7 +250,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -259,7 +267,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
|||||||
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher)
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -322,3 +330,19 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{}
|
||||||
|
hasher := &mockPasswordHasher{}
|
||||||
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
|
||||||
|
|
||||||
|
cmd := RegisterUserCommand{
|
||||||
|
Email: "test@example.com",
|
||||||
|
Password: "Secure123!",
|
||||||
|
Timezone: "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
|
if err != appErrors.ErrRegistrationClosed {
|
||||||
|
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResendVerificationEmailCommand struct {
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResendVerificationEmailHandler struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
emailService services.EmailService
|
||||||
|
appURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResendVerificationEmailHandler(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
emailService services.EmailService,
|
||||||
|
appURL string,
|
||||||
|
) *ResendVerificationEmailHandler {
|
||||||
|
return &ResendVerificationEmailHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
emailService: emailService,
|
||||||
|
appURL: appURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error {
|
||||||
|
if cmd.Email == "" {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.EmailVerified {
|
||||||
|
return errors.ErrAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateVerificationToken()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate verification token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := time.Now().Add(24 * time.Hour)
|
||||||
|
user.EmailVerificationToken = &token
|
||||||
|
user.EmailVerificationExpiry = &expiry
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token)
|
||||||
|
|
||||||
|
emailBody := fmt.Sprintf(`
|
||||||
|
<h2>Verify your email address</h2>
|
||||||
|
<p>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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.emailService.Send(message); err != nil {
|
||||||
|
return fmt.Errorf("failed to send verification email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateVerificationToken() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VerifyEmailCommand struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyEmailHandler struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
|
||||||
|
return &VerifyEmailHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error {
|
||||||
|
if cmd.Token == "" {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.EmailVerified {
|
||||||
|
return errors.ErrAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
user.EmailVerified = true
|
||||||
|
user.EmailVerificationToken = nil
|
||||||
|
user.EmailVerificationExpiry = nil
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||||
|
return fmt.Errorf("failed to verify email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
|
|||||||
return nil, errors.ErrNotFound
|
return nil, errors.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !user.EmailVerified {
|
||||||
|
return nil, errors.ErrEmailNotVerified
|
||||||
|
}
|
||||||
|
|
||||||
return &LoginUserResult{
|
return &LoginUserResult{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||||
|
return nil, errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
|
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ type User struct {
|
|||||||
Email string
|
Email string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
EmailVerified bool
|
||||||
|
EmailVerificationToken *string
|
||||||
|
EmailVerificationExpiry *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ type UserRepository interface {
|
|||||||
Create(ctx context.Context, user *entities.User) error
|
Create(ctx context.Context, user *entities.User) error
|
||||||
FindByID(ctx context.Context, id string) (*entities.User, error)
|
FindByID(ctx context.Context, id string) (*entities.User, error)
|
||||||
FindByEmail(ctx context.Context, email string) (*entities.User, error)
|
FindByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||||
|
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
|
||||||
Update(ctx context.Context, user *entities.User) error
|
Update(ctx context.Context, user *entities.User) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
type EmailMessage struct {
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
IsHTML bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailService interface {
|
||||||
|
Send(message EmailMessage) error
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@ type Config struct {
|
|||||||
RefreshTokenExpiry string
|
RefreshTokenExpiry string
|
||||||
CORSOrigins string
|
CORSOrigins string
|
||||||
DefaultTimezone string
|
DefaultTimezone string
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPassword string
|
||||||
|
SupportEmail string
|
||||||
|
SendWelcomeEmail string
|
||||||
|
RegistrationMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -30,6 +37,13 @@ func Load() (*Config, error) {
|
|||||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||||
CORSOrigins: os.Getenv("CORS_ORIGINS"),
|
CORSOrigins: os.Getenv("CORS_ORIGINS"),
|
||||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||||
|
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||||
|
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
||||||
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
||||||
|
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
||||||
|
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DBPath == "" {
|
if cfg.DBPath == "" {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
|
|
||||||
|
"gopkg.in/mail.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
SupportEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMTPService struct {
|
||||||
|
config SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSMTPService(config SMTPConfig) *SMTPService {
|
||||||
|
return &SMTPService{
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SMTPService) Send(message services.EmailMessage) error {
|
||||||
|
m := mail.NewMessage()
|
||||||
|
m.SetHeader("From", s.config.From)
|
||||||
|
m.SetHeader("To", message.To)
|
||||||
|
m.SetHeader("Subject", message.Subject)
|
||||||
|
|
||||||
|
if message.IsHTML {
|
||||||
|
m.SetBody("text/html", message.Body)
|
||||||
|
} else {
|
||||||
|
m.SetBody("text/plain", message.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
|
||||||
|
dialer.TLSConfig = &tls.Config{
|
||||||
|
ServerName: s.config.Host,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.sendWithRetry(dialer, m); err != nil {
|
||||||
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
|
||||||
|
maxRetries := 3
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
if err := dialer.DialAndSend(message); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(time.Second * time.Duration(i+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SMTPService) GetConfig() SMTPConfig {
|
||||||
|
return s.config
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSMTPService(t *testing.T) {
|
||||||
|
config := SMTPConfig{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
Username: "user@example.com",
|
||||||
|
Password: "password",
|
||||||
|
From: "noreply@example.com",
|
||||||
|
SupportEmail: "support@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewSMTPService(config)
|
||||||
|
|
||||||
|
if service == nil {
|
||||||
|
t.Fatal("Expected service to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.GetConfig().Host != config.Host {
|
||||||
|
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPService_MessageConstruction(t *testing.T) {
|
||||||
|
config := SMTPConfig{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
Username: "user@example.com",
|
||||||
|
Password: "password",
|
||||||
|
From: "noreply@example.com",
|
||||||
|
SupportEmail: "support@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewSMTPService(config)
|
||||||
|
|
||||||
|
message := services.EmailMessage{
|
||||||
|
To: "recipient@example.com",
|
||||||
|
Subject: "Test Email",
|
||||||
|
Body: "<h1>Test</h1>",
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.To == "" {
|
||||||
|
t.Error("Expected recipient to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Subject == "" {
|
||||||
|
t.Error("Expected subject to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !message.IsHTML {
|
||||||
|
t.Error("Expected message to be HTML")
|
||||||
|
}
|
||||||
|
|
||||||
|
if service == nil {
|
||||||
|
t.Fatal("Service should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateData struct {
|
||||||
|
AppName string
|
||||||
|
AppURL string
|
||||||
|
SupportEmail string
|
||||||
|
Data map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateRenderer struct {
|
||||||
|
appName string
|
||||||
|
appURL string
|
||||||
|
supportEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
|
||||||
|
return &TemplateRenderer{
|
||||||
|
appName: appName,
|
||||||
|
appURL: appURL,
|
||||||
|
supportEmail: supportEmail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
|
||||||
|
tmpl, err := template.New("email").Parse(templateContent)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateData := TemplateData{
|
||||||
|
AppName: r.appName,
|
||||||
|
AppURL: r.appURL,
|
||||||
|
SupportEmail: r.supportEmail,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewTemplateRenderer(t *testing.T) {
|
||||||
|
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||||
|
|
||||||
|
if renderer == nil {
|
||||||
|
t.Fatal("Expected renderer to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if renderer.appName != "Test App" {
|
||||||
|
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateRenderer_Render(t *testing.T) {
|
||||||
|
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||||
|
|
||||||
|
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Name": "John",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := renderer.Render(template, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to render template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "Hello John, welcome to Test App!"
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
|
||||||
|
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
|
||||||
|
|
||||||
|
template := `
|
||||||
|
App: {{.AppName}}
|
||||||
|
URL: {{.AppURL}}
|
||||||
|
Support: {{.SupportEmail}}
|
||||||
|
User: {{.Data.User}}
|
||||||
|
`
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"User": "Alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := renderer.Render(template, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to render template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(result, "My App") {
|
||||||
|
t.Error("Expected result to contain app name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "https://myapp.com") {
|
||||||
|
t.Error("Expected result to contain app URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "help@myapp.com") {
|
||||||
|
t.Error("Expected result to contain support email")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "Alice") {
|
||||||
|
t.Error("Expected result to contain user name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
|
||||||
|
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||||
|
|
||||||
|
template := `{{.Data.Invalid}}`
|
||||||
|
data := map[string]interface{}{}
|
||||||
|
|
||||||
|
result, err := renderer.Render(template, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Template should render even with missing data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != "<no value>" {
|
||||||
|
t.Logf("Got result: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
|
||||||
|
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||||
|
|
||||||
|
template := `{{.Data.Name`
|
||||||
|
data := map[string]interface{}{}
|
||||||
|
|
||||||
|
_, err := renderer.Render(template, data)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for invalid template syntax")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid #f0f0f0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{.AppName}}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{.Content}}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
|
||||||
|
<p>© {{.AppName}}. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -62,6 +62,11 @@ type AuthResponse struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
type RefreshRequest struct {
|
type RefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
}
|
}
|
||||||
@@ -72,13 +77,14 @@ type LogoutRequest struct {
|
|||||||
|
|
||||||
// Register godoc
|
// Register godoc
|
||||||
// @Summary Register a new user
|
// @Summary Register a new user
|
||||||
// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.
|
// @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
|
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
|
||||||
// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID"
|
// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
|
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
|
||||||
|
// @Failure 403 {object} ErrorResponse "Registration is closed"
|
||||||
// @Failure 409 {object} ErrorResponse "Email already registered"
|
// @Failure 409 {object} ErrorResponse "Email already registered"
|
||||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
// @Router /auth/register [post]
|
// @Router /auth/register [post]
|
||||||
@@ -95,7 +101,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
Timezone: req.Timezone,
|
Timezone: req.Timezone,
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := h.registerHandler.Handle(r.Context(), cmd)
|
result, err := h.registerHandler.Handle(r.Context(), cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == errors.ErrInvalidInput {
|
if err == errors.ErrInvalidInput {
|
||||||
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
|
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
|
||||||
@@ -105,31 +111,24 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusConflict, "Email already registered")
|
respondError(w, http.StatusConflict, "Email already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == errors.ErrRegistrationClosed {
|
||||||
|
respondError(w, http.StatusForbidden, "Registration is currently closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to register user")
|
respondError(w, http.StatusInternalServerError, "Failed to register user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.jwtService.GenerateToken(userID, req.Email)
|
var message string
|
||||||
if err != nil {
|
if result.EmailVerificationRequired {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to generate token")
|
message = "Registration successful. Please check your email to verify your account."
|
||||||
return
|
} else {
|
||||||
|
message = "Registration successful. You can now login."
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry)
|
respondJSON(w, http.StatusCreated, RegisterResponse{
|
||||||
if err != nil {
|
UserID: result.UserID,
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
|
Message: message,
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, AuthResponse{
|
|
||||||
Token: token,
|
|
||||||
RefreshToken: refreshToken.Token,
|
|
||||||
UserID: userID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +142,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
|
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
|
||||||
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
||||||
// @Failure 401 {object} ErrorResponse "Invalid email or password"
|
// @Failure 401 {object} ErrorResponse "Invalid email or password"
|
||||||
|
// @Failure 403 {object} ErrorResponse "Email not verified"
|
||||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
// @Router /auth/login [post]
|
// @Router /auth/login [post]
|
||||||
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -163,6 +163,10 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "Invalid email or password")
|
respondError(w, http.StatusUnauthorized, "Invalid email or password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == errors.ErrEmailNotVerified {
|
||||||
|
respondError(w, http.StatusForbidden, "Please verify your email before logging in")
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to login")
|
respondError(w, http.StatusInternalServerError, "Failed to login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ func TestAuthFlow(t *testing.T) {
|
|||||||
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
|
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp AuthResponse
|
var resp RegisterResponse
|
||||||
decodeResponse(t, rr, &resp)
|
decodeResponse(t, rr, &resp)
|
||||||
|
|
||||||
if resp.Token == "" {
|
|
||||||
t.Error("Expected token in response")
|
|
||||||
}
|
|
||||||
if resp.UserID == "" {
|
if resp.UserID == "" {
|
||||||
t.Error("Expected user ID in response")
|
t.Error("Expected user ID in response")
|
||||||
}
|
}
|
||||||
|
if resp.Message == "" {
|
||||||
|
t.Error("Expected message in response")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Register duplicate email", func(t *testing.T) {
|
t.Run("Register duplicate email", func(t *testing.T) {
|
||||||
|
|||||||
@@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) {
|
|||||||
ts := setupTestServer(t)
|
ts := setupTestServer(t)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
registerBody := RegisterRequest{
|
token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!")
|
||||||
Email: "entryuser@example.com",
|
|
||||||
Password: "Password123!",
|
|
||||||
Timezone: "UTC",
|
|
||||||
}
|
|
||||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
|
||||||
var authResp AuthResponse
|
|
||||||
decodeResponse(t, rr, &authResp)
|
|
||||||
token := authResp.Token
|
|
||||||
|
|
||||||
habitBody := CreateHabitRequest{
|
habitBody := CreateHabitRequest{
|
||||||
Name: "Reading",
|
Name: "Reading",
|
||||||
Type: "BOOLEAN",
|
Type: "BOOLEAN",
|
||||||
Frequency: "DAILY",
|
Frequency: "DAILY",
|
||||||
}
|
}
|
||||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||||
var habitResp map[string]string
|
var habitResp map[string]string
|
||||||
decodeResponse(t, rr, &habitResp)
|
decodeResponse(t, rr, &habitResp)
|
||||||
habitID := habitResp["id"]
|
habitID := habitResp["id"]
|
||||||
|
|||||||
@@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) {
|
|||||||
ts := setupTestServer(t)
|
ts := setupTestServer(t)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
registerBody := RegisterRequest{
|
token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!")
|
||||||
Email: "habituser@example.com",
|
|
||||||
Password: "Password123!",
|
|
||||||
Timezone: "UTC",
|
|
||||||
}
|
|
||||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
|
||||||
var authResp AuthResponse
|
|
||||||
decodeResponse(t, rr, &authResp)
|
|
||||||
token := authResp.Token
|
|
||||||
|
|
||||||
var habitID string
|
var habitID string
|
||||||
|
|
||||||
@@ -131,30 +123,22 @@ func TestHabitCRUDFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Access other user's habit", func(t *testing.T) {
|
t.Run("Access other user's habit", func(t *testing.T) {
|
||||||
registerBody := RegisterRequest{
|
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
|
||||||
Email: "otheruser@example.com",
|
|
||||||
Password: "Password123!",
|
|
||||||
Timezone: "UTC",
|
|
||||||
}
|
|
||||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
|
||||||
var authResp AuthResponse
|
|
||||||
decodeResponse(t, rr, &authResp)
|
|
||||||
otherToken := authResp.Token
|
|
||||||
|
|
||||||
reqBody := CreateHabitRequest{
|
reqBody := CreateHabitRequest{
|
||||||
Name: "Other User Habit",
|
Name: "Other User Habit",
|
||||||
Type: "BOOLEAN",
|
Type: "BOOLEAN",
|
||||||
Frequency: "DAILY",
|
Frequency: "DAILY",
|
||||||
}
|
}
|
||||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
|
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
|
||||||
var createResp map[string]string
|
var createResp map[string]string
|
||||||
decodeResponse(t, rr, &createResp)
|
decodeResponse(t, rr, &createResp)
|
||||||
otherHabitID := createResp["id"]
|
otherHabitID := createResp["id"]
|
||||||
|
|
||||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
|
rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
|
||||||
|
|
||||||
if rr.Code != http.StatusForbidden {
|
if rr2.Code != http.StatusForbidden {
|
||||||
t.Errorf("Expected status 403, got %d", rr.Code)
|
t.Errorf("Expected status 403, got %d", rr2.Code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func setupTestServer(t *testing.T) *TestServer {
|
|||||||
entryRepo := sqlite.NewHabitEntryRepository(db)
|
entryRepo := sqlite.NewHabitEntryRepository(db)
|
||||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
||||||
|
|
||||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
|
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open")
|
||||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||||
@@ -104,3 +104,22 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac
|
|||||||
t.Fatalf("Failed to decode response: %v", err)
|
t.Fatalf("Failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerAndLogin(t *testing.T, router http.Handler, email, password string) string {
|
||||||
|
registerBody := RegisterRequest{
|
||||||
|
Email: email,
|
||||||
|
Password: password,
|
||||||
|
Timezone: "UTC",
|
||||||
|
}
|
||||||
|
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||||
|
|
||||||
|
loginBody := LoginRequest{
|
||||||
|
Email: email,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||||
|
|
||||||
|
var authResp AuthResponse
|
||||||
|
decodeResponse(t, rr, &authResp)
|
||||||
|
return authResp.Token
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RunMigrations(db *sql.DB) error {
|
func RunMigrations(db *sql.DB) error {
|
||||||
@@ -18,9 +19,50 @@ func RunMigrations(db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := addEmailVerificationColumns(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addEmailVerificationColumns(db *sql.DB) error {
|
||||||
|
columns := []struct {
|
||||||
|
name string
|
||||||
|
definition string
|
||||||
|
}{
|
||||||
|
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
|
||||||
|
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
|
||||||
|
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, col := range columns {
|
||||||
|
exists, err := columnExists(db, "users", col.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if _, err := db.Exec(col.definition); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||||
|
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow(query, column).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
const createUsersTable = `
|
const createUsersTable = `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
|||||||
user.ID = uuid.New().String()
|
user.ID = uuid.New().String()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at)
|
INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := r.db.ExecContext(ctx, query,
|
_, err := r.db.ExecContext(ctx, query,
|
||||||
@@ -33,6 +33,9 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
|||||||
user.Email,
|
user.Email,
|
||||||
user.PasswordHash,
|
user.PasswordHash,
|
||||||
user.Timezone,
|
user.Timezone,
|
||||||
|
user.EmailVerified,
|
||||||
|
user.EmailVerificationToken,
|
||||||
|
user.EmailVerificationExpiry,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.UpdatedAt,
|
user.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -49,7 +52,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
|||||||
|
|
||||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, password_hash, timezone, created_at, updated_at
|
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
@@ -60,6 +63,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
|||||||
&user.Email,
|
&user.Email,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.Timezone,
|
&user.Timezone,
|
||||||
|
&user.EmailVerified,
|
||||||
|
&user.EmailVerificationToken,
|
||||||
|
&user.EmailVerificationExpiry,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -76,7 +82,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
|||||||
|
|
||||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, password_hash, timezone, created_at, updated_at
|
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = ?
|
WHERE email = ?
|
||||||
`
|
`
|
||||||
@@ -87,6 +93,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
|||||||
&user.Email,
|
&user.Email,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.Timezone,
|
&user.Timezone,
|
||||||
|
&user.EmailVerified,
|
||||||
|
&user.EmailVerificationToken,
|
||||||
|
&user.EmailVerificationExpiry,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -101,10 +110,40 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE email_verification_token = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var user entities.User
|
||||||
|
err := r.db.QueryRowContext(ctx, query, token).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Email,
|
||||||
|
&user.PasswordHash,
|
||||||
|
&user.Timezone,
|
||||||
|
&user.EmailVerified,
|
||||||
|
&user.EmailVerificationToken,
|
||||||
|
&user.EmailVerificationExpiry,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, errors.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find user by verification token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
|
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET email = ?, password_hash = ?, timezone = ?, updated_at = ?
|
SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -112,6 +151,9 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
|
|||||||
user.Email,
|
user.Email,
|
||||||
user.PasswordHash,
|
user.PasswordHash,
|
||||||
user.Timezone,
|
user.Timezone,
|
||||||
|
user.EmailVerified,
|
||||||
|
user.EmailVerificationToken,
|
||||||
|
user.EmailVerificationExpiry,
|
||||||
user.UpdatedAt,
|
user.UpdatedAt,
|
||||||
user.ID,
|
user.ID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppName = "Apocapoc"
|
||||||
|
AppURL = "https://apocapoc.app"
|
||||||
|
DefaultFrom = "noreply@apocapoc.app"
|
||||||
|
)
|
||||||
@@ -8,4 +8,6 @@ var (
|
|||||||
ErrInvalidInput = errors.New("invalid input")
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrEmailNotVerified = errors.New("email not verified")
|
||||||
|
ErrRegistrationClosed = errors.New("registration is closed")
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user