Improve email verification flow and error handling
- Send verification email before creating user to prevent orphaned accounts - Detect SMTP authentication errors and fail fast without retries - Add field-level validation errors for better frontend UX - Configure docker-compose with explicit environment variables - Document dollar sign escaping in .env.example (use $$) - Implement welcome email on successful verification - Clean up unnecessary comments
This commit is contained in:
+3
-3
@@ -1,21 +1,21 @@
|
|||||||
DB_PATH=./data/apocapoc.db
|
DB_PATH=./data/apocapoc.db
|
||||||
|
|
||||||
PORT=8080
|
PORT=8080
|
||||||
HOST=0.0.0.0
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
JWT_EXPIRY=1h
|
JWT_EXPIRY=1h
|
||||||
REFRESH_TOKEN_EXPIRY=7d
|
REFRESH_TOKEN_EXPIRY=7d
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:3000
|
|
||||||
|
|
||||||
DEFAULT_TIMEZONE=UTC
|
DEFAULT_TIMEZONE=UTC
|
||||||
|
|
||||||
# Email Configuration (optional - required for email features)
|
# Email Configuration (optional - required for email features)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
|
# Note: Escape $ signs with $$ (e.g., pa$word becomes pa$$word)
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=noreply@example.com
|
||||||
|
|
||||||
# Application Branding (optional - override support email if needed)
|
# Application Branding (optional - override support email if needed)
|
||||||
SUPPORT_EMAIL=contact@apocapoc.app
|
SUPPORT_EMAIL=contact@apocapoc.app
|
||||||
|
|||||||
+14
-6
@@ -16,7 +16,6 @@ import (
|
|||||||
"apocapoc-api/internal/infrastructure/email"
|
"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
|
||||||
@@ -74,21 +73,29 @@ func main() {
|
|||||||
Port: smtpPort,
|
Port: smtpPort,
|
||||||
Username: cfg.SMTPUser,
|
Username: cfg.SMTPUser,
|
||||||
Password: cfg.SMTPPassword,
|
Password: cfg.SMTPPassword,
|
||||||
From: constants.DefaultFrom,
|
From: cfg.SMTPFrom,
|
||||||
SupportEmail: cfg.SupportEmail,
|
SupportEmail: cfg.SupportEmail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendWelcomeEmail := cfg.SendWelcomeEmail == "true"
|
||||||
|
|
||||||
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())
|
||||||
|
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db.Conn())
|
||||||
|
|
||||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, constants.AppURL, cfg.RegistrationMode)
|
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
|
||||||
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)
|
||||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||||
|
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, emailService, sendWelcomeEmail)
|
||||||
|
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, emailService, cfg.AppURL)
|
||||||
|
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, emailService, cfg.AppURL)
|
||||||
|
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||||
|
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
|
||||||
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||||
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||||
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
||||||
@@ -100,14 +107,15 @@ func main() {
|
|||||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||||
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
||||||
|
|
||||||
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
||||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
||||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
|
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
|
||||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
||||||
|
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler)
|
||||||
|
|
||||||
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||||
log.Printf("Server starting on %s", addr)
|
log.Printf("Server starting on %s", addr)
|
||||||
|
|
||||||
if err := http.ListenAndServe(addr, router); err != nil {
|
if err := http.ListenAndServe(addr, router); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeleteUserCommand struct {
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteUserHandler struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeleteUserHandler(userRepo repositories.UserRepository) *DeleteUserHandler {
|
||||||
|
return &DeleteUserHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeleteUserHandler) Handle(ctx context.Context, cmd DeleteUserCommand) error {
|
||||||
|
if cmd.UserID == "" {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByID(ctx, cmd.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Delete(ctx, user.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type RegisterUserHandler struct {
|
|||||||
emailService services.EmailService
|
emailService services.EmailService
|
||||||
appURL string
|
appURL string
|
||||||
registrationMode string
|
registrationMode string
|
||||||
|
sendWelcomeEmail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRegisterUserHandler(
|
func NewRegisterUserHandler(
|
||||||
@@ -39,6 +40,7 @@ func NewRegisterUserHandler(
|
|||||||
emailService services.EmailService,
|
emailService services.EmailService,
|
||||||
appURL string,
|
appURL string,
|
||||||
registrationMode string,
|
registrationMode string,
|
||||||
|
sendWelcomeEmail bool,
|
||||||
) *RegisterUserHandler {
|
) *RegisterUserHandler {
|
||||||
return &RegisterUserHandler{
|
return &RegisterUserHandler{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
@@ -46,6 +48,7 @@ func NewRegisterUserHandler(
|
|||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
appURL: appURL,
|
appURL: appURL,
|
||||||
registrationMode: registrationMode,
|
registrationMode: registrationMode,
|
||||||
|
sendWelcomeEmail: sendWelcomeEmail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 nil, errors.ErrInvalidInput
|
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||||
@@ -81,6 +84,10 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
|||||||
user.EmailVerificationToken = &token
|
user.EmailVerificationToken = &token
|
||||||
user.EmailVerificationExpiry = &expiry
|
user.EmailVerificationExpiry = &expiry
|
||||||
emailVerificationRequired = true
|
emailVerificationRequired = true
|
||||||
|
|
||||||
|
if err := h.sendVerificationEmail(user); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send verification email: %w", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
user.EmailVerified = true
|
user.EmailVerified = true
|
||||||
}
|
}
|
||||||
@@ -89,15 +96,6 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
|||||||
return nil, err
|
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{
|
return &RegisterUserResult{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
EmailVerificationRequired: emailVerificationRequired,
|
EmailVerificationRequired: emailVerificationRequired,
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockPasswordHasher struct {
|
type mockPasswordHasher struct {
|
||||||
hashFunc func(password string) (string, error)
|
hashFunc func(password string) (string, error)
|
||||||
}
|
}
|
||||||
@@ -65,7 +69,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -102,7 +106,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, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -125,7 +129,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.Handle(context.Background(), cmd)
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != appErrors.ErrInvalidInput {
|
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -135,7 +139,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, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -160,7 +164,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.Handle(context.Background(), cmd)
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != appErrors.ErrInvalidInput {
|
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -170,7 +174,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, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -191,7 +195,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.Handle(context.Background(), cmd)
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != appErrors.ErrInvalidInput {
|
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -206,7 +210,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -215,7 +219,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.Handle(context.Background(), cmd)
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != appErrors.ErrAlreadyExists {
|
if !errors.Is(err, appErrors.ErrAlreadyExists) {
|
||||||
t.Errorf("expected ErrAlreadyExists, got %v", err)
|
t.Errorf("expected ErrAlreadyExists, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +232,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
|||||||
return "", expectedErr
|
return "", expectedErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -250,7 +254,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -267,7 +271,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, nil, "", "open")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -333,7 +337,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
|||||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
hasher := &mockPasswordHasher{}
|
hasher := &mockPasswordHasher{}
|
||||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
|
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
|
||||||
|
|
||||||
cmd := RegisterUserCommand{
|
cmd := RegisterUserCommand{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
@@ -342,7 +346,7 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.Handle(context.Background(), cmd)
|
_, err := handler.Handle(context.Background(), cmd)
|
||||||
if err != appErrors.ErrRegistrationClosed {
|
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
|
||||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/entities"
|
||||||
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestPasswordResetCommand struct {
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestPasswordResetHandler struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||||
|
emailService services.EmailService
|
||||||
|
appURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestPasswordResetHandler(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||||
|
emailService services.EmailService,
|
||||||
|
appURL string,
|
||||||
|
) *RequestPasswordResetHandler {
|
||||||
|
return &RequestPasswordResetHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||||
|
emailService: emailService,
|
||||||
|
appURL: appURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RequestPasswordResetHandler) Handle(ctx context.Context, cmd RequestPasswordResetCommand) 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.ErrEmailNotVerified
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, err := generatePasswordResetToken()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(1 * time.Hour)
|
||||||
|
resetToken := entities.NewPasswordResetToken(user.ID, tokenStr, expiresAt)
|
||||||
|
|
||||||
|
if err := h.passwordResetTokenRepo.Create(ctx, resetToken); err != nil {
|
||||||
|
return fmt.Errorf("failed to save reset token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLink := fmt.Sprintf("%s/reset-password?token=%s", h.appURL, tokenStr)
|
||||||
|
|
||||||
|
emailBody := fmt.Sprintf(`
|
||||||
|
<h2>Password Reset Request</h2>
|
||||||
|
<p>You requested to reset your password. Click the link below to reset it:</p>
|
||||||
|
<p><a href="%s">Reset Password</a></p>
|
||||||
|
<p>This link will expire in 1 hour.</p>
|
||||||
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`, resetLink)
|
||||||
|
|
||||||
|
message := services.EmailMessage{
|
||||||
|
To: user.Email,
|
||||||
|
Subject: "Password Reset Request",
|
||||||
|
Body: emailBody,
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.emailService.Send(message); err != nil {
|
||||||
|
return fmt.Errorf("failed to send reset email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePasswordResetToken() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
"apocapoc-api/internal/shared/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResetPasswordCommand struct {
|
||||||
|
Token string
|
||||||
|
NewPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPasswordHandler struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||||
|
passwordHasher services.PasswordHasher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResetPasswordHandler(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||||
|
passwordHasher services.PasswordHasher,
|
||||||
|
) *ResetPasswordHandler {
|
||||||
|
return &ResetPasswordHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||||
|
passwordHasher: passwordHasher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd ResetPasswordCommand) error {
|
||||||
|
if cmd.Token == "" || cmd.NewPassword == "" {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validation.ValidatePassword(cmd.NewPassword); err != nil {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToken, err := h.passwordResetTokenRepo.FindByToken(ctx, cmd.Token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if resetToken.IsExpired() {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if resetToken.IsUsed() {
|
||||||
|
return errors.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.FindByID(ctx, resetToken.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := h.passwordHasher.Hash(cmd.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = hashedPassword
|
||||||
|
|
||||||
|
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||||
|
return fmt.Errorf("failed to update password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToken.MarkAsUsed()
|
||||||
|
if err := h.passwordResetTokenRepo.Update(ctx, resetToken); err != nil {
|
||||||
|
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/entities"
|
||||||
"apocapoc-api/internal/domain/repositories"
|
"apocapoc-api/internal/domain/repositories"
|
||||||
|
"apocapoc-api/internal/domain/services"
|
||||||
"apocapoc-api/internal/shared/errors"
|
"apocapoc-api/internal/shared/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,11 +17,19 @@ type VerifyEmailCommand struct {
|
|||||||
|
|
||||||
type VerifyEmailHandler struct {
|
type VerifyEmailHandler struct {
|
||||||
userRepo repositories.UserRepository
|
userRepo repositories.UserRepository
|
||||||
|
emailService services.EmailService
|
||||||
|
sendWelcomeEmail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
|
func NewVerifyEmailHandler(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
emailService services.EmailService,
|
||||||
|
sendWelcomeEmail bool,
|
||||||
|
) *VerifyEmailHandler {
|
||||||
return &VerifyEmailHandler{
|
return &VerifyEmailHandler{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
|
emailService: emailService,
|
||||||
|
sendWelcomeEmail: sendWelcomeEmail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,5 +60,27 @@ func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand)
|
|||||||
return fmt.Errorf("failed to verify email: %w", err)
|
return fmt.Errorf("failed to verify email: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.sendWelcomeEmail && h.emailService != nil {
|
||||||
|
h.sendWelcomeEmailToUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error {
|
||||||
|
emailBody := fmt.Sprintf(`
|
||||||
|
<h2>Welcome to Apocapoc!</h2>
|
||||||
|
<p>Your email has been successfully verified.</p>
|
||||||
|
<p>You can now start tracking your habits and building better routines.</p>
|
||||||
|
<p>If you have any questions or need help, please don't hesitate to contact us.</p>
|
||||||
|
`)
|
||||||
|
|
||||||
|
message := services.EmailMessage{
|
||||||
|
To: user.Email,
|
||||||
|
Subject: "Welcome to Apocapoc!",
|
||||||
|
Body: emailBody,
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.emailService.Send(message)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entitie
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepositoryForRefresh) Delete(ctx context.Context, id string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestRefreshTokenHandler_Success(t *testing.T) {
|
func TestRefreshTokenHandler_Success(t *testing.T) {
|
||||||
refreshTokenRepo := &mockRefreshTokenRepository{
|
refreshTokenRepo := &mockRefreshTokenRepository{
|
||||||
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordResetToken(userID, token string, expiresAt time.Time) *PasswordResetToken {
|
||||||
|
return &PasswordResetToken{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
UserID: userID,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
UsedAt: nil,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PasswordResetToken) IsExpired() bool {
|
||||||
|
return time.Now().After(t.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PasswordResetToken) IsUsed() bool {
|
||||||
|
return t.UsedAt != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PasswordResetToken) MarkAsUsed() {
|
||||||
|
now := time.Now()
|
||||||
|
t.UsedAt = &now
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordResetTokenRepository interface {
|
||||||
|
Create(ctx context.Context, token *entities.PasswordResetToken) error
|
||||||
|
FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error)
|
||||||
|
Update(ctx context.Context, token *entities.PasswordResetToken) error
|
||||||
|
DeleteExpired(ctx context.Context) error
|
||||||
|
}
|
||||||
@@ -12,4 +12,5 @@ type UserRepository interface {
|
|||||||
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)
|
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
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
DBPath string
|
DBPath string
|
||||||
Port string
|
Port string
|
||||||
Host string
|
AppURL string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
JWTExpiry string
|
JWTExpiry string
|
||||||
RefreshTokenExpiry string
|
RefreshTokenExpiry string
|
||||||
CORSOrigins string
|
|
||||||
DefaultTimezone string
|
DefaultTimezone string
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort string
|
SMTPPort string
|
||||||
SMTPUser string
|
SMTPUser string
|
||||||
SMTPPassword string
|
SMTPPassword string
|
||||||
|
SMTPFrom string
|
||||||
SupportEmail string
|
SupportEmail string
|
||||||
SendWelcomeEmail string
|
SendWelcomeEmail string
|
||||||
RegistrationMode string
|
RegistrationMode string
|
||||||
@@ -31,16 +31,16 @@ func Load() (*Config, error) {
|
|||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
DBPath: os.Getenv("DB_PATH"),
|
DBPath: os.Getenv("DB_PATH"),
|
||||||
Port: getEnvOrDefault("PORT", "8080"),
|
Port: getEnvOrDefault("PORT", "8080"),
|
||||||
Host: getEnvOrDefault("HOST", "0.0.0.0"),
|
AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
||||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||||
CORSOrigins: os.Getenv("CORS_ORIGINS"),
|
|
||||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||||
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||||
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
||||||
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
||||||
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||||
@@ -49,6 +49,9 @@ func Load() (*Config, error) {
|
|||||||
if cfg.DBPath == "" {
|
if cfg.DBPath == "" {
|
||||||
return nil, fmt.Errorf("DB_PATH is required")
|
return nil, fmt.Errorf("DB_PATH is required")
|
||||||
}
|
}
|
||||||
|
if cfg.AppURL == "" {
|
||||||
|
return nil, fmt.Errorf("APP_URL is required")
|
||||||
|
}
|
||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||||
}
|
}
|
||||||
@@ -58,9 +61,6 @@ func Load() (*Config, error) {
|
|||||||
if cfg.RefreshTokenExpiry == "" {
|
if cfg.RefreshTokenExpiry == "" {
|
||||||
return nil, fmt.Errorf("REFRESH_TOKEN_EXPIRY is required")
|
return nil, fmt.Errorf("REFRESH_TOKEN_EXPIRY is required")
|
||||||
}
|
}
|
||||||
if cfg.CORSOrigins == "" {
|
|
||||||
return nil, fmt.Errorf("CORS_ORIGINS is required")
|
|
||||||
}
|
|
||||||
if cfg.DefaultTimezone == "" {
|
if cfg.DefaultTimezone == "" {
|
||||||
return nil, fmt.Errorf("DEFAULT_TIMEZONE is required")
|
return nil, fmt.Errorf("DEFAULT_TIMEZONE is required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package email
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apocapoc-api/internal/domain/services"
|
"apocapoc-api/internal/domain/services"
|
||||||
@@ -46,6 +47,11 @@ func (s *SMTPService) Send(message services.EmailMessage) error {
|
|||||||
ServerName: s.config.Host,
|
ServerName: s.config.Host,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use SSL from start for port 465, STARTTLS for other ports
|
||||||
|
if s.config.Port == 465 {
|
||||||
|
dialer.SSL = true
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.sendWithRetry(dialer, m); err != nil {
|
if err := s.sendWithRetry(dialer, m); err != nil {
|
||||||
return fmt.Errorf("failed to send email: %w", err)
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
}
|
}
|
||||||
@@ -62,13 +68,36 @@ func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message)
|
|||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
if isAuthError(err) {
|
||||||
|
return fmt.Errorf("SMTP authentication failed. Please check your SMTP credentials (username, password, and from address)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isConfigError(err) {
|
||||||
|
return fmt.Errorf("SMTP configuration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if i < maxRetries-1 {
|
if i < maxRetries-1 {
|
||||||
time.Sleep(time.Second * time.Duration(i+1))
|
time.Sleep(time.Second * time.Duration(i+1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastErr
|
return fmt.Errorf("failed to send email after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthError(err error) bool {
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(errStr, "authentication failed") ||
|
||||||
|
strings.Contains(errStr, "535") ||
|
||||||
|
strings.Contains(errStr, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConfigError(err error) bool {
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "no such host") ||
|
||||||
|
strings.Contains(errStr, "network is unreachable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SMTPService) GetConfig() SMTPConfig {
|
func (s *SMTPService) GetConfig() SMTPConfig {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import (
|
|||||||
"apocapoc-api/internal/application/queries"
|
"apocapoc-api/internal/application/queries"
|
||||||
"apocapoc-api/internal/domain/repositories"
|
"apocapoc-api/internal/domain/repositories"
|
||||||
"apocapoc-api/internal/infrastructure/auth"
|
"apocapoc-api/internal/infrastructure/auth"
|
||||||
"apocapoc-api/internal/shared/errors"
|
appErrors "apocapoc-api/internal/shared/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandlers struct {
|
type AuthHandlers struct {
|
||||||
@@ -18,6 +19,10 @@ type AuthHandlers struct {
|
|||||||
refreshTokenHandler *queries.RefreshTokenHandler
|
refreshTokenHandler *queries.RefreshTokenHandler
|
||||||
revokeTokenHandler *commands.RevokeTokenHandler
|
revokeTokenHandler *commands.RevokeTokenHandler
|
||||||
revokeAllTokensHandler *commands.RevokeAllTokensHandler
|
revokeAllTokensHandler *commands.RevokeAllTokensHandler
|
||||||
|
verifyEmailHandler *commands.VerifyEmailHandler
|
||||||
|
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler
|
||||||
|
requestPasswordResetHandler *commands.RequestPasswordResetHandler
|
||||||
|
resetPasswordHandler *commands.ResetPasswordHandler
|
||||||
jwtService *auth.JWTService
|
jwtService *auth.JWTService
|
||||||
refreshTokenRepo repositories.RefreshTokenRepository
|
refreshTokenRepo repositories.RefreshTokenRepository
|
||||||
refreshTokenExpiry time.Duration
|
refreshTokenExpiry time.Duration
|
||||||
@@ -29,6 +34,10 @@ func NewAuthHandlers(
|
|||||||
refreshTokenHandler *queries.RefreshTokenHandler,
|
refreshTokenHandler *queries.RefreshTokenHandler,
|
||||||
revokeTokenHandler *commands.RevokeTokenHandler,
|
revokeTokenHandler *commands.RevokeTokenHandler,
|
||||||
revokeAllTokensHandler *commands.RevokeAllTokensHandler,
|
revokeAllTokensHandler *commands.RevokeAllTokensHandler,
|
||||||
|
verifyEmailHandler *commands.VerifyEmailHandler,
|
||||||
|
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler,
|
||||||
|
requestPasswordResetHandler *commands.RequestPasswordResetHandler,
|
||||||
|
resetPasswordHandler *commands.ResetPasswordHandler,
|
||||||
jwtService *auth.JWTService,
|
jwtService *auth.JWTService,
|
||||||
refreshTokenRepo repositories.RefreshTokenRepository,
|
refreshTokenRepo repositories.RefreshTokenRepository,
|
||||||
refreshTokenExpiry time.Duration,
|
refreshTokenExpiry time.Duration,
|
||||||
@@ -39,6 +48,10 @@ func NewAuthHandlers(
|
|||||||
refreshTokenHandler: refreshTokenHandler,
|
refreshTokenHandler: refreshTokenHandler,
|
||||||
revokeTokenHandler: revokeTokenHandler,
|
revokeTokenHandler: revokeTokenHandler,
|
||||||
revokeAllTokensHandler: revokeAllTokensHandler,
|
revokeAllTokensHandler: revokeAllTokensHandler,
|
||||||
|
verifyEmailHandler: verifyEmailHandler,
|
||||||
|
resendVerificationEmailHandler: resendVerificationEmailHandler,
|
||||||
|
requestPasswordResetHandler: requestPasswordResetHandler,
|
||||||
|
resetPasswordHandler: resetPasswordHandler,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
refreshTokenRepo: refreshTokenRepo,
|
refreshTokenRepo: refreshTokenRepo,
|
||||||
refreshTokenExpiry: refreshTokenExpiry,
|
refreshTokenExpiry: refreshTokenExpiry,
|
||||||
@@ -103,15 +116,15 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, 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 errors.Is(err, appErrors.ErrInvalidInput) {
|
||||||
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
|
respondValidationError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == errors.ErrAlreadyExists {
|
if err == appErrors.ErrAlreadyExists {
|
||||||
respondError(w, http.StatusConflict, "Email already registered")
|
respondError(w, http.StatusConflict, "Email already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == errors.ErrRegistrationClosed {
|
if err == appErrors.ErrRegistrationClosed {
|
||||||
respondError(w, http.StatusForbidden, "Registration is currently closed")
|
respondError(w, http.StatusForbidden, "Registration is currently closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -159,11 +172,11 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := h.loginHandler.Handle(r.Context(), query)
|
result, err := h.loginHandler.Handle(r.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
|
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
|
||||||
respondError(w, http.StatusUnauthorized, "Invalid email or password")
|
respondError(w, http.StatusUnauthorized, "Invalid email or password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == errors.ErrEmailNotVerified {
|
if err == appErrors.ErrEmailNotVerified {
|
||||||
respondError(w, http.StatusForbidden, "Please verify your email before logging in")
|
respondError(w, http.StatusForbidden, "Please verify your email before logging in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,7 +233,7 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := h.refreshTokenHandler.Handle(r.Context(), query)
|
result, err := h.refreshTokenHandler.Handle(r.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
|
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
|
||||||
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
|
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,11 +293,11 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
err := h.revokeTokenHandler.Handle(r.Context(), cmd)
|
err := h.revokeTokenHandler.Handle(r.Context(), cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == errors.ErrNotFound {
|
if err == appErrors.ErrNotFound {
|
||||||
respondError(w, http.StatusNotFound, "Refresh token not found")
|
respondError(w, http.StatusNotFound, "Refresh token not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == errors.ErrInvalidInput {
|
if err == appErrors.ErrInvalidInput {
|
||||||
respondError(w, http.StatusBadRequest, "Invalid refresh token")
|
respondError(w, http.StatusBadRequest, "Invalid refresh token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -296,3 +309,199 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
"message": "Successfully logged out",
|
"message": "Successfully logged out",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifyEmailRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResendVerificationRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail godoc
|
||||||
|
// @Summary Verify email address
|
||||||
|
// @Description Verify user email address using the token sent via email
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body VerifyEmailRequest true "Verification token"
|
||||||
|
// @Success 200 {object} map[string]string "Email verified successfully"
|
||||||
|
// @Failure 400 {object} ErrorResponse "Invalid or expired token"
|
||||||
|
// @Failure 409 {object} ErrorResponse "Email already verified"
|
||||||
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
|
// @Router /auth/verify-email [post]
|
||||||
|
func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req VerifyEmailRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := commands.VerifyEmailCommand{
|
||||||
|
Token: req.Token,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.verifyEmailHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == appErrors.ErrInvalidInput {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid or expired verification token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrAlreadyExists {
|
||||||
|
respondError(w, http.StatusConflict, "Email already verified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to verify email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Email verified successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerification godoc
|
||||||
|
// @Summary Resend verification email
|
||||||
|
// @Description Resend the email verification link to the user's email address
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body ResendVerificationRequest true "User email"
|
||||||
|
// @Success 200 {object} map[string]string "Verification email sent"
|
||||||
|
// @Failure 400 {object} ErrorResponse "Invalid email"
|
||||||
|
// @Failure 404 {object} ErrorResponse "User not found"
|
||||||
|
// @Failure 409 {object} ErrorResponse "Email already verified"
|
||||||
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
|
// @Router /auth/resend-verification [post]
|
||||||
|
func (h *AuthHandlers) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ResendVerificationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := commands.ResendVerificationEmailCommand{
|
||||||
|
Email: req.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.resendVerificationEmailHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == appErrors.ErrInvalidInput {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrAlreadyExists {
|
||||||
|
respondError(w, http.StatusConflict, "Email already verified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to send verification email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Verification email sent successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForgotPasswordRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPasswordRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgotPassword godoc
|
||||||
|
// @Summary Request password reset
|
||||||
|
// @Description Request a password reset email with a reset token
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body ForgotPasswordRequest true "User email"
|
||||||
|
// @Success 200 {object} map[string]string "Reset email sent successfully"
|
||||||
|
// @Failure 400 {object} ErrorResponse "Invalid email"
|
||||||
|
// @Failure 403 {object} ErrorResponse "Email not verified"
|
||||||
|
// @Failure 404 {object} ErrorResponse "User not found"
|
||||||
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
|
// @Router /auth/forgot-password [post]
|
||||||
|
func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ForgotPasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := commands.RequestPasswordResetCommand{
|
||||||
|
Email: req.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.requestPasswordResetHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == appErrors.ErrInvalidInput {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrEmailNotVerified {
|
||||||
|
respondError(w, http.StatusForbidden, "Please verify your email before resetting password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to send reset email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Password reset email sent successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword godoc
|
||||||
|
// @Summary Reset password
|
||||||
|
// @Description Reset user password using the reset token from email
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body ResetPasswordRequest true "Reset token and new password"
|
||||||
|
// @Success 200 {object} map[string]string "Password reset successfully"
|
||||||
|
// @Failure 400 {object} ErrorResponse "Invalid token or password requirements not met"
|
||||||
|
// @Failure 404 {object} ErrorResponse "User not found"
|
||||||
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
|
// @Router /auth/reset-password [post]
|
||||||
|
func (h *AuthHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ResetPasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := commands.ResetPasswordCommand{
|
||||||
|
Token: req.Token,
|
||||||
|
NewPassword: req.NewPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.resetPasswordHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == appErrors.ErrInvalidInput {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid or expired token, or password requirements not met")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == appErrors.ErrNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to reset password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Password reset successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,4 +86,5 @@ type HabitEntriesResponse struct {
|
|||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
Field *string `json:"field,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apocapoc-api/internal/application/commands"
|
"apocapoc-api/internal/application/commands"
|
||||||
@@ -590,3 +591,22 @@ func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
|||||||
func respondError(w http.ResponseWriter, status int, message string) {
|
func respondError(w http.ResponseWriter, status int, message string) {
|
||||||
respondJSON(w, status, ErrorResponse{Error: message})
|
respondJSON(w, status, ErrorResponse{Error: message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func respondValidationError(w http.ResponseWriter, err error) {
|
||||||
|
errMsg := err.Error()
|
||||||
|
var field *string
|
||||||
|
|
||||||
|
if strings.Contains(errMsg, ": ") {
|
||||||
|
parts := strings.SplitN(errMsg, ": ", 3)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
fieldName := parts[1]
|
||||||
|
field = &fieldName
|
||||||
|
errMsg = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: errMsg,
|
||||||
|
Field: field,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,12 +40,17 @@ func setupTestServer(t *testing.T) *TestServer {
|
|||||||
habitRepo := sqlite.NewHabitRepository(db)
|
habitRepo := sqlite.NewHabitRepository(db)
|
||||||
entryRepo := sqlite.NewHabitEntryRepository(db)
|
entryRepo := sqlite.NewHabitEntryRepository(db)
|
||||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
||||||
|
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
|
||||||
|
|
||||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open")
|
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)
|
||||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||||
|
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo)
|
||||||
|
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "")
|
||||||
|
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "")
|
||||||
|
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||||
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||||
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||||
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
||||||
@@ -59,12 +64,15 @@ func setupTestServer(t *testing.T) *TestServer {
|
|||||||
|
|
||||||
refreshTokenExpiry := 7 * 24 * time.Hour
|
refreshTokenExpiry := 7 * 24 * time.Hour
|
||||||
|
|
||||||
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
|
||||||
|
|
||||||
|
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
||||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
||||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler)
|
statsHandlers := NewStatsHandlers(getHabitStatsHandler)
|
||||||
healthHandlers := NewHealthHandlers(db)
|
healthHandlers := NewHealthHandlers(db)
|
||||||
|
userHandlers := NewUserHandlers(deleteUserHandler)
|
||||||
|
|
||||||
router := NewRouter("*", habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService)
|
||||||
|
|
||||||
handler := http.Handler(router)
|
handler := http.Handler(router)
|
||||||
return &TestServer{
|
return &TestServer{
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import (
|
|||||||
_ "apocapoc-api/docs"
|
_ "apocapoc-api/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, jwtService *auth.JWTService) *chi.Mux {
|
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, jwtService *auth.JWTService) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{corsOrigins},
|
AllowedOrigins: []string{appURL},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
@@ -42,6 +42,10 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
|
|||||||
r.Post("/login", authHandlers.Login)
|
r.Post("/login", authHandlers.Login)
|
||||||
r.Post("/refresh", authHandlers.Refresh)
|
r.Post("/refresh", authHandlers.Refresh)
|
||||||
r.Post("/logout", authHandlers.Logout)
|
r.Post("/logout", authHandlers.Logout)
|
||||||
|
r.Post("/verify-email", authHandlers.VerifyEmail)
|
||||||
|
r.Post("/resend-verification", authHandlers.ResendVerification)
|
||||||
|
r.Post("/forgot-password", authHandlers.ForgotPassword)
|
||||||
|
r.Post("/reset-password", authHandlers.ResetPassword)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/api/v1/habits", func(r chi.Router) {
|
r.Route("/api/v1/habits", func(r chi.Router) {
|
||||||
@@ -65,5 +69,11 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
|
|||||||
r.Get("/habits/{id}", statsHandlers.GetHabitStats)
|
r.Get("/habits/{id}", statsHandlers.GetHabitStats)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/api/v1/users", func(r chi.Router) {
|
||||||
|
r.Use(AuthMiddleware(jwtService))
|
||||||
|
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
|
||||||
|
r.Delete("/me", userHandlers.DeleteAccount)
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/application/commands"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserHandlers struct {
|
||||||
|
deleteUserHandler *commands.DeleteUserHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserHandlers(deleteUserHandler *commands.DeleteUserHandler) *UserHandlers {
|
||||||
|
return &UserHandlers{
|
||||||
|
deleteUserHandler: deleteUserHandler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount godoc
|
||||||
|
// @Summary Delete user account
|
||||||
|
// @Description Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.
|
||||||
|
// @Tags users
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]string "Account deleted successfully"
|
||||||
|
// @Failure 401 {object} ErrorResponse "Unauthorized - invalid or missing token"
|
||||||
|
// @Failure 404 {object} ErrorResponse "User not found"
|
||||||
|
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||||
|
// @Router /users/me [delete]
|
||||||
|
func (h *UserHandlers) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Context().Value("user_id").(string)
|
||||||
|
|
||||||
|
cmd := commands.DeleteUserCommand{
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.deleteUserHandler.Handle(r.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
if err == errors.ErrNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to delete account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Account deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ func RunMigrations(db *sql.DB) error {
|
|||||||
createHabitsTable,
|
createHabitsTable,
|
||||||
createHabitEntriesTable,
|
createHabitEntriesTable,
|
||||||
createRefreshTokensTable,
|
createRefreshTokensTable,
|
||||||
|
createPasswordResetTokensTable,
|
||||||
createIndexes,
|
createIndexes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,18 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const createPasswordResetTokensTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
const createIndexes = `
|
const createIndexes = `
|
||||||
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
|
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
|
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
|
||||||
@@ -124,4 +137,6 @@ CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
|
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/entities"
|
||||||
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordResetTokenRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordResetTokenRepository(db *sql.DB) *PasswordResetTokenRepository {
|
||||||
|
return &PasswordResetTokenRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PasswordResetTokenRepository) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO password_reset_tokens (id, user_id, token, expires_at, created_at, used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.ExecContext(ctx, query,
|
||||||
|
token.ID,
|
||||||
|
token.UserID,
|
||||||
|
token.Token,
|
||||||
|
token.ExpiresAt,
|
||||||
|
token.CreatedAt,
|
||||||
|
token.UsedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PasswordResetTokenRepository) FindByToken(ctx context.Context, tokenStr string) (*entities.PasswordResetToken, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, token, expires_at, created_at, used_at
|
||||||
|
FROM password_reset_tokens
|
||||||
|
WHERE token = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
token := &entities.PasswordResetToken{}
|
||||||
|
err := r.db.QueryRowContext(ctx, query, tokenStr).Scan(
|
||||||
|
&token.ID,
|
||||||
|
&token.UserID,
|
||||||
|
&token.Token,
|
||||||
|
&token.ExpiresAt,
|
||||||
|
&token.CreatedAt,
|
||||||
|
&token.UsedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PasswordResetTokenRepository) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||||
|
query := `
|
||||||
|
UPDATE password_reset_tokens
|
||||||
|
SET used_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, query, token.UsedAt, token.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PasswordResetTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM password_reset_tokens
|
||||||
|
WHERE expires_at < ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.ExecContext(ctx, query, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -170,6 +170,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM users WHERE id = ?`
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func isUniqueConstraintError(err error) bool {
|
func isUniqueConstraintError(err error) bool {
|
||||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,4 @@ package constants
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
AppName = "Apocapoc"
|
AppName = "Apocapoc"
|
||||||
AppURL = "https://apocapoc.app"
|
|
||||||
DefaultFrom = "noreply@apocapoc.app"
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user