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
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:
parent
d5152d89a2
commit
44349ec444
10 changed files with 577 additions and 56 deletions
206
apps/web/src/features/search/components/FacetSidebar.tsx
Normal file
206
apps/web/src/features/search/components/FacetSidebar.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { SearchPageEmpty } from './SearchPageEmpty';
|
||||||
import { SearchPageError } from './SearchPageError';
|
import { SearchPageError } from './SearchPageError';
|
||||||
import { SearchPageResults } from './SearchPageResults';
|
import { SearchPageResults } from './SearchPageResults';
|
||||||
import { SearchPageSkeleton } from './SearchPageSkeleton';
|
import { SearchPageSkeleton } from './SearchPageSkeleton';
|
||||||
|
import { FacetSidebar } from '@/features/search/components/FacetSidebar';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,6 +30,8 @@ export function SearchPage() {
|
||||||
hasResults,
|
hasResults,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
facets,
|
||||||
|
setFacets,
|
||||||
} = useSearchPage();
|
} = useSearchPage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -46,15 +49,32 @@ export function SearchPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{/*
|
||||||
<SearchPageSkeleton />
|
v1.0.9 W4 Day 18 — facet sidebar shown only when the user has
|
||||||
) : !query ? (
|
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} />
|
<SearchPageDiscovery onQuerySelect={setQuery} />
|
||||||
) : !hasResults ? (
|
)}
|
||||||
<SearchPageEmpty />
|
|
||||||
) : results ? (
|
|
||||||
<SearchPageResults results={results} query={query} activeTab={activeTab} onTabChange={onTabChange} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { SearchResults } from '@/types/search';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { useSearchHistory } from '../../hooks/useSearchHistory';
|
import { useSearchHistory } from '../../hooks/useSearchHistory';
|
||||||
|
|
@ -12,6 +12,34 @@ function tabToTypes(tab: SearchActiveTab): string[] {
|
||||||
return [tab];
|
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() {
|
export function useSearchPage() {
|
||||||
const { addToHistory } = useSearchHistory();
|
const { addToHistory } = useSearchHistory();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -27,6 +55,8 @@ export function useSearchPage() {
|
||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
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
|
// Ref to prevent debounce race condition when clearing
|
||||||
const isClearingRef = useRef(false);
|
const isClearingRef = useRef(false);
|
||||||
|
|
@ -63,7 +93,7 @@ export function useSearchPage() {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const types = tabToTypes(activeTab);
|
const types = tabToTypes(activeTab);
|
||||||
const data = await searchApi.search(debouncedQuery, types);
|
const data = await searchApi.search(debouncedQuery, types, { facets: debouncedFacets });
|
||||||
setResults(data);
|
setResults(data);
|
||||||
addToHistory(debouncedQuery);
|
addToHistory(debouncedQuery);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -75,8 +105,9 @@ export function useSearchPage() {
|
||||||
performSearch();
|
performSearch();
|
||||||
const nextParams: Record<string, string> = { q: debouncedQuery };
|
const nextParams: Record<string, string> = { q: debouncedQuery };
|
||||||
if (activeTab !== 'all') nextParams.type = activeTab;
|
if (activeTab !== 'all') nextParams.type = activeTab;
|
||||||
|
writeFacetsToParams(nextParams, debouncedFacets);
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [debouncedQuery, activeTab, setSearchParams]);
|
}, [debouncedQuery, activeTab, debouncedFacets, setSearchParams]);
|
||||||
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
isClearingRef.current = true;
|
isClearingRef.current = true;
|
||||||
|
|
@ -111,5 +142,7 @@ export function useSearchPage() {
|
||||||
hasResults,
|
hasResults,
|
||||||
activeTab: activeTabValue,
|
activeTab: activeTabValue,
|
||||||
onTabChange: handleTabChange,
|
onTabChange: handleTabChange,
|
||||||
|
facets,
|
||||||
|
setFacets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { SearchResults } from '@/types/search';
|
import { SearchResults } from '@/types/search';
|
||||||
|
import { apiClient } from '@/services/api/client';
|
||||||
import { getSearch, getSearchSuggestions } from '@/services/generated/search/search';
|
import { getSearch, getSearchSuggestions } from '@/services/generated/search/search';
|
||||||
|
|
||||||
/** v0.10.2 F363: Optional cursor/limit for future pagination */
|
/** v0.10.2 F363: Optional cursor/limit for future pagination */
|
||||||
|
|
@ -9,18 +10,41 @@ export interface SearchParams {
|
||||||
limit?: number;
|
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
|
// v1.0.9 item 1.6 — search endpoints now have OpenAPI annotations
|
||||||
// (`@Router /search [get]` + `/search/suggestions [get]`), so orval emits
|
// (`@Router /search [get]` + `/search/suggestions [get]`), so orval emits
|
||||||
// typed clients we delegate to. The frontend SearchResults shape is kept
|
// typed clients we delegate to. The frontend SearchResults shape is kept
|
||||||
// as-is because the backend response wraps the unified result in the
|
// as-is because the backend response wraps the unified result in the
|
||||||
// generic envelope `handlers.APIResponse{data}` — orval types `data` as
|
// generic envelope `handlers.APIResponse{data}` — orval types `data` as
|
||||||
// `unknown`, so we narrow at the boundary.
|
// `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 = {
|
export const searchApi = {
|
||||||
search: async (
|
search: async (
|
||||||
query: string,
|
query: string,
|
||||||
types?: string[],
|
types?: string[],
|
||||||
opts?: { cursor?: string; limit?: number }
|
opts?: { cursor?: string; limit?: number; facets?: SearchFacets }
|
||||||
): Promise<SearchResults> => {
|
): 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 };
|
const params: { q: string; type?: string[]; cursor?: string; limit?: number } = { q: query };
|
||||||
if (types && types.length > 0) {
|
if (types && types.length > 0) {
|
||||||
params.type = types;
|
params.type = types;
|
||||||
|
|
@ -51,3 +75,36 @@ function unwrapApiData<T>(response: unknown): T {
|
||||||
}
|
}
|
||||||
return response as 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
83
tests/e2e/32-faceted-search.spec.ts
Normal file
83
tests/e2e/32-faceted-search.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,8 +2,6 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -83,6 +81,10 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
|
||||||
protected.GET("/settings", settingsHandler.GetSettings)
|
protected.GET("/settings", settingsHandler.GetSettings)
|
||||||
protected.PUT("/settings", settingsHandler.UpdateSettings)
|
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) {
|
userOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||||
userIDStr := c.Param("id")
|
userIDStr := c.Param("id")
|
||||||
return uuid.Parse(userIDStr)
|
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)
|
// GET /me/export: sync JSON fallback (v0.10.8 - prefer POST for async ZIP)
|
||||||
exportHandler := func(c *gin.Context) {
|
protected.GET("/me/export", gdprExportHandler.ExportJSON)
|
||||||
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)
|
|
||||||
|
|
||||||
// POST /me/export: async GDPR export (v0.10.8 F065)
|
// POST /me/export: async GDPR export (v0.10.8 F065)
|
||||||
protected.POST("/me/export", gdprExportHandler.RequestExport)
|
protected.POST("/me/export", gdprExportHandler.RequestExport)
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,19 @@ func NewSearchService(client *Client, logger *zap.Logger) *SearchService {
|
||||||
return &SearchService{client: client, logger: logger}
|
return &SearchService{client: client, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search performs full-text search with BM25, fuzziness for typos (F364)
|
// Search performs full-text search with BM25, fuzziness for typos (F364).
|
||||||
func (s *SearchService) Search(query string, types []string) (*services.SearchResult, error) {
|
// 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)
|
return s.search(context.Background(), query, types, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ var SearchHandlersInstance *SearchHandlers
|
||||||
// SearchServiceInterface defines the interface for search operations
|
// SearchServiceInterface defines the interface for search operations
|
||||||
// This allows for easier testing with mocks
|
// This allows for easier testing with mocks
|
||||||
type SearchServiceInterface interface {
|
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)
|
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
|
// Search performs a full-text search across tracks, users, and playlists
|
||||||
// @Summary Unified search
|
// @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
|
// @Tags Search
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param q query string true "Search query"
|
// @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 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 genre query string false "Filter tracks by genre (exact match)"
|
||||||
// @Param limit query int false "Page size (max 50)"
|
// @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"
|
// @Success 200 {object} handlers.APIResponse "Search results"
|
||||||
// @Failure 400 {object} handlers.APIResponse "Validation error"
|
// @Failure 400 {object} handlers.APIResponse "Validation error"
|
||||||
// @Failure 500 {object} handlers.APIResponse "Search failed"
|
// @Failure 500 {object} handlers.APIResponse "Search failed"
|
||||||
|
|
@ -63,6 +70,11 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
types := c.QueryArray("type")
|
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
|
// 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
|
// hits this. Query content is NOT recorded (PII / search history is
|
||||||
|
|
@ -71,11 +83,12 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
|
||||||
trace.WithAttributes(
|
trace.WithAttributes(
|
||||||
attribute.Int("search.query_length", len(query)),
|
attribute.Int("search.query_length", len(query)),
|
||||||
attribute.StringSlice("search.types", types),
|
attribute.StringSlice("search.types", types),
|
||||||
|
attribute.Bool("search.filtered", filters.HasAny()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
results, err := sh.searchService.Search(query, types)
|
results, err := sh.searchService.Search(query, types, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, "search failed")
|
span.SetStatus(codes.Error, "search failed")
|
||||||
|
|
@ -86,6 +99,54 @@ func (sh *SearchHandlers) Search(c *gin.Context) {
|
||||||
RespondSuccess(c, http.StatusOK, results)
|
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
|
// Suggestions returns autocomplete suggestions for the search input
|
||||||
// @Summary Search suggestions
|
// @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.
|
// @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.
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ type MockSearchService struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockSearchService) Search(query string, types []string) (*services.SearchResult, error) {
|
func (m *MockSearchService) Search(query string, types []string, filters *services.SearchFilters) (*services.SearchResult, error) {
|
||||||
args := m.Called(query, types)
|
args := m.Called(query, types, filters)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
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
|
// Execute
|
||||||
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
|
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
|
||||||
|
|
@ -89,7 +89,7 @@ func TestSearchHandlers_Search_WithTypes(t *testing.T) {
|
||||||
Playlists: []services.PlaylistResult{},
|
Playlists: []services.PlaylistResult{},
|
||||||
}
|
}
|
||||||
|
|
||||||
mockService.On("Search", "test", []string{"track"}).Return(expectedResult, nil)
|
mockService.On("Search", "test", []string{"track"}, mock.Anything).Return(expectedResult, nil)
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
req, _ := http.NewRequest("GET", "/api/v1/search?q=test&type=track", nil)
|
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)
|
mockService := new(MockSearchService)
|
||||||
router := setupTestSearchRouter(mockService)
|
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
|
// Execute
|
||||||
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
|
req, _ := http.NewRequest("GET", "/api/v1/search?q=test", nil)
|
||||||
|
|
@ -163,7 +163,7 @@ func TestSearchHandlers_Search_MultipleTypes(t *testing.T) {
|
||||||
Playlists: []services.PlaylistResult{},
|
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
|
// Execute
|
||||||
req, _ := http.NewRequest("GET", "/api/v1/search?q=test&type=track&type=user", nil)
|
req, _ := http.NewRequest("GET", "/api/v1/search?q=test&type=track&type=user", nil)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,33 @@ type SearchResult struct {
|
||||||
Playlists []PlaylistResult `json:"playlists"`
|
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 {
|
type TrackResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -50,8 +77,9 @@ func NewSearchService(db *database.Database, logger *zap.Logger) *SearchService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search performs a full-text search
|
// Search performs a full-text search. v1.0.9 W4 Day 18 — `filters`
|
||||||
func (ss *SearchService) Search(query string, types []string) (*SearchResult, error) {
|
// is optional ; pass nil for the legacy unfiltered behavior.
|
||||||
|
func (ss *SearchService) Search(query string, types []string, filters *SearchFilters) (*SearchResult, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
results := &SearchResult{}
|
results := &SearchResult{}
|
||||||
|
|
||||||
|
|
@ -61,7 +89,7 @@ func (ss *SearchService) Search(query string, types []string) (*SearchResult, er
|
||||||
searchUsers := searchAll || contains(types, "user")
|
searchUsers := searchAll || contains(types, "user")
|
||||||
searchPlaylists := searchAll || contains(types, "playlist")
|
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 {
|
if searchTracks {
|
||||||
parsed := ParseSearchQuery(query)
|
parsed := ParseSearchQuery(query)
|
||||||
cond, args := BuildWhereCondition(parsed, []string{"title", "artist", "album"})
|
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)"
|
cond = "(title ILIKE $1 OR artist ILIKE $1)"
|
||||||
args = []interface{}{"%" + query + "%"}
|
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, '')
|
sql := `SELECT id::text, title, COALESCE(artist, ''), COALESCE(stream_manifest_url, file_path), COALESCE(cover_art_path, '')
|
||||||
FROM tracks
|
FROM tracks
|
||||||
WHERE (` + cond + `) AND deleted_at IS NULL AND status = 'active'
|
WHERE (` + cond + `) AND deleted_at IS NULL AND status = 'active'
|
||||||
|
|
@ -222,3 +255,41 @@ func contains(slice []string, item string) bool {
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue