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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user