feat: add user centric features
This commit is contained in:
@@ -17,6 +17,9 @@ 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
|
||||
@@ -26,21 +29,25 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
||||
return &UserRepo{pg: pg}
|
||||
}
|
||||
|
||||
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
|
||||
// `email` is lowercased before insert; the column is CITEXT so case doesn't
|
||||
// matter for lookups, but lower-casing keeps stored values tidy.
|
||||
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
|
||||
// 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)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, password_hash, name, avatar_url, role,
|
||||
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).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
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
|
||||
}
|
||||
@@ -60,15 +67,21 @@ 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, password_hash, name, avatar_url, role,
|
||||
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.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
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) {
|
||||
@@ -79,16 +92,20 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
|
||||
// Returns the updated user.
|
||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
||||
// 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, password_hash, name, avatar_url, role,
|
||||
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)?"
|
||||
@@ -101,13 +118,16 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, a
|
||||
}
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user