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
+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")
}