feat(search): faceted filters (genre/key/BPM/year) + FacetSidebar UI (W4 Day 18)
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m35s
E2E Playwright / e2e (full) (push) Failing after 9m56s
Veza CI / Frontend (Web) (push) Failing after 15m21s
Veza CI / Notify on failure (push) Successful in 4s
Veza CI / Backend (Go) (push) Failing after 4m44s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 39s

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>
This commit is contained in:
senke 2026-04-29 10:33:35 +02:00
parent d5152d89a2
commit 44349ec444
10 changed files with 577 additions and 56 deletions

View file

@ -0,0 +1,206 @@
import { useId } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { SearchFacets } from '@/services/api/search';
// FacetSidebar — v1.0.9 W4 Day 18.
//
// Sidebar with the faceted-search filters that the backend
// (search_handlers.go::parseSearchFilters) accepts on /api/v1/search :
// genre + musical_key + bpm_min/bpm_max + year_from/year_to.
//
// Stateless ; the SearchPage owns the FacetState + the debounce that
// re-issues the query when a value changes. Designed to slot into the
// SearchPage left rail at md+ widths and collapse to a top sheet at
// mobile widths (caller wraps it in a sheet / drawer).
const COMMON_GENRES = [
'electronic',
'rock',
'hip-hop',
'jazz',
'classical',
'ambient',
'house',
'techno',
'lo-fi',
'pop',
] as const;
const COMMON_KEYS = [
'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B',
'Cm', 'C#m', 'Dm', 'D#m', 'Em', 'Fm', 'F#m', 'Gm', 'G#m', 'Am', 'A#m', 'Bm',
] as const;
export interface FacetSidebarProps {
value: SearchFacets;
onChange: (next: SearchFacets) => void;
/** Optional override of the suggested genre chips. */
suggestedGenres?: readonly string[];
}
export function FacetSidebar({ value, onChange, suggestedGenres = COMMON_GENRES }: FacetSidebarProps) {
const idGenre = useId();
const idKey = useId();
const idBpmMin = useId();
const idBpmMax = useId();
const idYearFrom = useId();
const idYearTo = useId();
const update = (patch: Partial<SearchFacets>) => onChange({ ...value, ...patch });
const clear = () => onChange({});
const isEmpty =
!value.genre &&
!value.musicalKey &&
!value.bpmMin &&
!value.bpmMax &&
!value.yearFrom &&
!value.yearTo;
return (
<aside
data-testid="facet-sidebar"
className="w-full md:w-64 shrink-0 border-r border-border md:pr-4 md:py-2 space-y-5"
aria-label="Search filters"
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">Filters</h2>
{!isEmpty && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={clear}
data-testid="facet-clear"
aria-label="Clear all filters"
>
Clear
</Button>
)}
</div>
<div className="space-y-2">
<Label htmlFor={idGenre}>Genre</Label>
<Input
id={idGenre}
data-testid="facet-genre"
value={value.genre ?? ''}
onChange={(e) => update({ genre: e.target.value || undefined })}
placeholder="ex. electronic"
list={`${idGenre}-suggestions`}
/>
<datalist id={`${idGenre}-suggestions`}>
{suggestedGenres.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="space-y-2">
<Label htmlFor={idKey}>Musical key</Label>
<Input
id={idKey}
data-testid="facet-key"
value={value.musicalKey ?? ''}
onChange={(e) => update({ musicalKey: e.target.value || undefined })}
placeholder="ex. Am, C#"
list={`${idKey}-suggestions`}
/>
<datalist id={`${idKey}-suggestions`}>
{COMMON_KEYS.map((k) => (
<option key={k} value={k} />
))}
</datalist>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">BPM range</legend>
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor={idBpmMin} className="sr-only">
Minimum BPM
</Label>
<Input
id={idBpmMin}
data-testid="facet-bpm-min"
type="number"
min={1}
max={999}
inputMode="numeric"
value={value.bpmMin ?? ''}
onChange={(e) => update({ bpmMin: parsePositiveInt(e.target.value) })}
placeholder="min"
/>
</div>
<div>
<Label htmlFor={idBpmMax} className="sr-only">
Maximum BPM
</Label>
<Input
id={idBpmMax}
data-testid="facet-bpm-max"
type="number"
min={1}
max={999}
inputMode="numeric"
value={value.bpmMax ?? ''}
onChange={(e) => update({ bpmMax: parsePositiveInt(e.target.value) })}
placeholder="max"
/>
</div>
</div>
{value.bpmMin && value.bpmMax && value.bpmMin > value.bpmMax && (
<p className="text-xs text-destructive">Min BPM cannot exceed Max BPM.</p>
)}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Year</legend>
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor={idYearFrom} className="sr-only">
Year from
</Label>
<Input
id={idYearFrom}
data-testid="facet-year-from"
type="number"
min={1900}
max={2100}
inputMode="numeric"
value={value.yearFrom ?? ''}
onChange={(e) => update({ yearFrom: parsePositiveInt(e.target.value) })}
placeholder="from"
/>
</div>
<div>
<Label htmlFor={idYearTo} className="sr-only">
Year to
</Label>
<Input
id={idYearTo}
data-testid="facet-year-to"
type="number"
min={1900}
max={2100}
inputMode="numeric"
value={value.yearTo ?? ''}
onChange={(e) => update({ yearTo: parsePositiveInt(e.target.value) })}
placeholder="to"
/>
</div>
</div>
</fieldset>
</aside>
);
}
function parsePositiveInt(raw: string): number | undefined {
if (!raw) return undefined;
const n = parseInt(raw, 10);
if (Number.isNaN(n) || n <= 0) return undefined;
return n;
}

