package repository import ( "context" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" ) // ErrUserNotFound is returned by lookup methods when no row matches. var ErrUserNotFound = errors.New("user not found") // ErrEmailTaken is returned by Create when the email already exists. var ErrEmailTaken = errors.New("email already registered") // ErrHandleTaken is returned when the chosen handle collides with an existing user. var ErrHandleTaken = errors.New("handle already in use") // UserRepo provides CRUD on the users table. type UserRepo struct { pg *Postgres } func NewUserRepo(pg *Postgres) *UserRepo { return &UserRepo{pg: pg} } // Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on // unique-violation. The caller is responsible for picking a unique handle — // the auth service does this with a retry loop. func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) { const q = ` INSERT INTO users (email, password_hash, name, handle) VALUES ($1, $2, $3, $4) RETURNING id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at` u := &model.User{} err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle, ).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) if err != nil { if isUniqueViolation(err, "users_handle_key") { return nil, ErrHandleTaken } if isUniqueViolation(err, "users_email_key") { return nil, ErrEmailTaken } return nil, fmt.Errorf("create user: %w", err) } return u, nil } // FindByEmail returns the user with the given email, or ErrUserNotFound. func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) { return r.findOne(ctx, `WHERE email = $1`, strings.ToLower(strings.TrimSpace(email))) } // FindByID returns the user with the given id, or ErrUserNotFound. func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) { return r.findOne(ctx, `WHERE id = $1`, id) } // FindByHandle returns the user with the given handle (case-insensitive // thanks to the citext column), or ErrUserNotFound. func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) { return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle)) } func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) { q := ` SELECT id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at FROM users ` + where + ` LIMIT 1` u := &model.User{} err := r.pg.Pool.QueryRow(ctx, q, args...). Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("find user: %w", err) } return u, nil } // UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to // leave a field unchanged. Returns the updated user. // // `handle` collisions surface as ErrHandleTaken so the handler can render // a useful message. func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) { const q = ` UPDATE users SET name = COALESCE($2, name), avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END, handle = COALESCE($5, handle), updated_at = now() WHERE id = $1 RETURNING id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at` // $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?" // If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which // may itself be the empty string — caller decides if "" should map to NULL). updateAvatar := avatarURL != nil var avatarVal *string if updateAvatar && *avatarURL != "" { avatarVal = avatarURL } u := &model.User{} err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle). Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } if isUniqueViolation(err, "users_handle_key") { return nil, ErrHandleTaken } return nil, fmt.Errorf("update profile: %w", err) } return u, nil } // UpdatePassword sets a new password hash and bumps token_version so any // existing JWTs immediately become invalid. func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error { const q = ` UPDATE users SET password_hash = $2, token_version = token_version + 1, updated_at = now() WHERE id = $1` tag, err := r.pg.Pool.Exec(ctx, q, id, newHash) if err != nil { return fmt.Errorf("update password: %w", err) } if tag.RowsAffected() == 0 { return ErrUserNotFound } return nil } // MarkEmailVerified sets email_verified_at = now() if not already set. func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error { const q = `UPDATE users SET email_verified_at = now(), updated_at = now() WHERE id = $1 AND email_verified_at IS NULL` _, err := r.pg.Pool.Exec(ctx, q, id) if err != nil { return fmt.Errorf("mark verified: %w", err) } return nil } // Helper for unique-violation detection. Avoids importing pgconn just for // the SQLSTATE; we string-match on the message which contains the constraint // name. Good enough for the one constraint we care about. func isUniqueViolation(err error, constraint string) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "23505") || strings.Contains(msg, constraint) } // AuthTokenRepo handles password_reset + email_verification tokens. type AuthTokenRepo struct { pg *Postgres } func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo { return &AuthTokenRepo{pg: pg} } // Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the // raw token; only the hash is stored, never the plaintext. func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) { const q = ` INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at) VALUES ($1, $2, $3, $4) RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at` t := &model.AuthToken{} err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)). Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) if err != nil { return nil, fmt.Errorf("create auth_token: %w", err) } return t, nil } // FindValid returns a non-used, non-expired token by hash + purpose. func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) { const q = ` SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at FROM auth_tokens WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now() LIMIT 1` t := &model.AuthToken{} err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose). Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("find token: %w", err) } return t, nil } // Consume marks a token as used. After this call the token can never be // honoured again, even if it has not yet expired. func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error { const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL` _, err := r.pg.Pool.Exec(ctx, q, id) if err != nil { return fmt.Errorf("consume token: %w", err) } return nil } // InvalidatePending marks all not-yet-used tokens for a user+purpose as used. // Called when a fresh token is issued so old links are immediately dead. func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error { const q = `UPDATE auth_tokens SET used_at = now() WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL` _, err := r.pg.Pool.Exec(ctx, q, userID, purpose) if err != nil { return fmt.Errorf("invalidate pending tokens: %w", err) } return nil }