package handlers import ( "fmt" "net/http" "strconv" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" "veza-backend-api/internal/tracing" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) var SearchHandlersInstance *SearchHandlers // SearchServiceInterface defines the interface for search operations // This allows for easier testing with mocks type SearchServiceInterface interface { Search(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error) Suggestions(query string, limit int) (*services.SearchResult, error) } type SearchHandlers struct { searchService SearchServiceInterface } func NewSearchHandlers(searchService *services.SearchService) { SearchHandlersInstance = &SearchHandlers{ searchService: searchService, } } // NewSearchHandlersWithInterface creates new search handlers with an interface (for testing) func NewSearchHandlersWithInterface(searchService SearchServiceInterface) *SearchHandlers { SearchHandlersInstance = &SearchHandlers{ searchService: searchService, } return SearchHandlersInstance } // Search performs a full-text search across tracks, users, and playlists // @Summary Unified search // @Description Postgres FTS-backed search across tracks, users, and playlists. Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). v1.0.9 W4 Day 18 adds faceted filters on tracks: genre, musical_key, bpm_min, bpm_max, year_from, year_to (all optional ; ignored on user/playlist results). // @Tags Search // @Produce json // @Param q query string true "Search query" // @Param type query []string false "Restrict to one or more entity types: track, user, playlist" collectionFormat(multi) // @Param genre query string false "Filter tracks by genre (exact match)" // @Param musical_key query string false "Filter tracks by musical key (e.g. C, Am)" // @Param bpm_min query int false "Minimum BPM (1..999)" // @Param bpm_max query int false "Maximum BPM (1..999)" // @Param year_from query int false "Minimum release year (1900..2100)" // @Param year_to query int false "Maximum release year (1900..2100)" // @Param cursor query string false "Opaque pagination cursor" // @Param limit query int false "Page size (max 50)" // @Success 200 {object} handlers.APIResponse "Search results" // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 500 {object} handlers.APIResponse "Search failed" // @Router /search [get] func (sh *SearchHandlers) Search(c *gin.Context) { query := c.Query("q") if query == "" { RespondWithAppError(c, apperrors.NewValidationError("Search query is required")) return } types := c.QueryArray("type") filters, filterErr := parseSearchFilters(c) if filterErr != nil { RespondWithAppError(c, apperrors.NewValidationError(filterErr.Error())) return } // v1.0.9 Day 9 — search.query span. Hot path: every search bar press // hits this. Query content is NOT recorded (PII / search history is // sensitive); only length + types so cardinality stays bounded. _, span := otel.Tracer(tracing.TracerName).Start(c.Request.Context(), "search.query", trace.WithAttributes( attribute.Int("search.query_length", len(query)), attribute.StringSlice("search.types", types), attribute.Bool("search.filtered", filters.HasAny()), ), ) defer span.End() results, err := sh.searchService.Search(query, types, filters) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "search failed") RespondWithAppError(c, apperrors.NewInternalErrorWrap("Search failed", err)) return } RespondSuccess(c, http.StatusOK, results) } // parseSearchFilters reads the faceted-search query params and // validates bounds. Returns a non-nil *SearchFilters with zero values // for any param that wasn't sent ; service-side appendTrackFacets // skips zero-valued fields silently. // // v1.0.9 W4 Day 18. func parseSearchFilters(c *gin.Context) (*services.SearchFilters, error) { f := &services.SearchFilters{ Genre: c.Query("genre"), MusicalKey: c.Query("musical_key"), } if v := c.Query("bpm_min"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 999 { return nil, fmt.Errorf("bpm_min must be an integer in [1, 999]") } f.BPMMin = n } if v := c.Query("bpm_max"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 999 { return nil, fmt.Errorf("bpm_max must be an integer in [1, 999]") } f.BPMMax = n } if f.BPMMin > 0 && f.BPMMax > 0 && f.BPMMin > f.BPMMax { return nil, fmt.Errorf("bpm_min cannot exceed bpm_max") } if v := c.Query("year_from"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1900 || n > 2100 { return nil, fmt.Errorf("year_from must be an integer in [1900, 2100]") } f.YearFrom = n } if v := c.Query("year_to"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1900 || n > 2100 { return nil, fmt.Errorf("year_to must be an integer in [1900, 2100]") } f.YearTo = n } if f.YearFrom > 0 && f.YearTo > 0 && f.YearFrom > f.YearTo { return nil, fmt.Errorf("year_from cannot exceed year_to") } return f, nil } // Suggestions returns autocomplete suggestions for the search input // @Summary Search suggestions // @Description Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at `limit` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. // @Tags Search // @Produce json // @Param q query string true "Partial query (min 1 char)" // @Param limit query int false "Max number of suggestions (1..20, default 5)" // @Success 200 {object} handlers.APIResponse "Suggestions" // @Failure 400 {object} handlers.APIResponse "Validation error" // @Failure 500 {object} handlers.APIResponse "Suggestions failed" // @Router /search/suggestions [get] func (sh *SearchHandlers) Suggestions(c *gin.Context) { query := c.Query("q") if query == "" { RespondWithAppError(c, apperrors.NewValidationError("Query parameter 'q' is required")) return } limit := 5 if l := c.Query("limit"); l != "" { if n, err := parseInt(l); err == nil && n > 0 && n <= 20 { limit = n } } results, err := sh.searchService.Suggestions(query, limit) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Suggestions failed", err)) return } RespondSuccess(c, http.StatusOK, results) } func parseInt(s string) (int, error) { return strconv.Atoi(s) }