From d3ad443fdeadc641b73bfa1b30d00f4a379df56e Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 31 May 2026 10:53:37 +0530 Subject: [PATCH] feat: add user search --- internal/handler/user.go | 52 ++++++++++++++++++++++++++++++ internal/repository/user_repo.go | 54 ++++++++++++++++++++++++++++++++ internal/server/server.go | 7 ++++- internal/service/user_service.go | 6 ++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/internal/handler/user.go b/internal/handler/user.go index fcf5f58..57f3734 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "strings" "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) } +// 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 // well-formed JSON; an empty object when the user has never written). // The frontend treats the response as the source of truth on first diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 314d4a1..b4c7acc 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -152,6 +152,60 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error 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. diff --git a/internal/server/server.go b/internal/server/server.go index 081b135..5ccd009 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) { if s.userH == nil { r.HandleFunc("/*", s.authUnavailable) 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.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Patch("/me", s.userH.UpdateProfile) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index c6f69c5..1bd0077 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -59,6 +59,12 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatar 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). // Used by the frontend to hydrate ReadingSettings on sign-in so the // theme/palette/font choices roam across devices.