Files
spsays-bff/internal/repository/user_repo.go
T
2026-06-16 14:47:45 +05:30

598 lines
20 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}
}
// 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, banned_at`
// 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, &u.BannedAt,
)
}
// 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()
}
// AllHandles returns every user's handle, for the public-profile sitemap.
// Projection-only (handle is unique + indexed), cheap to sweep. Excludes the
// reserved "me" handle (the signed-in user's own private library route).
func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT handle FROM users WHERE handle <> '' AND handle::text <> 'me' ORDER BY handle`)
if err != nil {
return nil, fmt.Errorf("list handles: %w", err)
}
defer rows.Close()
handles := []string{}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, fmt.Errorf("scan handle: %w", err)
}
handles = append(handles, h)
}
return handles, rows.Err()
}
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
// newest first, as a discovery list for the feed's "new on the platform"
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
// (pending or accepted), and anyone involved in a block either direction — so
// every suggestion is a fresh account the viewer can actually follow.
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
if limit <= 0 || limit > 20 {
limit = 5
}
const q = `
SELECT u.id, u.handle, u.name, u.avatar_url
FROM users u
WHERE u.created_at >= $2
AND u.id <> $1
AND NOT EXISTS (
SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id
)
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = $1 AND b.blocked_id = u.id)
OR (b.blocker_id = u.id AND b.blocked_id = $1)
)
ORDER BY u.created_at DESC
LIMIT $3`
rows, err := r.pg.Pool.Query(ctx, q, viewerID, since, limit)
if err != nil {
return nil, fmt.Errorf("recently joined: %w", err)
}
defer rows.Close()
out := []model.UserSummary{}
for rows.Next() {
var u model.UserSummary
if err := rows.Scan(&u.ID, &u.Handle, &u.Name, &u.AvatarURL); err != nil {
return nil, fmt.Errorf("scan recently joined: %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
}