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:
2025-11-28 11:32:51 +01:00
parent 00f6b51228
commit f37c1ac19b
24 changed files with 850 additions and 78 deletions
+3 -3
View File
@@ -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
+14 -6
View File
@@ -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 {
@@ -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
}
+8 -10
View File
@@ -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,
@@ -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)
}
}
@@ -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"
"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(`
<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
}
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) {
@@ -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)
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
Update(ctx context.Context, user *entities.User) error
Delete(ctx context.Context, id string) error
}
+7 -7
View File
@@ -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")
}
+30 -1
View File
@@ -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 {
+235 -26
View File
@@ -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",
})
}
+2 -1
View File
@@ -85,5 +85,6 @@ type HabitEntriesResponse struct {
}
type ErrorResponse struct {
Error string `json:"error"`
Error string `json:"error"`
Field *string `json:"field,omitempty"`
}
@@ -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,
})
}
@@ -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{
+12 -2
View File
@@ -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
}
@@ -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,
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);
`
@@ -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
}
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")
}
+1 -3
View File
@@ -1,7 +1,5 @@
package constants
const (
AppName = "Apocapoc"
AppURL = "https://apocapoc.app"
DefaultFrom = "noreply@apocapoc.app"
AppName = "Apocapoc"
)