veza/veza-backend-api/internal/handlers/search_handlers.go

184 lines
6.8 KiB
Go
Raw Normal View History

2025-12-03 19:29:37 +00:00
package handlers
import (
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
"fmt"
2025-12-03 19:29:37 +00:00
"net/http"
"strconv"
2025-12-03 19:29:37 +00:00
2026-03-06 18:13:16 +00:00
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9) Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a collector, which tail-samples (errors + slow always, 10% rest) and ships to Tempo. Grafana service-map dashboard pivots on the 4 instrumented hot paths. - internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown, BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler, W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true short-circuits to a no-op. Failure to dial collector is non-fatal. - cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion ldflag-overridable for resource attributes. - 4 hot paths instrumented : * handlers/auth.go::Login → "auth.login" * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate" * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook" * handlers/search_handlers.go::Search → "search.query" PII guarded — email masked, query content not recorded (length only). - infra/ansible/roles/otel_collector : pin v0.116.1 contrib build, systemd unit, tail-sampling config (errors + > 500ms always kept). - infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend (S3 deferred to v1.1), 14d retention. - infra/ansible/playbooks/observability.yml : provisions both Incus containers + applies common baseline + roles in order. - inventory/lab.yml : new groups observability, otel_collectors, tempo. - config/grafana/dashboards/service-map.json : node graph + 4 hot-path span tables + collector throughput/queue panels. - docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented. Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab deployment to validate with `ansible-playbook -i inventory/lab.yml playbooks/observability.yml` once roles/postgres_ha is up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:11 +00:00
"veza-backend-api/internal/tracing"
2025-12-03 19:29:37 +00:00
"github.com/gin-gonic/gin"
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9) Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a collector, which tail-samples (errors + slow always, 10% rest) and ships to Tempo. Grafana service-map dashboard pivots on the 4 instrumented hot paths. - internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown, BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler, W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true short-circuits to a no-op. Failure to dial collector is non-fatal. - cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion ldflag-overridable for resource attributes. - 4 hot paths instrumented : * handlers/auth.go::Login → "auth.login" * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate" * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook" * handlers/search_handlers.go::Search → "search.query" PII guarded — email masked, query content not recorded (length only). - infra/ansible/roles/otel_collector : pin v0.116.1 contrib build, systemd unit, tail-sampling config (errors + > 500ms always kept). - infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend (S3 deferred to v1.1), 14d retention. - infra/ansible/playbooks/observability.yml : provisions both Incus containers + applies common baseline + roles in order. - inventory/lab.yml : new groups observability, otel_collectors, tempo. - config/grafana/dashboards/service-map.json : node graph + 4 hot-path span tables + collector throughput/queue panels. - docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented. Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab deployment to validate with `ansible-playbook -i inventory/lab.yml playbooks/observability.yml` once roles/postgres_ha is up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:11 +00:00
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
2025-12-03 19:29:37 +00:00
)
var SearchHandlersInstance *SearchHandlers
// SearchServiceInterface defines the interface for search operations
// This allows for easier testing with mocks
type SearchServiceInterface interface {
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(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error)
Suggestions(query string, limit int) (*services.SearchResult, error)
}
2025-12-03 19:29:37 +00:00
type SearchHandlers struct {
searchService SearchServiceInterface
2025-12-03 19:29:37 +00:00
}
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
}
2025-12-03 19:29:37 +00:00
// Search performs a full-text search across tracks, users, and playlists
// @Summary Unified search
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
// @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
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
// @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]
2025-12-03 19:29:37 +00:00
func (sh *SearchHandlers) Search(c *gin.Context) {
query := c.Query("q")
if query == "" {
2026-03-06 18:13:16 +00:00
RespondWithAppError(c, apperrors.NewValidationError("Search query is required"))
2025-12-03 19:29:37 +00:00
return
}
types := c.QueryArray("type")
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
filters, filterErr := parseSearchFilters(c)
if filterErr != nil {
RespondWithAppError(c, apperrors.NewValidationError(filterErr.Error()))
return
}
2025-12-03 19:29:37 +00:00
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9) Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a collector, which tail-samples (errors + slow always, 10% rest) and ships to Tempo. Grafana service-map dashboard pivots on the 4 instrumented hot paths. - internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown, BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler, W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true short-circuits to a no-op. Failure to dial collector is non-fatal. - cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion ldflag-overridable for resource attributes. - 4 hot paths instrumented : * handlers/auth.go::Login → "auth.login" * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate" * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook" * handlers/search_handlers.go::Search → "search.query" PII guarded — email masked, query content not recorded (length only). - infra/ansible/roles/otel_collector : pin v0.116.1 contrib build, systemd unit, tail-sampling config (errors + > 500ms always kept). - infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend (S3 deferred to v1.1), 14d retention. - infra/ansible/playbooks/observability.yml : provisions both Incus containers + applies common baseline + roles in order. - inventory/lab.yml : new groups observability, otel_collectors, tempo. - config/grafana/dashboards/service-map.json : node graph + 4 hot-path span tables + collector throughput/queue panels. - docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented. Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab deployment to validate with `ansible-playbook -i inventory/lab.yml playbooks/observability.yml` once roles/postgres_ha is up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:11 +00:00
// 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),
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
attribute.Bool("search.filtered", filters.HasAny()),
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9) Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a collector, which tail-samples (errors + slow always, 10% rest) and ships to Tempo. Grafana service-map dashboard pivots on the 4 instrumented hot paths. - internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown, BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler, W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true short-circuits to a no-op. Failure to dial collector is non-fatal. - cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion ldflag-overridable for resource attributes. - 4 hot paths instrumented : * handlers/auth.go::Login → "auth.login" * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate" * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook" * handlers/search_handlers.go::Search → "search.query" PII guarded — email masked, query content not recorded (length only). - infra/ansible/roles/otel_collector : pin v0.116.1 contrib build, systemd unit, tail-sampling config (errors + > 500ms always kept). - infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend (S3 deferred to v1.1), 14d retention. - infra/ansible/playbooks/observability.yml : provisions both Incus containers + applies common baseline + roles in order. - inventory/lab.yml : new groups observability, otel_collectors, tempo. - config/grafana/dashboards/service-map.json : node graph + 4 hot-path span tables + collector throughput/queue panels. - docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented. Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab deployment to validate with `ansible-playbook -i inventory/lab.yml playbooks/observability.yml` once roles/postgres_ha is up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:11 +00:00
),
)
defer span.End()
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
results, err := sh.searchService.Search(query, types, filters)
2025-12-03 19:29:37 +00:00
if err != nil {
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9) Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a collector, which tail-samples (errors + slow always, 10% rest) and ships to Tempo. Grafana service-map dashboard pivots on the 4 instrumented hot paths. - internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown, BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler, W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true short-circuits to a no-op. Failure to dial collector is non-fatal. - cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion ldflag-overridable for resource attributes. - 4 hot paths instrumented : * handlers/auth.go::Login → "auth.login" * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate" * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook" * handlers/search_handlers.go::Search → "search.query" PII guarded — email masked, query content not recorded (length only). - infra/ansible/roles/otel_collector : pin v0.116.1 contrib build, systemd unit, tail-sampling config (errors + > 500ms always kept). - infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend (S3 deferred to v1.1), 14d retention. - infra/ansible/playbooks/observability.yml : provisions both Incus containers + applies common baseline + roles in order. - inventory/lab.yml : new groups observability, otel_collectors, tempo. - config/grafana/dashboards/service-map.json : node graph + 4 hot-path span tables + collector throughput/queue panels. - docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented. Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab deployment to validate with `ansible-playbook -i inventory/lab.yml playbooks/observability.yml` once roles/postgres_ha is up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:11 +00:00
span.RecordError(err)
span.SetStatus(codes.Error, "search failed")
2026-03-06 18:13:16 +00:00
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Search failed", err))
2025-12-03 19:29:37 +00:00
return
}
RespondSuccess(c, http.StatusOK, results)
}
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
// 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 == "" {
2026-03-06 18:13:16 +00:00
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 {
2026-03-06 18:13:16 +00:00
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Suggestions failed", err))
return
}
RespondSuccess(c, http.StatusOK, results)
}
func parseInt(s string) (int, error) {
return strconv.Atoi(s)
}