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))) } // CreateOAuth inserts a user with a NULL password_hash — used by the OAuth // sign-in flow when there's no matching email yet. Email is nullable here: // providers that don't return one (e.g. phone-only Facebook accounts) end // up as users with email=NULL until they fill it in via profile settings. // Same handle-collision retry contract as Create. func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) { const q = ` INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at) VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END) RETURNING id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at` var normEmail *string if email != nil { v := strings.ToLower(strings.TrimSpace(*email)) if v != "" { normEmail = &v } } u := &model.User{} err := r.pg.Pool.QueryRow(ctx, q, normEmail, name, handle, avatarURL, emailVerified, ).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 oauth user: %w", err) } return u, nil } // 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 } // SetEmail fills in (or replaces) a user's email and resets email_verified_at // to NULL so a fresh verification flow can run. Returns ErrEmailTaken if // another row already owns this address. // // Used by the "add your email later" flow for OAuth users who signed up // without one. The caller (auth service) is expected to follow up by // minting and sending a verification token. func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) { const q = ` UPDATE users SET email = $2, email_verified_at = NULL, 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` u := &model.User{} err := r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))). 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_email_key") { return nil, ErrEmailTaken } return nil, fmt.Errorf("set email: %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 } // Search returns users whose name or handle matches `q` (ILIKE // substring). Leading "@" is stripped — callers can pass either // "@foo" or "foo". Ordering bubbles handle-prefix matches above // name-prefix above the rest, then alphabetic by name as the // tiebreaker. // // Postgres ILIKE is fine at our user-table scale (low six figures // max). When the table grows enough to warrant fuzzy / trigram // matching, add a pg_trgm GIN index and switch to similarity(). func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.User, error) { cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@") if cleaned == "" { return []model.User{}, nil } if limit <= 0 || limit > 50 { limit = 20 } substring := "%" + cleaned + "%" prefix := cleaned + "%" const sql = ` SELECT id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1 ORDER BY CASE WHEN handle::text ILIKE $2 THEN 0 WHEN name ILIKE $2 THEN 1 ELSE 2 END, name LIMIT $3` rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit) if err != nil { return nil, fmt.Errorf("search users: %w", err) } defer rows.Close() out := []model.User{} for rows.Next() { var u model.User if err := rows.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, ); err != nil { return nil, fmt.Errorf("scan user: %w", err) } out = append(out, u) } return out, rows.Err() } // GetReadingPrefs returns the raw JSONB bytes for the user's reading // prefs (empty object when never set). The frontend owns the shape, so // we round-trip the payload verbatim — no server-side schema enforced. func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) { const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1` var raw []byte err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("get reading prefs: %w", err) } if len(raw) == 0 { raw = []byte("{}") } return raw, nil } // UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is // responsible for validating that `prefsJSON` is well-formed JSON (the // handler does this by re-marshalling the decoded struct before calling // in). func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error { const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1` tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON) if err != nil { return fmt.Errorf("update reading prefs: %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 } // FindValidForUser returns a non-used, non-expired token by user + hash + // purpose. Used by the OTP login flow where the keyspace is small (6 // digits) — scoping by user_id prevents a global hash-collision lookup. func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, 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 user_id = $1 AND token_hash = $2 AND purpose = $3 AND used_at IS NULL AND expires_at > now() LIMIT 1` t := &model.AuthToken{} err := r.pg.Pool.QueryRow(ctx, q, userID, 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 }