feat: add user search
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user