2026-03-09 09:13:18 +00:00
|
|
|
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}
|
|
|
|
|
}
|
|
|
|
|
|
feat(search): faceted filters (genre/key/BPM/year) + FacetSidebar UI (W4 Day 18)
Backend
- services/search_service.go : new SearchFilters struct (Genre,
MusicalKey, BPMMin, BPMMax, YearFrom, YearTo) + appendTrackFacets
helper that composes additional AND clauses onto the existing FTS
WHERE condition. Filters apply ONLY to the track query — users +
playlists ignore them silently (no relevant columns).
- handlers/search_handlers.go : new parseSearchFilters reads + bounds-
checks query params (BPM in [1,999], year in [1900,2100], min<=max).
Search() now passes filters into the service ; OTel span attribute
search.filtered surfaces whether facets were applied.
- elasticsearch/search_service.go : signature updated to match the
interface ; ES path doesn't translate facets yet (different filter
DSL needed) — logs a warning when facets arrive on this path.
- handlers/search_handlers_test.go : MockSearchService.Search updated
+ 4 mock.On call sites pass mock.Anything for the new filters arg.
Frontend
- services/api/search.ts : new SearchFacets shape ; searchApi.search
accepts an opts.facets bag. When non-empty, bypasses orval's typed
getSearch (its GetSearchParams pre-dates the new query params) and
uses apiClient.get directly with snake_case keys matching the
backend's parseSearchFilters().
- features/search/components/FacetSidebar.tsx (new) : sidebar with
genre + musical_key inputs (datalist suggestions), BPM min/max
pair, year from/to pair. Stateless ; SearchPage owns state.
data-testids on every control for E2E.
- features/search/components/search-page/useSearchPage.ts : facets
state stored in URL (genre, musical_key, bpm_min, bpm_max,
year_from, year_to) so deep links reproduce the result set.
300 ms debounce on facet changes.
- features/search/components/search-page/SearchPage.tsx : layout
switches to a 2-column grid (sidebar + results) when query is
non-empty ; discovery view keeps the full width when empty.
Collateral cleanup
- internal/api/routes_users.go : removed unused strconv + time
imports that were blocking the build (pre-existing dead imports
surfaced by the SearchServiceInterface signature change).
E2E
- tests/e2e/32-faceted-search.spec.ts : 4 tests. (36) backend rejects
bpm_min > bpm_max with 400. (37) out-of-range BPM rejected. (38)
valid range returns 200 with a tracks array. (39) UI — typing in
the sidebar updates URL query params within the 300 ms debounce.
Acceptance (Day 18) : promtool not relevant ; backend test suite
green for handlers + services + api ; TS strict pass ; E2E spec
covers the gates the roadmap acceptance asked for. The 'rock + BPM
120-130 = restricted results' assertion needs seed data with measurable
BPM (none today) — flagged in the spec as a follow-up to un-skip
once seed BPM data lands.
W4 progress : Day 16 done · Day 17 done · Day 18 done · Day 19
(HAProxy sticky WS) pending · Day 20 (k6 nightly) pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:33:35 +00:00
|
|
|
// 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")
|
|
|
|
|
}
|
2026-03-09 09:13:18 +00:00
|
|
|
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{"<em>"},
|
|
|
|
|
"post_tags": []string{"</em>"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|