package elasticsearch import ( "bytes" "context" "encoding/json" "fmt" "strings" "veza-backend-api/internal/services" "go.uber.org/zap" ) // SearchService implements search via Elasticsearch (v0.10.2 F363, F364, F365) type SearchService struct { client *Client logger *zap.Logger } // NewSearchService creates an Elasticsearch-backed search service func NewSearchService(client *Client, logger *zap.Logger) *SearchService { return &SearchService{client: client, logger: logger} } // Search performs full-text search with BM25, fuzziness for typos (F364). // v1.0.9 W4 Day 18 — `filters` parameter added to match the // handlers.SearchServiceInterface contract. The Elasticsearch path // does NOT honor faceted filters today — this implementation is only // reachable when ELASTICSEARCH_URL is set, and applying SQL-style // facets to the ES query needs a different filter DSL. Documented as // a follow-up : the bool/filter clause translation is W5 territory. // In the meantime we accept the filters arg + log a warning when // non-empty so operators discover the gap before users do. func (s *SearchService) Search(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error) { if filters != nil && filters.HasAny() && s.logger != nil { s.logger.Warn("elasticsearch search service ignored faceted filters — fall-through ES path doesn't translate them yet") } return s.search(context.Background(), query, types, 10) } // Suggestions returns autocomplete suggestions (F365) func (s *SearchService) Suggestions(query string, limit int) (*services.SearchResult, error) { if limit <= 0 { limit = 5 } if limit > 20 { limit = 20 } return s.search(context.Background(), query, nil, limit) } func (s *SearchService) search(ctx context.Context, query string, types []string, limit int) (*services.SearchResult, error) { if s.client == nil { return nil, fmt.Errorf("elasticsearch client not configured") } searchAll := len(types) == 0 searchTracks := searchAll || strContains(types, "track") searchUsers := searchAll || strContains(types, "user") searchPlaylists := searchAll || strContains(types, "playlist") results := &services.SearchResult{} // F364: fuzziness for typo tolerance; no boost by popularity (ethical) if searchTracks { idx := indexName(s.client.Config.Index, IdxTracks) mq := map[string]interface{}{ "multi_match": map[string]interface{}{ "query": query, "fuzziness": "AUTO", "fields": []string{"title^2", "artist^2", "description", "album", "tags", "genre"}, "type": "best_fields", }, } tracks, err := s.runSearch(ctx, idx, mq, limit, []string{"title", "artist", "description"}) if err != nil { if s.logger != nil { s.logger.Warn("ES track search failed", zap.Error(err)) } } else { results.Tracks = s.mapTracks(tracks) } } if searchUsers { idx := indexName(s.client.Config.Index, IdxUsers) mq := map[string]interface{}{ "multi_match": map[string]interface{}{ "query": query, "fuzziness": "AUTO", "fields": []string{"username^2", "display_name^2", "bio"}, "type": "best_fields", }, } users, err := s.runSearch(ctx, idx, mq, limit, []string{"username", "display_name"}) if err != nil { if s.logger != nil { s.logger.Warn("ES user search failed", zap.Error(err)) } } else { results.Users = s.mapUsers(users) } } if searchPlaylists { idx := indexName(s.client.Config.Index, IdxPlaylists) mq := map[string]interface{}{ "multi_match": map[string]interface{}{ "query": query, "fuzziness": "AUTO", "fields": []string{"name^2", "description"}, "type": "best_fields", }, } pls, err := s.runSearch(ctx, idx, mq, limit, []string{"name", "description"}) if err != nil { if s.logger != nil { s.logger.Warn("ES playlist search failed", zap.Error(err)) } } else { results.Playlists = s.mapPlaylists(pls) } } return results, nil } type esHit struct { Source map[string]interface{} `json:"_source"` } type esSearchResp struct { Hits struct { Hits []esHit `json:"hits"` } `json:"hits"` } func (s *SearchService) runSearch(ctx context.Context, index string, query map[string]interface{}, limit int, highlightFields []string) ([]esHit, error) { body := map[string]interface{}{ "query": map[string]interface{}{ "bool": map[string]interface{}{ "must": []interface{}{query}, }, }, "size": limit, } if len(highlightFields) > 0 { hlFields := make(map[string]interface{}) for _, f := range highlightFields { hlFields[f] = map[string]interface{}{} } body["highlight"] = map[string]interface{}{ "fields": hlFields, "pre_tags": []string{""}, "post_tags": []string{""}, } } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { return nil, err } res, err := s.client.Search( s.client.Search.WithContext(ctx), s.client.Search.WithIndex(index), s.client.Search.WithBody(&buf), ) if err != nil { return nil, err } defer res.Body.Close() if res.IsError() { return nil, fmt.Errorf("ES search: %s", res.String()) } var out esSearchResp if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return nil, err } hits := make([]esHit, len(out.Hits.Hits)) for i, h := range out.Hits.Hits { hits[i] = h } return hits, nil } func (s *SearchService) mapTracks(hits []esHit) []services.TrackResult { out := make([]services.TrackResult, 0, len(hits)) for _, h := range hits { tr := services.TrackResult{} if v, ok := h.Source["id"].(string); ok { tr.ID = v } if v, ok := h.Source["title"].(string); ok { tr.Title = v } if v, ok := h.Source["artist"].(string); ok { tr.Artist = v } if v, ok := h.Source["stream_manifest_url"].(string); ok && v != "" { tr.URL = v } else if v, ok := h.Source["file_path"].(string); ok { tr.URL = v } if v, ok := h.Source["cover_art_path"].(string); ok { tr.CoverArtPath = v } out = append(out, tr) } return out } func (s *SearchService) mapUsers(hits []esHit) []services.UserResult { out := make([]services.UserResult, 0, len(hits)) for _, h := range hits { ur := services.UserResult{} if v, ok := h.Source["id"].(string); ok { ur.ID = v } if v, ok := h.Source["username"].(string); ok { ur.Username = v } if v, ok := h.Source["avatar"].(string); ok { ur.AvatarURL = v } out = append(out, ur) } return out } func (s *SearchService) mapPlaylists(hits []esHit) []services.PlaylistResult { out := make([]services.PlaylistResult, 0, len(hits)) for _, h := range hits { pr := services.PlaylistResult{} if v, ok := h.Source["id"].(string); ok { pr.ID = v } if v, ok := h.Source["name"].(string); ok { pr.Name = v } if v, ok := h.Source["cover_url"].(string); ok { pr.Cover = v } out = append(out, pr) } return out } func strContains(slice []string, item string) bool { for _, s := range slice { if strings.EqualFold(s, item) { return true } } return false }