diff --git a/.env.example b/.env.example index 6395cb2..a9adfee 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,21 @@ DB_PATH=./data/apocapoc.db PORT=8080 -HOST=0.0.0.0 +APP_URL=http://localhost:8080 JWT_SECRET=change-me-in-production JWT_EXPIRY=1h REFRESH_TOKEN_EXPIRY=7d -CORS_ORIGINS=http://localhost:3000 - DEFAULT_TIMEZONE=UTC # Email Configuration (optional - required for email features) SMTP_HOST= SMTP_PORT=587 SMTP_USER= +# Note: Escape $ signs with $$ (e.g., pa$word becomes pa$$word) SMTP_PASSWORD= +SMTP_FROM=noreply@example.com # Application Branding (optional - override support email if needed) SUPPORT_EMAIL=contact@apocapoc.app diff --git a/cmd/api/main.go b/cmd/api/main.go index 738c4a5..2b3a4da 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -16,7 +16,6 @@ import ( "apocapoc-api/internal/infrastructure/email" httpInfra "apocapoc-api/internal/infrastructure/http" "apocapoc-api/internal/infrastructure/persistence/sqlite" - "apocapoc-api/internal/shared/constants" ) // @title Apocapoc API @@ -74,21 +73,29 @@ func main() { Port: smtpPort, Username: cfg.SMTPUser, Password: cfg.SMTPPassword, - From: constants.DefaultFrom, + From: cfg.SMTPFrom, SupportEmail: cfg.SupportEmail, }) } + sendWelcomeEmail := cfg.SendWelcomeEmail == "true" + userRepo := sqlite.NewUserRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(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) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(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) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) @@ -100,14 +107,15 @@ func main() { markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) 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) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler) 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) if err := http.ListenAndServe(addr, router); err != nil { diff --git a/internal/application/commands/delete_user_handler.go b/internal/application/commands/delete_user_handler.go new file mode 100644 index 0000000..bcb2de4 --- /dev/null +++ b/internal/application/commands/delete_user_handler.go @@ -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 +} diff --git a/internal/application/commands/register_user.go b/internal/application/commands/register_user.go index 9962980..4d63dbf 100644 --- a/internal/application/commands/register_user.go +++ b/internal/application/commands/register_user.go @@ -31,6 +31,7 @@ type RegisterUserHandler struct { emailService services.EmailService appURL string registrationMode string + sendWelcomeEmail bool } func NewRegisterUserHandler( @@ -39,6 +40,7 @@ func NewRegisterUserHandler( emailService services.EmailService, appURL string, registrationMode string, + sendWelcomeEmail bool, ) *RegisterUserHandler { return &RegisterUserHandler{ userRepo: userRepo, @@ -46,6 +48,7 @@ func NewRegisterUserHandler( emailService: emailService, appURL: appURL, 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 { - return nil, errors.ErrInvalidInput + return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err) } existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email) @@ -81,6 +84,10 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman user.EmailVerificationToken = &token user.EmailVerificationExpiry = &expiry emailVerificationRequired = true + + if err := h.sendVerificationEmail(user); err != nil { + return nil, fmt.Errorf("failed to send verification email: %w", err) + } } else { user.EmailVerified = true } @@ -89,15 +96,6 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman 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, diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index 0dc6e44..88b532b 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -40,6 +40,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error { return nil } +func (m *mockUserRepo) Delete(ctx context.Context, id string) error { + return nil +} + type mockPasswordHasher struct { hashFunc func(password string) (string, error) } @@ -65,7 +69,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -102,7 +106,7 @@ func TestRegisterUserHandler_Success(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) tests := []struct { name string @@ -125,7 +129,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) { } _, err := handler.Handle(context.Background(), cmd) - if err != appErrors.ErrInvalidInput { + if !errors.Is(err, appErrors.ErrInvalidInput) { t.Errorf("expected ErrInvalidInput, got %v", err) } }) @@ -135,7 +139,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidPassword(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) tests := []struct { name string @@ -160,7 +164,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) { } _, err := handler.Handle(context.Background(), cmd) - if err != appErrors.ErrInvalidInput { + if !errors.Is(err, appErrors.ErrInvalidInput) { t.Errorf("expected ErrInvalidInput, got %v", err) } }) @@ -170,7 +174,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) { func TestRegisterUserHandler_InvalidTimezone(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) tests := []struct { name string @@ -191,7 +195,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) { } _, err := handler.Handle(context.Background(), cmd) - if err != appErrors.ErrInvalidInput { + if !errors.Is(err, appErrors.ErrInvalidInput) { t.Errorf("expected ErrInvalidInput, got %v", err) } }) @@ -206,7 +210,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -215,7 +219,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) { } _, err := handler.Handle(context.Background(), cmd) - if err != appErrors.ErrAlreadyExists { + if !errors.Is(err, appErrors.ErrAlreadyExists) { t.Errorf("expected ErrAlreadyExists, got %v", err) } } @@ -228,7 +232,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) { return "", expectedErr }, } - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -250,7 +254,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { }, } hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -267,7 +271,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) { func TestRegisterUserHandler_EdgeCases(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "open") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false) tests := []struct { name string @@ -333,7 +337,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) { func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { repo := &mockUserRepo{} hasher := &mockPasswordHasher{} - handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed") + handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false) cmd := RegisterUserCommand{ Email: "test@example.com", @@ -342,7 +346,7 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) { } _, err := handler.Handle(context.Background(), cmd) - if err != appErrors.ErrRegistrationClosed { + if !errors.Is(err, appErrors.ErrRegistrationClosed) { t.Errorf("expected ErrRegistrationClosed, got %v", err) } } diff --git a/internal/application/commands/request_password_reset_handler.go b/internal/application/commands/request_password_reset_handler.go new file mode 100644 index 0000000..5444b70 --- /dev/null +++ b/internal/application/commands/request_password_reset_handler.go @@ -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(` +

