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} } // userColumns is the canonical column list for SELECT/RETURNING on the // users table, in the exact order scanUser reads them. Keeping the two in // lockstep here is what makes adding a column a single-edit change instead // of a hunt across every query. const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan, email_verified_at, token_version, created_at, updated_at, country, sampradaya, gender, initiated, initiated_at, daily_chanting_rounds, preferred_chanting_time, peak_active_time` // rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so // scanUser works for single-row and iterated reads alike. type rowScanner interface { Scan(dest ...any) error } // scanUser reads one users row (selected via userColumns) into u. func scanUser(row rowScanner, u *model.User) error { return row.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, &u.Country, &u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds, &u.PreferredChantingTime, &u.PeakActiveTime, ) } // ProfileUpdate is the set of fields UpdateProfile can change. Every field // is a pointer so "leave unchanged" (nil) is distinct from "set". For the // nullable string fields, a non-nil pointer to "" means "clear to NULL"; // DailyChantingRounds uses *0 as its clear sentinel (1 is the real min). type ProfileUpdate struct { Name *string AvatarURL *string Handle *string Country *string Sampradaya *string // nil=leave, ""=clear, code=set Gender *string Initiated *bool InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear DailyChantingRounds *int // 0 to clear PreferredChantingTime *string PeakActiveTime *string } // 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) { q := ` INSERT INTO users (email, password_hash, name, handle) VALUES ($1, $2, $3, $4) RETURNING ` + userColumns u := &model.User{} err := scanUser(r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle, ), u) 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) { 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 ` + userColumns var normEmail *string if email != nil { v := strings.ToLower(strings.TrimSpace(*email)) if v != "" { normEmail = &v } } u := &model.User{} err := scanUser(r.pg.Pool.QueryRow(ctx, q, normEmail, name, handle, avatarURL, emailVerified, ), u) 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 ` + userColumns + ` FROM users ` + where + ` LIMIT 1` u := &model.User{} err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u) 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) { q := ` UPDATE users SET email = $2, email_verified_at = NULL, updated_at = now() WHERE id = $1 RETURNING ` + userColumns u := &model.User{} err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u) 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 applies a partial profile patch. Only the non-nil fields of // `p` are touched; the SQL is built dynamically so an untouched column is // never written (keeps updated_at honest and the patch minimal). For the // nullable string fields a non-nil "" clears the column to NULL. // // `handle` collisions surface as ErrHandleTaken so the handler can render // a useful message. func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) { sets := make([]string, 0, 10) args := []any{id} // set appends "col = $N" with a bound value. set := func(col string, val any) { args = append(args, val) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) } // setExpr appends a parameterless SET clause (e.g. "col = NULL"). setExpr := func(expr string) { sets = append(sets, expr) } // nullableString handles the nil=leave / ""=clear / value=set trio. nullableString := func(col string, v *string) { if v == nil { return } if *v == "" { setExpr(col + " = NULL") } else { set(col, *v) } } if p.Name != nil { set("name", *p.Name) } if p.Handle != nil { set("handle", *p.Handle) } nullableString("avatar_url", p.AvatarURL) nullableString("country", p.Country) nullableString("sampradaya", p.Sampradaya) nullableString("gender", p.Gender) nullableString("preferred_chanting_time", p.PreferredChantingTime) nullableString("peak_active_time", p.PeakActiveTime) if p.Initiated != nil { set("initiated", *p.Initiated) } if p.InitiatedAt != nil { if *p.InitiatedAt == "" { setExpr("initiated_at = NULL") } else { args = append(args, *p.InitiatedAt) sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args))) } } if p.DailyChantingRounds != nil { if *p.DailyChantingRounds == 0 { setExpr("daily_chanting_rounds = NULL") } else { set("daily_chanting_rounds", *p.DailyChantingRounds) } } // Nothing to change — return the current row rather than a no-op UPDATE. if len(sets) == 0 { return r.findOne(ctx, `WHERE id = $1`, id) } sets = append(sets, "updated_at = now()") q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns u := &model.User{} err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u) 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 } // SetCountryIfEmpty fills in the account country only when it's still // NULL — used by the best-effort signup capture, so a later manual edit // (or a second capture) never clobbers a value the user chose. `cc` is an // ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the // caller's to swallow (capture is fire-and-forget). func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error { cc = strings.ToUpper(strings.TrimSpace(cc)) if len(cc) != 2 { return nil } _, err := r.pg.Pool.Exec(ctx, ` UPDATE users SET country = $2, updated_at = now() WHERE id = $1 AND country IS NULL`, id, cc) if err != nil { return fmt.Errorf("set country: %w", err) } return 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, offset int) ([]model.User, int, error) { cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@") if cleaned == "" { return []model.User{}, 0, nil } if limit <= 0 || limit > 50 { limit = 20 } if offset < 0 { offset = 0 } substring := "%" + cleaned + "%" prefix := cleaned + "%" // Total matches for pagination — same WHERE as the page query. Cheap // enough at our user-table scale; revisit alongside the pg_trgm switch. var total int if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`, substring).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count search users: %w", err) } if total == 0 || offset >= total { return []model.User{}, total, nil } // The trailing `handle` tiebreaker makes the ordering total (handle is // unique) so OFFSET paging is stable — without it, rows sharing a rank // and name could reshuffle between pages. sql := `SELECT ` + userColumns + ` 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, handle LIMIT $3 OFFSET $4` rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset) if err != nil { return nil, 0, fmt.Errorf("search users: %w", err) } defer rows.Close() out := []model.User{} for rows.Next() { var u model.User if err := scanUser(rows, &u); err != nil { return nil, 0, fmt.Errorf("scan user: %w", err) } out = append(out, u) } return out, total, 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 }