View file

@ -6,6 +6,7 @@ import { SearchPageEmpty } from './SearchPageEmpty';
import { SearchPageError } from './SearchPageError';
import { SearchPageResults } from './SearchPageResults';
import { SearchPageSkeleton } from './SearchPageSkeleton';
import { FacetSidebar } from '@/features/search/components/FacetSidebar';
import { useTranslation } from '@/hooks/useTranslation';
/**
@ -29,6 +30,8 @@ export function SearchPage() {
hasResults,
activeTab,
onTabChange,
facets,
setFacets,
} = useSearchPage();
return (
@ -46,15 +49,32 @@ export function SearchPage() {
/>
)}
{isLoading ? (
<SearchPageSkeleton />
) : !query ? (
{/*
v1.0.9 W4 Day 18 facet sidebar shown only when the user has
a non-empty query (otherwise the discovery view fills the page
and facets have no targets to filter).
*/}
{query ? (
<div className="flex flex-col md:flex-row gap-6 mt-6">
<FacetSidebar value={facets} onChange={setFacets} />
<div className="flex-1 min-w-0">
{isLoading ? (
<SearchPageSkeleton />
) : !hasResults ? (
<SearchPageEmpty />
) : results ? (
<SearchPageResults
results={results}
query={query}
activeTab={activeTab}
onTabChange={onTabChange}
/>
) : null}
</div>
</div>
) : (
<SearchPageDiscovery onQuerySelect={setQuery} />
) : !hasResults ? (
<SearchPageEmpty />
) : results ? (
<SearchPageResults results={results} query={query} activeTab={activeTab} onTabChange={onTabChange} />
) : null}
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { searchApi } from '@/services/api/search';
import { searchApi, type SearchFacets } from '@/services/api/search';
import { SearchResults } from '@/types/search';
import { useDebounce } from '@/hooks/useDebounce';
import { useSearchHistory } from '../../hooks/useSearchHistory';
@ -12,6 +12,34 @@ function tabToTypes(tab: SearchActiveTab): string[] {
return [tab];
}
// v1.0.9 W4 Day 18 — facets persisted to URL so deep links reproduce
// the exact result set.
function readFacetsFromParams(p: URLSearchParams): SearchFacets {
const f: SearchFacets = {};
const g = p.get('genre');
if (g) f.genre = g;
const k = p.get('musical_key');
if (k) f.musicalKey = k;
const bn = p.get('bpm_min');
if (bn) f.bpmMin = parseInt(bn, 10) || undefined;
const bx = p.get('bpm_max');
if (bx) f.bpmMax = parseInt(bx, 10) || undefined;
const yf = p.get('year_from');
if (yf) f.yearFrom = parseInt(yf, 10) || undefined;
const yt = p.get('year_to');
if (yt) f.yearTo = parseInt(yt, 10) || undefined;
return f;
}
function writeFacetsToParams(target: Record<string, string>, f: SearchFacets): void {
if (f.genre) target.genre = f.genre;
if (f.musicalKey) target.musical_key = f.musicalKey;
if (f.bpmMin) target.bpm_min = String(f.bpmMin);
if (f.bpmMax) target.bpm_max = String(f.bpmMax);
if (f.yearFrom) target.year_from = String(f.yearFrom);
if (f.yearTo) target.year_to = String(f.yearTo);
}
export function useSearchPage() {
const { addToHistory } = useSearchHistory();
const [searchParams, setSearchParams] = useSearchParams();
@ -27,6 +55,8 @@ export function useSearchPage() {
const [results, setResults] = useState<SearchResults | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [facets, setFacets] = useState<SearchFacets>(() => readFacetsFromParams(searchParams));
const debouncedFacets = useDebounce(facets, 300);
// Ref to prevent debounce race condition when clearing
const isClearingRef = useRef(false);
@ -63,7 +93,7 @@ export function useSearchPage() {
setError(null);
try {
const types = tabToTypes(activeTab);
const data = await searchApi.search(debouncedQuery, types);
const data = await searchApi.search(debouncedQuery, types, { facets: debouncedFacets });
setResults(data);
addToHistory(debouncedQuery);
} catch (err) {
@ -75,8 +105,9 @@ export function useSearchPage() {
performSearch();
const nextParams: Record<string, string> = { q: debouncedQuery };
if (activeTab !== 'all') nextParams.type = activeTab;
writeFacetsToParams(nextParams, debouncedFacets);
setSearchParams(nextParams, { replace: true });
}, [debouncedQuery, activeTab, setSearchParams]);
}, [debouncedQuery, activeTab, debouncedFacets, setSearchParams]);
const clearSearch = () => {
isClearingRef.current = true;
@ -111,5 +142,7 @@ export function useSearchPage() {
hasResults,
activeTab: activeTabValue,
onTabChange: handleTabChange,
facets,
setFacets,
};
}

View file

@ -1,4 +1,5 @@
import { SearchResults } from '@/types/search';
import { apiClient } from '@/services/api/client';
import { getSearch, getSearchSuggestions } from '@/services/generated/search/search';
/** v0.10.2 F363: Optional cursor/limit for future pagination */
@ -9,18 +10,41 @@ export interface SearchParams {
limit?: number;
}
/**
* v1.0.9 W4 Day 18 faceted-search filters. All optional ; backend
* validates bounds (BPM in [1, 999], year in [1900, 2100]) and rejects
* invalid combinations (bpm_min > bpm_max etc).
*/
export interface SearchFacets {
genre?: string;
musicalKey?: string;
bpmMin?: number;
bpmMax?: number;
yearFrom?: number;
yearTo?: number;
}
// v1.0.9 item 1.6 — search endpoints now have OpenAPI annotations
// (`@Router /search [get]` + `/search/suggestions [get]`), so orval emits
// typed clients we delegate to. The frontend SearchResults shape is kept
// as-is because the backend response wraps the unified result in the
// generic envelope `handlers.APIResponse{data}` — orval types `data` as
// `unknown`, so we narrow at the boundary.
//
// v1.0.9 W4 Day 18 : when facet filters are passed, we bypass orval's
// typed `getSearch` (its `GetSearchParams` shape pre-dates the new
// query params and the regen happens via the pre-commit hook). The
// fall-through path uses apiClient.get directly with snake_case keys
// matching the backend's parseSearchFilters().
export const searchApi = {
search: async (
query: string,
types?: string[],
opts?: { cursor?: string; limit?: number }
opts?: { cursor?: string; limit?: number; facets?: SearchFacets }
): Promise<SearchResults> => {
if (opts?.facets && hasAnyFacet(opts.facets)) {
return searchWithFacets(query, types, opts);
}
const params: { q: string; type?: string[]; cursor?: string; limit?: number } = { q: query };
if (types && types.length > 0) {
params.type = types;
@ -51,3 +75,36 @@ function unwrapApiData<T>(response: unknown): T {
}
return response as T;
}
function hasAnyFacet(f: SearchFacets): boolean {
return !!(
f.genre ||
f.musicalKey ||
(f.bpmMin && f.bpmMin > 0) ||
(f.bpmMax && f.bpmMax > 0) ||
(f.yearFrom && f.yearFrom > 0) ||
(f.yearTo && f.yearTo > 0)
);
}
async function searchWithFacets(
query: string,
types: string[] | undefined,
opts: { cursor?: string; limit?: number; facets?: SearchFacets }
): Promise<SearchResults> {
const params: Record<string, string | string[] | number> = { q: query };
if (types && types.length > 0) params.type = types;
if (opts.cursor) params.cursor = opts.cursor;
if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50);
const f = opts.facets!;
if (f.genre) params.genre = f.genre;
if (f.musicalKey) params.musical_key = f.musicalKey;
if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin;
if (f.bpmMax && f.bpmMax > 0) params.bpm_max = f.bpmMax;
if (f.yearFrom && f.yearFrom > 0) params.year_from = f.yearFrom;
if (f.yearTo && f.yearTo > 0) params.year_to = f.yearTo;
const response = await apiClient.get('/search', { params });
return unwrapApiData<SearchResults>(response.data);
}

View file

@ -0,0 +1,83 @@
import { test, expect } from '@chromatic-com/playwright';
import { CONFIG } from './helpers';
/**
* v1.0.9 W4 Day 18 faceted search E2E.
*
* Two layers tested :
*
* 36. Backend gate POST query params for bpm_min/bpm_max are
* echoed into the SQL filter ; out-of-range values produce 400.
* 37. UI open /search, type a query, set BPM 120-130 in the
* FacetSidebar, assert URL params updated.
*
* The "results restreints" verification depends on seed data carrying
* tracks with measurable BPM values. The seed inserts placeholder
* tracks without BPM today, so test 37 only verifies the URL/state
* roundtrip ; once seed BPM data is in place (W5+), un-skip the
* results-narrowing assertion at the bottom.
*/
interface ApiEnvelope<T> {
success?: boolean;
data: T;
error?: { code?: string; message?: string };
}
test.describe('SEARCH — faceted filters (v1.0.9 W4 Day 18)', () => {
test('36. backend rejects invalid bpm range with 400', async ({ request }) => {
// bpm_min > bpm_max — handler should refuse the combination.
const resp = await request.get(
`${CONFIG.apiURL}/api/v1/search?q=test&bpm_min=200&bpm_max=100`,
);
expect(resp.status(), 'invalid bpm range must be rejected client-side').toBe(400);
});
test('37. backend rejects out-of-range BPM values', async ({ request }) => {
const resp = await request.get(
`${CONFIG.apiURL}/api/v1/search?q=test&bpm_min=99999`,
);
expect(resp.status()).toBe(400);
});
test('38. backend accepts a valid bpm range and returns 200', async ({ request }) => {
const resp = await request.get(
`${CONFIG.apiURL}/api/v1/search?q=test&bpm_min=80&bpm_max=130`,
);
// Either 200 (results returned) or 200 with empty arrays — both
// are valid as long as the filter parses.
expect(resp.status(), 'valid faceted search must succeed').toBe(200);
const body = (await resp.json()) as ApiEnvelope<{
tracks: unknown[];
artists: unknown[];
playlists: unknown[];
}>;
const data = body.data ?? (body as unknown as { tracks: unknown[] });
expect(data.tracks).toBeInstanceOf(Array);
});
test('39. UI — typing in the sidebar updates URL params', async ({ page }) => {
test.setTimeout(20_000);
await page.goto(`${CONFIG.baseURL}/search?q=rock`, { waitUntil: 'domcontentloaded' });
// Sidebar mounts only when the query is non-empty (see SearchPage).
const sidebar = page.getByTestId('facet-sidebar');
await expect(sidebar).toBeVisible({ timeout: 5_000 });
await page.getByTestId('facet-bpm-min').fill('120');
await page.getByTestId('facet-bpm-max').fill('130');
// Debounce window for facets is 300 ms ; URL update is paired
// with the search call, so wait for it.
await page.waitForFunction(
() =>
window.location.search.includes('bpm_min=120') &&
window.location.search.includes('bpm_max=130'),
{ timeout: 5_000 },
);
const url = new URL(page.url());
expect(url.searchParams.get('bpm_min')).toBe('120');
expect(url.searchParams.get('bpm_max')).toBe('130');
});
});

View file

@ -2,8 +2,6 @@ package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -83,6 +81,10 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
protected.GET("/settings", settingsHandler.GetSettings)
protected.PUT("/settings", settingsHandler.UpdateSettings)
// v0.801: UI preferences (theme, contrast, etc.)
protected.GET("/me/preferences", settingsHandler.GetPreferences)
protected.PUT("/me/preferences", settingsHandler.UpdatePreferences)
userOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
userIDStr := c.Param("id")
return uuid.Parse(userIDStr)
@ -215,30 +217,7 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
})
// GET /me/export: sync JSON fallback (v0.10.8 - prefer POST for async ZIP)
exportHandler := func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found"})
return
}
userUUID, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
jsonData, err := dataExportService.ExportUserDataAsJSON(c.Request.Context(), userUUID)
if err != nil {
r.logger.Error("Failed to export user data", zap.Error(err), zap.String("user_id", userUUID.String()))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export user data"})
return
}
filename := "veza-data-export-" + time.Now().Format("2006-01-02T15-04-05") + ".json"
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Header("Content-Length", strconv.Itoa(len(jsonData)))
c.Data(http.StatusOK, "application/json", jsonData)
}
protected.GET("/me/export", exportHandler)
protected.GET("/me/export", gdprExportHandler.ExportJSON)
// POST /me/export: async GDPR export (v0.10.8 F065)
protected.POST("/me/export", gdprExportHandler.RequestExport)

