feat: add user search

This commit is contained in:
Krispire Admin
2026-05-31 10:53:37 +05:30
parent 0969887008
commit d3ad443fde
4 changed files with 118 additions and 1 deletions
+52
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -53,6 +54,57 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, u) response.JSON(w, http.StatusOK, u)
} }
// userSearchItem is the public-safe slice of model.User returned by the
// search endpoint. Sensitive fields (email, password hash, role, plan,
// token_version, timestamps) are intentionally omitted.
type userSearchItem struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
}
// Search handles GET /api/v1/users/search?q=...&limit=...
//
// Public endpoint — anonymous and signed-in viewers alike can search
// for users by handle or display name. Returns a sanitized payload
// without auth-sensitive fields. Used by the global /search page when
// the query carries an "@" prefix.
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
})
return
}
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
}
users, err := h.svc.Search(r.Context(), q, limit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "search failed")
return
}
items := make([]userSearchItem, 0, len(users))
for _, u := range users {
items = append(items, userSearchItem{
Handle: u.Handle,
Name: u.Name,
AvatarURL: u.AvatarURL,
})
}
response.JSON(w, http.StatusOK, map[string]any{
"query": q,
"results": items,
})
}
// GetReadingPrefs returns the caller's saved reading prefs blob (always // GetReadingPrefs returns the caller's saved reading prefs blob (always
// well-formed JSON; an empty object when the user has never written). // well-formed JSON; an empty object when the user has never written).
// The frontend treats the response as the source of truth on first // The frontend treats the response as the source of truth on first
+54
View File
@@ -152,6 +152,60 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
return nil 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 // GetReadingPrefs returns the raw JSONB bytes for the user's reading
// prefs (empty object when never set). The frontend owns the shape, so // prefs (empty object when never set). The frontend owns the shape, so
// we round-trip the payload verbatim — no server-side schema enforced. // we round-trip the payload verbatim — no server-side schema enforced.
+6 -1
View File
@@ -475,12 +475,17 @@ func (s *Server) setupRouter() {
}) })
}) })
// User profile + avatar — all protected. // User profile — mixed public-read + protected writes.
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
if s.userH == nil { if s.userH == nil {
r.HandleFunc("/*", s.authUnavailable) r.HandleFunc("/*", s.authUnavailable)
return return
} }
// Public read — search by handle / name. Returns a
// sanitized payload (no email / role / plan / etc.).
r.Get("/search", s.userH.Search)
// Protected writes — profile edit, avatar presign,
// reading-prefs roaming.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Patch("/me", s.userH.UpdateProfile) r.Patch("/me", s.userH.UpdateProfile)
+6
View File
@@ -59,6 +59,12 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatar
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle) return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
} }
// Search returns users matching the query (handle or name substring).
// Used by the public user-search endpoint on the /search page.
func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
return s.users.Search(ctx, q, limit)
}
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON). // GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
// Used by the frontend to hydrate ReadingSettings on sign-in so the // Used by the frontend to hydrate ReadingSettings on sign-in so the
// theme/palette/font choices roam across devices. // theme/palette/font choices roam across devices.