Password Reset Request

+

You requested to reset your password. Click the link below to reset it:

+

Reset Password

+

This link will expire in 1 hour.

+

If you didn't request this, you can safely ignore this email.

+ `, 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 +} diff --git a/internal/application/commands/reset_password_handler.go b/internal/application/commands/reset_password_handler.go new file mode 100644 index 0000000..a38faf7 --- /dev/null +++ b/internal/application/commands/reset_password_handler.go @@ -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 +} diff --git a/internal/application/commands/verify_email_handler.go b/internal/application/commands/verify_email_handler.go index 326a759..81deaac 100644 --- a/internal/application/commands/verify_email_handler.go +++ b/internal/application/commands/verify_email_handler.go @@ -5,7 +5,9 @@ import ( "fmt" "time" + "apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/repositories" + "apocapoc-api/internal/domain/services" "apocapoc-api/internal/shared/errors" ) @@ -14,12 +16,20 @@ type VerifyEmailCommand 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{ - 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) } + if h.sendWelcomeEmail && h.emailService != nil { + h.sendWelcomeEmailToUser(user) + } + return nil } + +func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error { + emailBody := fmt.Sprintf(` +

Welcome to Apocapoc!

+

Your email has been successfully verified.

+

You can now start tracking your habits and building better routines.

+

If you have any questions or need help, please don't hesitate to contact us.

