login via magic link

This commit is contained in:
juancwu 2026-01-04 19:24:01 -05:00
commit 94a05b0433
22 changed files with 815 additions and 122 deletions

View file

@ -0,0 +1,60 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrProfileNotFound = errors.New("profile not found")
)
type ProfileRepository interface {
Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error)
}
type profileRepository struct {
db *sqlx.DB
}
func NewProfileRepository(db *sqlx.DB) *profileRepository {
return &profileRepository{db: db}
}
func (r *profileRepository) Create(profile *model.Profile) (string, error) {
if profile.CreatedAt.IsZero() {
profile.CreatedAt = time.Now()
}
if profile.UpdatedAt.IsZero() {
profile.UpdatedAt = time.Now()
}
_, err := r.db.Exec(`
INSERT INTO profiles (id, user_id, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
`, profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt)
if err != nil {
return "", err
}
return profile.ID, nil
}
func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
var profile model.Profile
err := r.db.Get(&profile, `SELECT * FROM profiles WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return &profile, nil
}

View file

@ -0,0 +1,77 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrTokenNotFound = errors.New("token not found")
)
type TokenRepository interface {
Create(token *model.Token) (string, error)
DeleteByUserAndType(userID string, tokenType string) error
ConsumeToken(token string) (*model.Token, error)
}
type tokenRepository struct {
db *sqlx.DB
}
func NewTokenRepository(db *sqlx.DB) *tokenRepository {
return &tokenRepository{db: db}
}
func (r *tokenRepository) Create(token *model.Token) (string, error) {
if token.CreatedAt.IsZero() {
token.CreatedAt = time.Now()
}
query := `
INSERT INTO tokens (id, user_id, type, token, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.Exec(query, token.ID, token.UserID, token.Type, token.Token, token.ExpiresAt, token.CreatedAt)
if err != nil {
return "", fmt.Errorf("failed to create token: %w", err)
}
return token.ID, nil
}
func (r *tokenRepository) DeleteByUserAndType(userID string, tokenType string) error {
query := `DELETE FROM tokens WHERE user_id = $1 AND type = $2 AND used_at IS NULL`
_, err := r.db.Exec(query, userID, tokenType)
return err
}
func (r *tokenRepository) ConsumeToken(tokenString string) (*model.Token, error) {
var token model.Token
now := time.Now()
query := `
UPDATE tokens
SET used_at = $1
WHERE token = $2
AND used_at IS NULL
AND expires_at > $3
RETURNING *
`
err := r.db.Get(&token, query, now, tokenString, now)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTokenNotFound
}
if err != nil {
return nil, err
}
return &token, nil
}

View file

@ -15,7 +15,7 @@ var (
)
type UserRepository interface {
Create(user *model.User) error
Create(user *model.User) (string, error)
ByID(id string) (*model.User, error)
ByEmail(email string) (*model.User, error)
Update(user *model.User) error
@ -30,19 +30,19 @@ func NewUserRepository(db *sqlx.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *model.User) error {
func (r *userRepository) Create(user *model.User) (string, error) {
query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateEmail
return "", ErrDuplicateEmail
}
return err
return "", err
}
return nil
return user.ID, nil
}
func (r *userRepository) ByID(id string) (*model.User, error) {
@ -58,15 +58,15 @@ func (r *userRepository) ByID(id string) (*model.User, error) {
}
func (r *userRepository) ByEmail(email string) (*model.User, error) {
user := &model.User{}
var user model.User
query := `SELECT * FROM users WHERE email = $1;`
err := r.db.Get(user, query, email)
err := r.db.Get(&user, query, email)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return user, err
return &user, err
}
func (r *userRepository) Update(user *model.User) error {