248 lines
8.6 KiB
Go
248 lines
8.6 KiB
Go
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
|
|
}
|