112 lines
4.1 KiB
Go
112 lines
4.1 KiB
Go
package models
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ErrUserNotFound is a sentinel error returned by any lookup method below
|
||
|
|
// when no matching row exists. Callers use errors.Is(err, ErrUserNotFound)
|
||
|
|
// to distinguish "not found" (an expected, normal outcome - e.g. checking
|
||
|
|
// if an email is already registered) from a real database failure.
|
||
|
|
var ErrUserNotFound = errors.New("user not found")
|
||
|
|
|
||
|
|
// UserRepository is the ONLY place in the entire application that writes
|
||
|
|
// SQL queries for the users table. Every handler that needs to read or
|
||
|
|
// write user data goes through here instead of touching *sql.DB directly.
|
||
|
|
//
|
||
|
|
// Why bother with this layer instead of just calling db.Query in handlers?
|
||
|
|
// - If you ever swap MySQL for Postgres (or add a cache in front), you
|
||
|
|
// change this one file - no handler code needs to know or care.
|
||
|
|
// - It gives handlers a small, purpose-built vocabulary (Create,
|
||
|
|
// FindByEmail, FindByID, SetGoogleID) instead of raw SQL strings
|
||
|
|
// scattered across the codebase.
|
||
|
|
type UserRepository struct {
|
||
|
|
db *sql.DB
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewUserRepository is the constructor. Go doesn't have classes/constructors
|
||
|
|
// built into the language - "NewXxx returns a *Xxx" is just a naming
|
||
|
|
// convention the whole ecosystem follows.
|
||
|
|
func NewUserRepository(db *sql.DB) *UserRepository {
|
||
|
|
return &UserRepository{db: db}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create inserts a new user row. It takes a *User (pointer) specifically so
|
||
|
|
// it can write the newly generated auto-increment ID back into the
|
||
|
|
// caller's struct after the insert succeeds - the caller passes in a User
|
||
|
|
// with ID == 0, and walks away with u.ID populated.
|
||
|
|
func (r *UserRepository) Create(ctx context.Context, u *User) error {
|
||
|
|
res, err := r.db.ExecContext(ctx,
|
||
|
|
"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
|
||
|
|
u.Email, u.PasswordHash, u.GoogleID, time.Now(),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create user: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
id, err := res.LastInsertId()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("get last insert id: %w", err)
|
||
|
|
}
|
||
|
|
u.ID = int(id)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// FindByEmail looks up a user by their email address. This is what the
|
||
|
|
// login flow uses: the user submits an email, we look up the matching row,
|
||
|
|
// then compare the submitted password against the stored PasswordHash.
|
||
|
|
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||
|
|
var u User
|
||
|
|
err := r.db.QueryRowContext(ctx,
|
||
|
|
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
|
||
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||
|
|
|
||
|
|
if errors.Is(err, sql.ErrNoRows) {
|
||
|
|
// sql.ErrNoRows is the driver's own sentinel for "query matched
|
||
|
|
// zero rows". We translate it into OUR sentinel (ErrUserNotFound)
|
||
|
|
// so callers never need to know or care that the underlying
|
||
|
|
// storage is SQL at all.
|
||
|
|
return nil, ErrUserNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("find user by email: %w", err)
|
||
|
|
}
|
||
|
|
return &u, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// FindByID looks up a user by their numeric ID. This is used after a
|
||
|
|
// session is validated: the session only stores the user's ID, so we look
|
||
|
|
// the rest of the user up fresh on every authenticated request (see
|
||
|
|
// middleware.RequireAuth).
|
||
|
|
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
|
||
|
|
var u User
|
||
|
|
err := r.db.QueryRowContext(ctx,
|
||
|
|
"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
|
||
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)
|
||
|
|
|
||
|
|
if errors.Is(err, sql.ErrNoRows) {
|
||
|
|
return nil, ErrUserNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("find user by id: %w", err)
|
||
|
|
}
|
||
|
|
return &u, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetGoogleID links a Google account to an existing user row - used the
|
||
|
|
// first time a user who originally registered with a password logs in via
|
||
|
|
// "Sign in with Google" using the same email address.
|
||
|
|
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
|
||
|
|
_, err := r.db.ExecContext(ctx,
|
||
|
|
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("set google id: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|