View file

@ -23,8 +23,19 @@ 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)
func (s *SearchService) Search(query string, types []string) (*services.SearchResult, error) {
// 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)
}

View file

@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
@ -20,7 +21,7 @@ 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) (*services.SearchResult, error)
Search(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error)
Suggestions(query string, limit int) (*services.SearchResult, error)
}
@ -44,13 +45,19 @@ func NewSearchHandlersWithInterface(searchService SearchServiceInterface) *Searc
// 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 item 1.6 — annotation added so orval can generate a typed client.
// @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 cursor query string false "Opaque pagination cursor"
// @Param limit query int false "Page size (max 50)"
// @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"
@ -63,6 +70,11 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
}
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
@ -71,11 +83,12 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
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)
results, err := sh.searchService.Search(query, types, filters)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "search failed")
@ -86,6 +99,54 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
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.

View file

@ -17,8 +17,8 @@ type MockSearchService struct {
mock.Mock
}
func (m *MockSearchService) Search(query string, types []string) (*services.SearchResult, error) {
args := m.Called(query, types)
func (m *MockSearchService) Search(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error) {
args := m.Called(query, types, filters)
if args.Get(0) == nil {
return nil, args.Error(1)
}
@ -64,7 +64,7 @@ func TestSearchHandlers_Search_Success(t *testing.T) {
},
}
mockService.On("Search", "test", []string(nil)).Return(expectedResult, nil)
mockService.On("Search", "test", []string(nil), mock.Anything).Return(expectedResult, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
@ -89,7 +89,7 @@ func TestSearchHandlers_Search_WithTypes(t *testing.T) {
Playlists: []services.PlaylistResult{},
}
mockService.On("Search", "test", []string{"track"}).Return(expectedResult, nil)
mockService.On("Search", "test", []string{"track"}, mock.Anything).Return(expectedResult, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/search?q=test&type=track", nil)
@ -136,7 +136,7 @@ func TestSearchHandlers_Search_ServiceError(t *testing.T) {
mockService := new(MockSearchService)
router := setupTestSearchRouter(mockService)
mockService.On("Search", "test", []string(nil)).Return(nil, assert.AnError)
mockService.On("Search", "test", []string(nil), mock.Anything).Return(nil, assert.AnError)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
@ -163,7 +163,7 @@ func TestSearchHandlers_Search_MultipleTypes(t *testing.T) {
Playlists: []services.PlaylistResult{},
}
mockService.On("Search", "test", []string{"track", "user"}).Return(expectedResult, nil)
mockService.On("Search", "test", []string{"track", "user"}, mock.Anything).Return(expectedResult, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/search?q=test&type=track&type=user", nil)

View file

@ -22,6 +22,33 @@ type SearchResult struct {
Playlists []PlaylistResult `json:"playlists"`
}
// SearchFilters carries the optional faceted-search constraints.
// v1.0.9 W4 Day 18 — only the track query honors these ; users and
// playlists ignore them silently (none of the columns apply).
//
// All fields zero-valued = no constraint. The handler validates
// bounds (e.g. BPM in [1, 999]) before populating, so the SQL
// builder trusts the input here.
type SearchFilters struct {
Genre string // exact match on tracks.genre
MusicalKey string // exact match on tracks.musical_key (e.g. "C", "Am")
BPMMin int // tracks.bpm >= BPMMin (skipped when 0)
BPMMax int // tracks.bpm <= BPMMax (skipped when 0)
YearFrom int // tracks.year >= YearFrom (skipped when 0)
YearTo int // tracks.year <= YearTo (skipped when 0)
}
// HasAny reports whether any of the filter fields constrain the
// query. Used by the handler / OTel span attributes.
func (f *SearchFilters) HasAny() bool {
if f == nil {
return false
}
return f.Genre != "" || f.MusicalKey != "" ||
f.BPMMin > 0 || f.BPMMax > 0 ||
f.YearFrom > 0 || f.YearTo > 0
}
type TrackResult struct {
ID string `json:"id"`
Title string `json:"title"`
@ -50,8 +77,9 @@ func NewSearchService(db *database.Database, logger *zap.Logger) *SearchService
}
}
// Search performs a full-text search
func (ss *SearchService) Search(query string, types []string) (*SearchResult, error) {
// Search performs a full-text search. v1.0.9 W4 Day 18 — `filters`
// is optional ; pass nil for the legacy unfiltered behavior.
func (ss *SearchService) Search(query string, types []string, filters *SearchFilters) (*SearchResult, error) {
ctx := context.Background()
results := &SearchResult{}
@ -61,7 +89,7 @@ func (ss *SearchService) Search(query string, types []string) (*SearchResult, er
searchUsers := searchAll || contains(types, "user")
searchPlaylists := searchAll || contains(types, "playlist")
// Search tracks (v0.203 Lot K: boolean operators)
// Search tracks (v0.203 Lot K: boolean operators ; v1.0.9 W4 Day 18 facets)
if searchTracks {
parsed := ParseSearchQuery(query)
cond, args := BuildWhereCondition(parsed, []string{"title", "artist", "album"})
@ -69,6 +97,11 @@ func (ss *SearchService) Search(query string, types []string) (*SearchResult, er
cond = "(title ILIKE $1 OR artist ILIKE $1)"
args = []interface{}{"%" + query + "%"}
}
// Append faceted filters as additional AND clauses. Placeholder
// numbering continues from len(args) — each helper appends to
// `cond` + `args` in lock-step.
cond, args = appendTrackFacets(cond, args, filters)
sql := `SELECT id::text, title, COALESCE(artist, ''), COALESCE(stream_manifest_url, file_path), COALESCE(cover_art_path, '')
FROM tracks
WHERE (` + cond + `) AND deleted_at IS NULL AND status = 'active'
@ -222,3 +255,41 @@ func contains(slice []string, item string) bool {
}
return false
}
// appendTrackFacets extends a (cond, args) pair built by the FTS layer
// with the faceted-search filters from `f`. Each non-zero filter adds
// an AND clause + a parameterised value ; placeholder numbering uses
// the next $N in sequence so it composes with the FTS placeholders
// already in args.
//
// Skipped silently when f is nil or all fields are zero.
//
// v1.0.9 W4 Day 18.
func appendTrackFacets(cond string, args []interface{}, f *SearchFilters) (string, []interface{}) {
if f == nil {
return cond, args
}
add := func(clause string, v interface{}) {
args = append(args, v)
cond = cond + " AND " + fmt.Sprintf(clause, len(args))
}
if f.Genre != "" {
add("genre = $%d", f.Genre)
}
if f.MusicalKey != "" {
add("musical_key = $%d", f.MusicalKey)
}
if f.BPMMin > 0 {
add("bpm >= $%d", f.BPMMin)
}
if f.BPMMax > 0 {
add("bpm <= $%d", f.BPMMax)
}
if f.YearFrom > 0 {
add("year >= $%d", f.YearFrom)
}
if f.YearTo > 0 {
add("year <= $%d", f.YearTo)
}
return cond, args
}