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 }