+ `) + + message := services.EmailMessage{ + To: user.Email, + Subject: "Welcome to Apocapoc!", + Body: emailBody, + IsHTML: true, + } + + return h.emailService.Send(message) +} diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index d1c8f1e..5e111fe 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -67,6 +67,10 @@ func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entitie return nil } +func (m *mockUserRepositoryForRefresh) Delete(ctx context.Context, id string) error { + return nil +} + func TestRefreshTokenHandler_Success(t *testing.T) { refreshTokenRepo := &mockRefreshTokenRepository{ findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) { diff --git a/internal/domain/entities/password_reset_token.go b/internal/domain/entities/password_reset_token.go new file mode 100644 index 0000000..a651495 --- /dev/null +++ b/internal/domain/entities/password_reset_token.go @@ -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 +} diff --git a/internal/domain/repositories/password_reset_token_repository.go b/internal/domain/repositories/password_reset_token_repository.go new file mode 100644 index 0000000..f3971e6 --- /dev/null +++ b/internal/domain/repositories/password_reset_token_repository.go @@ -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 +} diff --git a/internal/domain/repositories/user_repository.go b/internal/domain/repositories/user_repository.go index cf8740e..dc43a07 100644 --- a/internal/domain/repositories/user_repository.go +++ b/internal/domain/repositories/user_repository.go @@ -12,4 +12,5 @@ type UserRepository interface { 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 + Delete(ctx context.Context, id string) error } diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index 8a955e9..9546e9b 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -10,16 +10,16 @@ import ( type Config struct { DBPath string Port string - Host string + AppURL string JWTSecret string JWTExpiry string RefreshTokenExpiry string - CORSOrigins string DefaultTimezone string SMTPHost string SMTPPort string SMTPUser string SMTPPassword string + SMTPFrom string SupportEmail string SendWelcomeEmail string RegistrationMode string @@ -31,16 +31,16 @@ func Load() (*Config, error) { cfg := &Config{ DBPath: os.Getenv("DB_PATH"), Port: getEnvOrDefault("PORT", "8080"), - Host: getEnvOrDefault("HOST", "0.0.0.0"), + AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"), JWTSecret: os.Getenv("JWT_SECRET"), JWTExpiry: os.Getenv("JWT_EXPIRY"), RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"), - CORSOrigins: os.Getenv("CORS_ORIGINS"), 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"), + SMTPFrom: os.Getenv("SMTP_FROM"), SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"), SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"), RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"), @@ -49,6 +49,9 @@ func Load() (*Config, error) { if cfg.DBPath == "" { return nil, fmt.Errorf("DB_PATH is required") } + if cfg.AppURL == "" { + return nil, fmt.Errorf("APP_URL is required") + } if cfg.JWTSecret == "" { return nil, fmt.Errorf("JWT_SECRET is required") } @@ -58,9 +61,6 @@ func Load() (*Config, error) { if cfg.RefreshTokenExpiry == "" { return nil, fmt.Errorf("REFRESH_TOKEN_EXPIRY is required") } - if cfg.CORSOrigins == "" { - return nil, fmt.Errorf("CORS_ORIGINS is required") - } if cfg.DefaultTimezone == "" { return nil, fmt.Errorf("DEFAULT_TIMEZONE is required") } diff --git a/internal/infrastructure/email/smtp_service.go b/internal/infrastructure/email/smtp_service.go index 6a54868..497f288 100644 --- a/internal/infrastructure/email/smtp_service.go +++ b/internal/infrastructure/email/smtp_service.go @@ -3,6 +3,7 @@ package email import ( "crypto/tls" "fmt" + "strings" "time" "apocapoc-api/internal/domain/services" @@ -46,6 +47,11 @@ func (s *SMTPService) Send(message services.EmailMessage) error { 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 { 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 } else { 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 { 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 { diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index 908d2a5..8a761a4 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -2,6 +2,7 @@ package http import ( "encoding/json" + "errors" "net/http" "time" @@ -9,18 +10,22 @@ import ( "apocapoc-api/internal/application/queries" "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/infrastructure/auth" - "apocapoc-api/internal/shared/errors" + appErrors "apocapoc-api/internal/shared/errors" ) type AuthHandlers struct { - registerHandler *commands.RegisterUserHandler - loginHandler *queries.LoginUserHandler - refreshTokenHandler *queries.RefreshTokenHandler - revokeTokenHandler *commands.RevokeTokenHandler - revokeAllTokensHandler *commands.RevokeAllTokensHandler - jwtService *auth.JWTService - refreshTokenRepo repositories.RefreshTokenRepository - refreshTokenExpiry time.Duration + registerHandler *commands.RegisterUserHandler + loginHandler *queries.LoginUserHandler + refreshTokenHandler *queries.RefreshTokenHandler + revokeTokenHandler *commands.RevokeTokenHandler + revokeAllTokensHandler *commands.RevokeAllTokensHandler + verifyEmailHandler *commands.VerifyEmailHandler + resendVerificationEmailHandler *commands.ResendVerificationEmailHandler + requestPasswordResetHandler *commands.RequestPasswordResetHandler + resetPasswordHandler *commands.ResetPasswordHandler + jwtService *auth.JWTService + refreshTokenRepo repositories.RefreshTokenRepository + refreshTokenExpiry time.Duration } func NewAuthHandlers( @@ -29,19 +34,27 @@ func NewAuthHandlers( refreshTokenHandler *queries.RefreshTokenHandler, revokeTokenHandler *commands.RevokeTokenHandler, revokeAllTokensHandler *commands.RevokeAllTokensHandler, + verifyEmailHandler *commands.VerifyEmailHandler, + resendVerificationEmailHandler *commands.ResendVerificationEmailHandler, + requestPasswordResetHandler *commands.RequestPasswordResetHandler, + resetPasswordHandler *commands.ResetPasswordHandler, jwtService *auth.JWTService, refreshTokenRepo repositories.RefreshTokenRepository, refreshTokenExpiry time.Duration, ) *AuthHandlers { return &AuthHandlers{ - registerHandler: registerHandler, - loginHandler: loginHandler, - refreshTokenHandler: refreshTokenHandler, - revokeTokenHandler: revokeTokenHandler, - revokeAllTokensHandler: revokeAllTokensHandler, - jwtService: jwtService, - refreshTokenRepo: refreshTokenRepo, - refreshTokenExpiry: refreshTokenExpiry, + registerHandler: registerHandler, + loginHandler: loginHandler, + refreshTokenHandler: refreshTokenHandler, + revokeTokenHandler: revokeTokenHandler, + revokeAllTokensHandler: revokeAllTokensHandler, + verifyEmailHandler: verifyEmailHandler, + resendVerificationEmailHandler: resendVerificationEmailHandler, + requestPasswordResetHandler: requestPasswordResetHandler, + resetPasswordHandler: resetPasswordHandler, + jwtService: jwtService, + refreshTokenRepo: refreshTokenRepo, + refreshTokenExpiry: refreshTokenExpiry, } } @@ -103,15 +116,15 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { result, err := h.registerHandler.Handle(r.Context(), cmd) if err != nil { - if err == errors.ErrInvalidInput { - respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)") + if errors.Is(err, appErrors.ErrInvalidInput) { + respondValidationError(w, err) return } - if err == errors.ErrAlreadyExists { + if err == appErrors.ErrAlreadyExists { respondError(w, http.StatusConflict, "Email already registered") return } - if err == errors.ErrRegistrationClosed { + if err == appErrors.ErrRegistrationClosed { respondError(w, http.StatusForbidden, "Registration is currently closed") return } @@ -159,11 +172,11 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { result, err := h.loginHandler.Handle(r.Context(), query) 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") return } - if err == errors.ErrEmailNotVerified { + if err == appErrors.ErrEmailNotVerified { respondError(w, http.StatusForbidden, "Please verify your email before logging in") return } @@ -220,7 +233,7 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) { result, err := h.refreshTokenHandler.Handle(r.Context(), query) 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") return } @@ -280,11 +293,11 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { err := h.revokeTokenHandler.Handle(r.Context(), cmd) if err != nil { - if err == errors.ErrNotFound { + if err == appErrors.ErrNotFound { respondError(w, http.StatusNotFound, "Refresh token not found") return } - if err == errors.ErrInvalidInput { + if err == appErrors.ErrInvalidInput { respondError(w, http.StatusBadRequest, "Invalid refresh token") return } @@ -296,3 +309,199 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { "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", + }) +} diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index 1835a6e..2f7f06f 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -85,5 +85,6 @@ type HabitEntriesResponse struct { } type ErrorResponse struct { - Error string `json:"error"` + Error string `json:"error"` + Field *string `json:"field,omitempty"` } diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 6b22798..5f80fcb 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "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) { 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, + }) +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 7f9d30f..8dbdef3 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -40,12 +40,17 @@ func setupTestServer(t *testing.T) *TestServer { habitRepo := sqlite.NewHabitRepository(db) entryRepo := sqlite.NewHabitEntryRepository(db) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) + passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open") loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(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) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) @@ -59,12 +64,15 @@ func setupTestServer(t *testing.T) *TestServer { 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) statsHandlers := NewStatsHandlers(getHabitStatsHandler) 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) return &TestServer{ diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 46021f0..277eba5 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -15,13 +15,13 @@ import ( _ "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.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{corsOrigins}, + AllowedOrigins: []string{appURL}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: true, @@ -42,6 +42,10 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A r.Post("/login", authHandlers.Login) r.Post("/refresh", authHandlers.Refresh) 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) { @@ -65,5 +69,11 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A 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 } diff --git a/internal/infrastructure/http/user_handlers.go b/internal/infrastructure/http/user_handlers.go new file mode 100644 index 0000000..cc34f58 --- /dev/null +++ b/internal/infrastructure/http/user_handlers.go @@ -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", + }) +} diff --git a/internal/infrastructure/persistence/sqlite/migrations.go b/internal/infrastructure/persistence/sqlite/migrations.go index 862c382..02cfd26 100644 --- a/internal/infrastructure/persistence/sqlite/migrations.go +++ b/internal/infrastructure/persistence/sqlite/migrations.go @@ -11,6 +11,7 @@ func RunMigrations(db *sql.DB) error { createHabitsTable, createHabitEntriesTable, createRefreshTokensTable, + createPasswordResetTokensTable, 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 = ` 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); @@ -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_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_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); ` diff --git a/internal/infrastructure/persistence/sqlite/password_reset_token_repository.go b/internal/infrastructure/persistence/sqlite/password_reset_token_repository.go new file mode 100644 index 0000000..7e83995 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/password_reset_token_repository.go @@ -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 +} diff --git a/internal/infrastructure/persistence/sqlite/user_repository.go b/internal/infrastructure/persistence/sqlite/user_repository.go index e102969..fdc2f41 100644 --- a/internal/infrastructure/persistence/sqlite/user_repository.go +++ b/internal/infrastructure/persistence/sqlite/user_repository.go @@ -170,6 +170,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error 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 { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } diff --git a/internal/shared/constants/branding.go b/internal/shared/constants/branding.go index adb2a93..1e2403d 100644 --- a/internal/shared/constants/branding.go +++ b/internal/shared/constants/branding.go @@ -1,7 +1,5 @@ package constants const ( - AppName = "Apocapoc" - AppURL = "https://apocapoc.app" - DefaultFrom = "noreply@apocapoc.app" + AppName = "Apocapoc" )