From aa7776db5faf926ffbba53123a8ef08371e39ecf Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 22 Mar 2026 00:20:00 -0400 Subject: [PATCH] docs: consolidate implementation plans into unified search document - Remove GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md (superseded) - Remove SAVED_FILTERS_IMPLEMENTATION.md (superseded) - Add UNIFIED_SEARCH_IMPLEMENTATION.md with comprehensive plan for: - Consolidating /filtered and /search endpoints - All-fuzzy text filters (author, series, genre, language) - Exact match with quotes for Google-style search - Field-specific fuzzy search for autocomplete dropdowns - Combined search + filters functionality - Phase-by-phase implementation with SQL, service, handler, frontend, tests, docs --- GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md | 933 --------------- SAVED_FILTERS_IMPLEMENTATION.md | 1339 ---------------------- UNIFIED_SEARCH_IMPLEMENTATION.md | 1227 ++++++++++++++++++++ 3 files changed, 1227 insertions(+), 2272 deletions(-) delete mode 100644 GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md delete mode 100644 SAVED_FILTERS_IMPLEMENTATION.md create mode 100644 UNIFIED_SEARCH_IMPLEMENTATION.md diff --git a/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md b/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md deleted file mode 100644 index 5919b9a..0000000 --- a/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md +++ /dev/null @@ -1,933 +0,0 @@ -# GET /api/saved-filters/:id Endpoint Implementation Plan - -**Status:** Planning Phase -**Created:** March 21, 2026 -**Priority:** Medium -**Complexity:** Low - ---- - -## Overview - -Add a **GET /api/saved-filters/:id** endpoint to retrieve a single saved filter by ID. This enables future mobile/SPA clients to fetch filter details on-demand without relying on server-side rendering. - -### Key Decision Points - -- ✅ **Reuse existing service method** - `GetSavedFilterByID` query already exists -- ✅ **Follow established patterns** - Match existing handler structure -- ✅ **Minimal code changes** - Only add one new handler method -- ✅ **Backward compatible** - Doesn't affect existing endpoints -- ✅ **Future-proof** - Enables mobile/SPA clients -- ✅ **Follows PROJECT_GUIDELINES.md** - Service layer, test helpers, docs - ---- - -## Current State Analysis - -### Existing Saved Filters API - -**Endpoints Implemented:** -- ✅ `GET /api/saved-filters?resource_type=X` - List all filters (Line 54 in filters.go) -- ✅ `POST /api/saved-filters` - Create filter (Line 91 in filters.go) -- ✅ `PUT /api/saved-filters/:id` - Update filter (Line 160 in filters.go) -- ✅ `DELETE /api/saved-filters/:id` - Delete filter (Line 138 in filters.go) - -**Database Query Already Exists:** -```go -// internal/database/queries.sql.go - Line 4480+ -func (q *Queries) GetSavedFilterByID(ctx context.Context, db database.GetSavedFilterByIDParams) (database.SavedFilters, error) -``` - -**Service Layer Already Uses It:** -```go -// internal/services/filters.go:71-77 -existing, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{ - ID: pgtype.UUID{Bytes: filterID, Valid: true}, - UserID: pgtype.UUID{Bytes: userID, Valid: true}, -}) -``` - -### Why This Endpoint Is Needed - -**Current Limitation:** -- SSR approach stores filter data in HTML (data attributes) -- Mobile apps can't access HTML data attributes -- SPA clients need direct API access to filter details -- Future clients need RESTful CRUD operations - -**Solution:** -- Add GET /:id endpoint following REST conventions -- Reuse existing service/database code -- Follow established handler patterns -- Complete CRUD API for saved filters - ---- - -## Implementation Plan - -### Phase 1: Add Handler Method - -#### File: `internal/handlers/filters.go` - -**Location:** After `GetSavedFilters` method (around line 88) - -**Change Type:** Add new handler method - -**Implementation:** - -```go -// GetSavedFilterByID - GET /api/saved-filters/:id -// Retrieve a single saved filter by ID -func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) - - // Parse filter ID from URL parameter - filterID, err := uuid.Parse(c.Param("id")) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "invalid filter ID", - }) - } - - // Use service layer (includes business logic + ownership verification) - filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID) - if err != nil { - if err.Error() == "filter not found or access denied" { - return c.JSON(http.StatusNotFound, map[string]string{ - "error": "filter not found", - }) - } - return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": "failed to fetch filter", - }) - } - - // Return JSON response (JSONB handled automatically) - response := SavedFilterResponse{ - ID: filterID, - Name: filter.Name, - ResourceType: filter.ResourceType, - Filters: json.RawMessage(filter.Filters), // Return JSONB as-is - CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339), - UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339), - } - - return c.JSON(http.StatusOK, response) -} -``` - -**Notes:** -- ✅ Follows existing handler pattern (same as `GetSavedFilters`) -- ✅ Uses service layer (no direct database access) -- ✅ Returns `SavedFilterResponse` struct (already defined) -- ✅ Proper error handling (400, 404, 500) -- ✅ Ownership verification via service layer -- ✅ Matches existing authentication pattern - ---- - -### Phase 2: Add Service Layer Method - -#### File: `internal/services/filters.go` - -**Location:** After `GetSavedFilters` method (around line 32) - -**Change Type:** Add new service method - -**Implementation:** - -```go -// GetSavedFilterByID - Retrieve a single saved filter by ID -func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) (database.SavedFilters, error) { - filter, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{ - ID: pgtype.UUID{Bytes: filterID, Valid: true}, - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - }) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err) - } - - return filter, nil -} -``` - -**Notes:** -- ✅ Business logic: Verify filter exists and belongs to user -- ✅ Returns descriptive error for "not found or access denied" -- ✅ Reuses existing database query -- ✅ Follows existing service pattern (similar to UpdateSavedFilter:71-77) -- ✅ No new database code needed - ---- - -### Phase 3: Register Route - -#### File: `internal/router/filters.go` - -**Location:** Line 12 (after existing routes) - -**Change Type:** Add route registration - -**Current Code:** -```go -filters.GET("", cfg.FiltersHandler.GetSavedFilters) -filters.POST("", cfg.FiltersHandler.CreateSavedFilter) -filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter) -filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter) -``` - -**Updated Code:** -```go -filters.GET("", cfg.FiltersHandler.GetSavedFilters) -filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID) // NEW -filters.POST("", cfg.FiltersHandler.CreateSavedFilter) -filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter) -filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter) -``` - -**Notes:** -- ✅ Follows RESTful routing conventions -- ✅ `/:id` route must come BEFORE `""` route (already correct order) -- ✅ Matches existing route patterns - ---- - -### Phase 4: Integration Tests - -#### File: `cmd/server/tests/filters_test.go` - -**Location:** After existing tests (around line 216) - -**Change Type:** Add test cases - -**Implementation:** - -```go -t.Run("GET /api/saved-filters/:id retrieves single filter", func(t *testing.T) { - // First create a filter - reqBody := map[string]interface{}{ - "name": "Test Filter", - "resource_type": "media-items", - "filters": map[string]string{"search": "test"}, - } - body, _ := json.Marshal(reqBody) - - createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+setup.Token) - - createResp, err := client.Do(createReq) - require.NoError(t, err) - defer createResp.Body.Close() - - assert.Equal(t, http.StatusCreated, createResp.StatusCode) - - var createdFilter map[string]interface{} - json.NewDecoder(createResp.Body).Decode(&createdFilter) - filterID := createdFilter["id"].(string) - - // Now retrieve the filter by ID - getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) - getReq.Header.Set("Authorization", "Bearer "+setup.Token) - - getResp, err := client.Do(getReq) - require.NoError(t, err) - defer getResp.Body.Close() - - assert.Equal(t, http.StatusOK, getResp.StatusCode) - - var retrievedFilter map[string]interface{} - json.NewDecoder(getResp.Body).Decode(&retrievedFilter) - assert.Equal(t, "Test Filter", retrievedFilter["name"]) - assert.Equal(t, "media-items", retrievedFilter["resource_type"]) - assert.Equal(t, filterID, retrievedFilter["id"]) - assert.NotEmpty(t, retrievedFilter["filters"]) -}) - -t.Run("GET /api/saved-filters/:id without auth returns 401", func(t *testing.T) { - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil) - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -}) - -t.Run("GET /api/saved-filters/:id with invalid UUID returns 400", func(t *testing.T) { - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/invalid-uuid", nil) - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -}) - -t.Run("GET /api/saved-filters/:id with non-existent filter returns 404", func(t *testing.T) { - fakeID := uuid.New().String() - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+fakeID, nil) - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -}) - -t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) { - // Create filter with user 1 - reqBody := map[string]interface{}{ - "name": "User1 Filter", - "resource_type": "media-items", - "filters": map[string]string{"search": "test"}, - } - body, _ := json.Marshal(reqBody) - - createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+setup.Token) - - createResp, err := client.Do(createReq) - require.NoError(t, err) - defer createResp.Body.Close() - - var createdFilter map[string]interface{} - json.NewDecoder(createResp.Body).Decode(&createdFilter) - filterID := createdFilter["id"].(string) - - // Try to access with user 2 (different token) - // Note: This requires setup.AdminToken or creating a second user - // For now, we'll test with a non-existent filter to verify 404 logic - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) - httpReq.Header.Set("Authorization", "Bearer "+setup.AdminToken) // Different user - - getResp, err := client.Do(httpReq) - require.NoError(t, err) - defer getResp.Body.Close() - - // Should return 404 (not found or access denied) - assert.Equal(t, http.StatusNotFound, getResp.StatusCode) -}) -``` - -**Notes:** -- ✅ Uses `setupTestServer(t)` helper (PROJECT_GUIDELINES.md compliant) -- ✅ Tests all contexts: no auth, user, different user -- ✅ Tests error cases: 400, 401, 404 -- ✅ Tests success case: 200 -- ✅ Follows existing test pattern in filters_test.go -- ✅ Single setup shared across subtests - ---- - -### Phase 5: Bruno API Collection - -#### File: `bruno/saved-filters/Get Saved Filter By ID.yml` - -**Location:** `/home/nymusicman/Code/bookhoard/bruno/saved-filters/` - -**Change Type:** Create new file - -**Implementation:** - -```yaml -info: - name: Get Saved Filter By ID - type: http - seq: 4 -http: - method: GET - url: '{{base_url}}/api/saved-filters/{{filter_id}}' - auth: inherit - -docs: |- - ## Get Saved Filter By ID - - Retrieves a single saved filter by its ID. - - **Method:** GET - - **Endpoint:** /api/saved-filters/:id - - **Authentication:** Required (Bearer token) - - **URL Parameters:** - - `id` (UUID, required): The unique identifier of the saved filter - - Example: "550e8400-e29b-41d4-a716-446655440000" - - Must be a valid UUID format - - **Response:** - Single saved filter object: - ```json - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "My Sci-Fi Books", - "resource_type": "media-items", - "filters": { - "genre_filter": "Science Fiction", - "sort": "title ASC" - }, - "created_at": "2024-03-20T12:00:00Z", - "updated_at": "2024-03-20T12:00:00Z" - } - ``` - - **Status Codes:** - - 200: Success - Returns the saved filter - - 400: Bad Request - Invalid filter ID format - - 401: Unauthorized - Invalid or missing authentication token - - 404: Not Found - Filter doesn't exist or doesn't belong to user - - **Example Usage:** - ```bash - # Get a specific saved filter - curl -H "Authorization: Bearer YOUR_TOKEN" \ - "{{base_url}}/api/saved-filters/550e8400-e29b-41d4-a716-446655440000" - ``` - - **Notes:** - - Filters are user-specific (ownership verified via JWT) - - Returns 404 if filter doesn't exist OR doesn't belong to authenticated user - - Use `GET /api/saved-filters?resource_type=X` to list all filters first - - **Scenarios:** - - Get filter by ID from list response - - Load filter details for editing - - Verify filter exists before updating - - Mobile app on-demand filter loading -``` - -**Notes:** -- ✅ Follows existing Bruno YAML pattern -- ✅ Includes comprehensive documentation -- ✅ Uses variable placeholders (`{{filter_id}}`) -- ✅ Documents all status codes -- ✅ Includes example usage -- ✅ Placed in correct directory structure - ---- - -### Phase 6: API Documentation - -#### File: `docs/developer/api/saved-filters/index.md` - -**Location:** After DELETE section (around line 106) - -**Change Type:** Add new endpoint documentation - -**Implementation:** - -```markdown -### GET /api/saved-filters/:id - -Retrieve a single saved filter by ID. - -**URL Parameters:** -- `id` (UUID, required) - Filter ID to retrieve - -**Response:** Single saved filter object (HTTP 200) - -**Example Request:** -```bash -curl -H "Authorization: Bearer YOUR_TOKEN" \\ - "http://localhost:8765/api/saved-filters/550e8400-e29b-41d4-a716-446655440000" -``` - -**Example Response:** -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "My Sci-Fi Books", - "resource_type": "media-items", - "filters": { - "genre_filter": "Science Fiction", - "sort": "title ASC" - }, - "created_at": "2024-03-20T12:00:00Z", - "updated_at": "2024-03-20T12:00:00Z" -} -``` - -**Error Responses:** -- 400 Bad Request - Invalid filter ID format -- 401 Unauthorized - Invalid or missing authentication token -- 404 Not Found - Filter doesn't exist or doesn't belong to user - -**Use Cases:** -- Mobile apps: Fetch filter details on-demand -- SPAs: Load filter data without page reload -- Editing: Pre-fill filter update form -- Verification: Check filter exists before operations - ---- - -**Notes:** -- ✅ Follows existing documentation pattern -- ✅ Includes example requests/responses -- ✅ Documents all error codes -- ✅ Added to existing index.md file -- ✅ Provides use cases for new endpoint - ---- - -### Phase 7: User Documentation Update - -#### File: `docs/user/library-browsing.md` - -**Location:** Lines 13-15 (Loading Saved Filters section) - -**Change Type:** Update documentation to reflect implemented functionality - -**Current Content:** -```markdown -### Loading Saved Filters - -After saving filters, you can quickly load them from the saved filters dropdown (feature coming soon). For now, saved filters persist across page refreshes. -``` - -**Updated Content:** -```markdown -### Loading Saved Filters - -After saving filters, you can quickly load them from the saved filters dropdown: - -1. Click the **📋 Saved Filters** button (next to the Save Filter button) -2. Select a filter from the dropdown list -3. The filter values are automatically applied to the form -4. Your books are instantly filtered to show matching results - -**Tips:** -- Saved filters appear in the dropdown with their names -- Hover over a filter to see a delete button (🗑️) -- Click a filter name to apply it instantly -- Filters are applied without page reload (instant feedback) - -### Managing Saved Filters - -**Delete a Filter:** -1. Click the **📋 Saved Filters** button -2. Hover over the filter you want to delete -3. Click the **🗑️** delete button -4. Confirm deletion -5. The filter is removed from your list -``` - -**Additional Sections Added:** -- Managing Saved Filters (viewing, deleting) -- Common Use Cases (reading by genre, author collections, series tracking) - -**Notes:** -- ✅ Removes "coming soon" language -- ✅ Provides step-by-step instructions -- ✅ Includes visual indicators (📋, 🗑️, 💾) -- ✅ Explains instant feedback (no page reload) -- ✅ Adds practical use case examples -- ✅ Maintains consistency with existing documentation style - ---- - -### Phase 8: Frontend Integration - -#### File: `web/src/bookshelf.ts` - -**Location:** Lines 44-57 (loadFilter method) - -**Change Type:** Update method to fetch and apply filter data - -**Implementation:** - -```typescript -// Load a saved filter into the form -async loadFilter(event: Event) { - const button = event.target as HTMLElement; - const filterRow = button.closest("[data-filter-id]"); - if (!filterRow) return; - - const filterId = filterRow?.getAttribute("data-filter-id"); - if (!filterId) return; - - const filterName = button.textContent?.trim() || ""; - - // Show loading indicator - showToast(`Loading filter: ${filterName}...`, "info"); - - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } - - try { - // Fetch filter details from API - const response = await fetch(`/api/saved-filters/${filterId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - if (response.status === 404) { - showToast("Filter not found", "error"); - } else { - showToast("Failed to load filter", "error"); - } - return; - } - - const filter = await response.json(); - - // Parse filters JSON (string → object) - const filterData: Record = typeof filter.filters === 'string' - ? JSON.parse(filter.filters) - : filter.filters; - - // Get the hidden filter form - const filterForm = document.getElementById("filter-form") as HTMLFormElement; - if (!filterForm) { - showToast("Filter form not found", "error"); - return; - } - - // Clear existing filter values - filterForm.innerHTML = ` - - - `; - - // Populate form fields from filter data - Object.entries(filterData).forEach(([key, value]) => { - if (value) { // Only set non-empty values - const input = document.createElement("input"); - input.type = "hidden"; - input.name = key; - input.value = value; - filterForm.appendChild(input); - - // Also update visible form fields if they exist - const visibleField = document.querySelector(`[name="${key}"]`) as HTMLInputElement; - if (visibleField) { - visibleField.value = value; - } - } - }); - - // Trigger HTMX to apply the filter - // Use the first input to trigger the change event - const firstInput = filterForm.querySelector("input"); - if (firstInput) { - window.htmx.trigger(firstInput, "change"); - } - - showToast(`Filter applied: ${filterName}`, "success"); - - // Close the dropdown after applying - this.showFiltersDropdown = false; - - } catch (error) { - console.error("Failed to load filter:", error); - showToast("Error loading filter", "error"); - } -} -``` - -**Notes:** -- ✅ Maintains SSR-first: Only fetches on user interaction (click) -- ✅ Uses async/await for API call -- ✅ Handles JSON parsing (filters field may be string or object) -- ✅ Populates both hidden form and visible fields -- ✅ Triggers HTMX to apply filter (no page reload) -- ✅ Proper error handling (404, network errors) -- ✅ User feedback with toast messages -- ✅ Closes dropdown after applying filter - -**How it works:** -1. User clicks saved filter button (in dropdown) -2. Alpine calls `GET /api/saved-filters/:id` to get filter JSON -3. Response format: `{"id": "...", "name": "...", "filters": {"search": "...", "author_filter": "...", ...}}` -4. Parse `filters` object and populate hidden `#filter-form` fields -5. Also update visible form fields (author, genre, etc.) for user feedback -6. Trigger HTMX `change` event on form to submit filter -7. HTMX sends request to `/api/media-items/filtered` with all form data -8. Books grid updates with filtered results - -**SSR-First Compliance:** -- ✅ Initial page load: Server renders everything (no API calls) -- ✅ User interaction only: API called when user clicks filter -- ✅ No async x-init data fetching -- ✅ Progressive enhancement: Works without saved filters -- ✅ Hybrid approach: Alpine for data fetching, HTMX for form submission - ---- - -## Testing Plan - -### Test Cases - -#### 1. Success Case - Retrieve Existing Filter -**Steps:** -1. Create a saved filter via POST -2. GET /api/saved-filters/:id with returned ID -3. Verify response matches created filter - -**Expected:** -- ✅ HTTP 200 status -- ✅ Filter object returned -- ✅ All fields present (id, name, resource_type, filters, timestamps) -- ✅ Filters field is valid JSON - -#### 2. Error Case - Invalid UUID Format -**Steps:** -1. GET /api/saved-filters/invalid-uuid -2. Check response - -**Expected:** -- ✅ HTTP 400 status -- ✅ Error message: "invalid filter ID" - -#### 3. Error Case - Filter Not Found -**Steps:** -1. Generate random UUID -2. GET /api/saved-filters/:random-uuid -3. Check response - -**Expected:** -- ✅ HTTP 404 status -- ✅ Error message: "filter not found" - -#### 4. Error Case - No Authentication -**Steps:** -1. GET /api/saved-filters/:id without Authorization header -2. Check response - -**Expected:** -- ✅ HTTP 401 status -- ✅ Standard auth error response - -#### 5. Security Case - Cross-User Access -**Steps:** -1. User A creates filter -2. User B tries to GET User A's filter -3. Check response - -**Expected:** -- ✅ HTTP 404 status (not 403 - hide existence) -- ✅ Error message: "filter not found" -- ✅ No information leakage about other users' filters - -#### 6. Integration Case - From List to Detail -**Steps:** -1. GET /api/saved-filters?resource_type=media-items -2. Pick first filter ID from list -3. GET /api/saved-filters/:id -4. Verify data consistency - -**Expected:** -- ✅ IDs match -- ✅ Names match -- ✅ Filters data matches -- ✅ Timestamps match - ---- - -## Summary of Changes - -| File | Lines Changed | Type | Complexity | -|------|--------------|------|------------| -| `internal/handlers/filters.go` | +35 lines | Add handler method | Low | -| `internal/services/filters.go` | +8 lines | Add service method | Low | -| `internal/router/filters.go` | +1 line | Add route registration | Low | -| `cmd/server/tests/filters_test.go` | +120 lines | Add integration tests | Medium | -| `bruno/saved-filters/Get Saved Filter By ID.yml` | +80 lines | Create Bruno file | Low | -| `docs/developer/api/saved-filters/index.md` | +35 lines | Update documentation | Low | -| `web/src/bookshelf.ts` | +65 lines | Update loadFilter method | Medium | - -**Total:** ~344 lines of code across 7 files - -**No new database code needed** - All queries and handlers already exist! - ---- - -## Advantages of This Approach - -✅ **100% Pattern-compliant** - Follows established handler/service/test patterns -✅ **Minimal code changes** - Reuses existing database queries and service methods -✅ **Complete CRUD API** - Adds missing GET /:id endpoint -✅ **Future-proof** - Enables mobile/SPA clients -✅ **Backward compatible** - Doesn't affect existing endpoints -✅ **Security maintained** - Ownership verification via service layer -✅ **Well-tested** - Comprehensive integration tests using test_helpers -✅ **Well-documented** - Bruno YAML + API docs -✅ **No database changes** - All queries already exist - ---- - -## Rollback Plan - -If issues arise, rollback is straightforward: - -### Step 1: Revert Handler Changes -```bash -git checkout HEAD~1 internal/handlers/filters.go -``` - -### Step 2: Revert Service Changes -```bash -git checkout HEAD~1 internal/services/filters.go -``` - -### Step 3: Revert Router Changes -```bash -git checkout HEAD~1 internal/router/filters.go -``` - -### Step 4: Revert Test Changes -```bash -git checkout HEAD~1 cmd/server/tests/filters_test.go -``` - -### Step 5: Remove New Files -```bash -rm bruno/saved-filters/Get\ Saved\ Filter\ By\ ID.yml -git checkout HEAD~1 docs/developer/api/saved-filters/index.md -``` - -### Step 6: Rebuild -```bash -podman compose down -podman compose build -podman compose up -d -``` - ---- - -## Potential Issues & Mitigations - -### Issue 1: Route Order Conflict -**Problem:** `/:id` route must come before `""` route - -**Mitigation:** -- Already correct - existing routes are in proper order -- Verified: `filters.PUT("/:id")` is after `filters.GET("")` - -### Issue 2: UUID Parsing Errors -**Problem:** Malformed UUIDs cause panic - -**Mitigation:** -- Handler uses `uuid.Parse()` with error checking -- Returns 400 for invalid UUID format -- Follows existing pattern in UpdateSavedFilter - -### Issue 3: Service Layer Error Messages -**Problem:** Database errors leak implementation details - -**Mitigation:** -- Service layer wraps errors in descriptive messages -- Returns "filter not found or access denied" (hides which) -- Handler maps service errors to HTTP status codes - -### Issue 4: Test Database Cleanup -**Problem:** Tests create filters that persist - -**Mitigation:** -- Uses `setupTestServer(t)` which handles cleanup -- Test server is isolated from development database -- Tests follow existing pattern (no special cleanup needed) - ---- - -## Future Enhancements - -### Phase 3 Improvements (Optional) - -1. **Caching Headers** - - Add `Cache-Control: private, max-age=300` for 5-minute client caching - - Add `ETag` header for conditional requests - - Reduces unnecessary API calls - -2. **Filter Validation Endpoint** - - Add `POST /api/saved-filters/:id/validate` to test filters - - Returns count of matching items without applying filter - - Useful for preview before loading - -3. **Batch Filter Load** - - Add `POST /api/saved-filters/batch` with `ids: []` array - - Reduces round-trips for mobile apps - - Returns array of filter objects - ---- - -## Checklist - -### Implementation -- [ ] Add `GetSavedFilterByID()` method to `internal/handlers/filters.go` -- [ ] Add `GetSavedFilterByID()` method to `internal/services/filters.go` -- [ ] Register route in `internal/router/filters.go` -- [ ] Add integration tests to `cmd/server/tests/filters_test.go` -- [ ] Create Bruno YAML file `bruno/saved-filters/Get Saved Filter By ID.yml` -- [ ] Update documentation `docs/developer/api/saved-filters/index.md` -- [ ] Update `web/src/bookshelf.ts` loadFilter method to fetch and apply filters - -### Testing -- [ ] Test success case (200 - filter found) -- [ ] Test error case (400 - invalid UUID) -- [ ] Test error case (404 - filter not found) -- [ ] Test error case (401 - no auth) -- [ ] Test security case (cross-user access returns 404) -- [ ] Test integration case (list → detail flow) -- [ ] Test frontend: Click saved filter applies form values -- [ ] Test frontend: HTMX trigger updates books grid -- [ ] Test frontend: Error handling (404, network errors) -- [ ] Run full test suite: `go test ./... -v` - -### Build & Deploy -- [ ] Build TypeScript: `npm run build:ts` -- [ ] Rebuild container: `podman compose build` -- [ ] Restart services: `podman compose up -d` -- [ ] Verify endpoint with Bruno or curl -- [ ] Test frontend in browser: Click saved filter, verify books update - -### Documentation -- [ ] Update API documentation (`docs/developer/api/saved-filters/index.md`) -- [ ] Update user documentation for saved filters feature -- [ ] Verify Bruno YAML renders correctly -- [ ] Test docs search finds new content -- [ ] Add comments to code explaining logic - ---- - -## References - -- Current saved filters implementation: `internal/handlers/filters.go` -- Service layer pattern: `internal/services/filters.go` -- Existing tests: `cmd/server/tests/filters_test.go` -- API documentation: `docs/developer/api/saved-filters/index.md` -- Bruno collection: `bruno/saved-filters/` -- PROJECT_GUIDELINES.md: Service layer architecture, testing patterns - ---- - -## Questions & Decisions Log - -### Q1: Should we add pagination to GET /api/saved-filters? -**Decision:** Not in this implementation. Current endpoint returns all filters for a resource type, which is reasonable given user-specific data and likely small counts (< 100). Can add pagination later if needed. - -### Q2: Should we add filtering/sorting to GET /api/saved-filters? -**Decision:** Not in this implementation. Current implementation filters by `resource_type` query parameter which is sufficient. Can add more sophisticated filtering later if needed. - -### Q3: Should we return 403 or 404 for cross-user access attempts? -**Decision:** Return 404 to hide existence of other users' filters (security best practice). Don't reveal whether a filter exists - just "not found or access denied". - -### Q4: Should we add ETag/If-None-Match support? -**Decision:** Not in Phase 1. Can add in Phase 2 as a caching enhancement. For now, simple GET /:id is sufficient. - -### Q5: Do we need to validate that the filter ID belongs to the resource type? -**Decision:** Not necessary. The `resource_type` is part of the filter object returned. Clients can verify it matches their expectations. Ownership verification is already handled by the service layer. - ---- - -**Last Updated:** March 21, 2026 -**Document Version:** 1.0 -**Status:** Ready for Implementation -**Guideline Compliance:** ✅ Fully compliant with PROJECT_GUIDELINES.md -**Backend Changes:** ✅ Minimal - Reuses existing queries and patterns diff --git a/SAVED_FILTERS_IMPLEMENTATION.md b/SAVED_FILTERS_IMPLEMENTATION.md deleted file mode 100644 index 6261c07..0000000 --- a/SAVED_FILTERS_IMPLEMENTATION.md +++ /dev/null @@ -1,1339 +0,0 @@ -# Saved Filters Implementation Plan - -## Overview - -Implement a generic saved filters system that allows users to save and load custom filter presets for any resource type (media-items, collections, devices, etc.). - -**Current Issue:** Bookshelf page has Save Filter UI but backend API (`/api/saved-filters`) doesn't exist, causing 404 errors. - -**Solution:** Create generic `/api/saved-filters` endpoint with `resource_type` field for maximum flexibility. - ---- - -## Architecture - -### Design Principles - -1. **Generic & Extensible:** Single endpoint handles all resource types via `resource_type` field -2. **User-Scoped:** Each user owns their filters (auto-scoped via JWT) -3. **RESTful:** Standard CRUD operations (GET, POST, PUT, DELETE) -4. **JSONB Storage:** Flexible filter schema storage - -### API Endpoint - -**Base URL:** `/api/saved-filters` - -**Authentication:** JWT required (all endpoints) - -**Query Parameters:** -- `resource_type` (string, required for GET) - Filter by resource type - ---- - -## Database Schema - -### New Table: `saved_filters` - -```sql -CREATE TABLE saved_filters ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - resource_type TEXT NOT NULL, -- 'media-items', 'collections', 'devices', etc. - filters JSONB NOT NULL, -- {search: "", author_filter: "", genre: "", ...} - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Index for efficient user+resource lookups -CREATE INDEX idx_saved_filters_user_resource ON saved_filters(user_id, resource_type); - --- Index for name searches (future feature) -CREATE INDEX idx_saved_filters_name ON saved_filters(user_id, name); - --- Trigger to auto-update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_saved_filters_updated_at - BEFORE UPDATE ON saved_filters - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); -``` - -**Schema Rationale:** -- `user_id` foreign key with CASCADE delete - filters removed when user deleted -- `resource_type` string - allows any resource type without schema changes -- `filters` JSONB - flexible storage for different filter structures per resource -- Composite index - optimizes the most common query pattern -- **Trigger for `updated_at`** - Automatically updates timestamp on row modification - ---- - -## SQL Queries - -**File:** `internal/database/queries/queries.sql` - -### Query 1: Get Saved Filters - -```sql --- name: GetSavedFilters :many -SELECT * FROM saved_filters -WHERE user_id = @user_id AND resource_type = @resource_type -ORDER BY created_at DESC; -``` - -**Usage:** List all saved filters for a user + resource type - -### Query 2: Get Single Saved Filter - -```sql --- name: GetSavedFilterByID :one -SELECT * FROM saved_filters -WHERE id = @id AND user_id = @user_id; -``` - -**Usage:** Retrieve specific filter (for editing or validation) - -### Query 3: Create Saved Filter - -```sql --- name: CreateSavedFilter :one -INSERT INTO saved_filters (user_id, name, resource_type, filters) -VALUES (@user_id, @name, @resource_type, @filters) -RETURNING *; -``` - -**Usage:** Create new saved filter - -### Query 4: Update Saved Filter - -```sql --- name: UpdateSavedFilter :one -UPDATE saved_filters -SET name = @name, - filters = @filters, - updated_at = NOW() -WHERE id = @id AND user_id = @user_id -RETURNING *; -``` - -**Usage:** Update filter name or criteria - -### Query 5: Delete Saved Filter - -```sql --- name: DeleteSavedFilter :exec -DELETE FROM saved_filters -WHERE id = @id AND user_id = @user_id; -``` - -**Usage:** Remove saved filter - ---- - -## Backend Implementation - -### Step 1: Run SQLC Generation - -After adding queries to `queries.sql`, regenerate: - -```bash -cd /home/nymusicman/Code/bookhoard -sqlc generate -``` - -This updates `internal/database/queries.sql.go` with new query functions. - -### Step 2: Create Filters Service - -**File:** `internal/services/filters.go` - -```go -package services - -import ( - "bookhoard/internal/database" - "context" - "encoding/json" - "fmt" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -type FiltersService struct { - db *database.Queries -} - -func NewFiltersService(db *database.Queries) *FiltersService { - return &FiltersService{db: db} -} - -// GetSavedFilters - Retrieve all saved filters for a user + resource type -func (s *FiltersService) GetSavedFilters(ctx context.Context, userID uuid.UUID, resourceType string) ([]database.SavedFilters, error) { - filters, err := s.db.GetSavedFilters(ctx, database.GetSavedFiltersParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - ResourceType: resourceType, - }) - if err != nil { - return nil, fmt.Errorf("failed to get saved filters: %w", err) - } - - return filters, nil -} - -// CreateSavedFilter - Create a new saved filter -func (s *FiltersService) CreateSavedFilter(ctx context.Context, userID uuid.UUID, name string, resourceType string, filters map[string]string) (database.SavedFilters, error) { - // Business logic: Validate filter name uniqueness per user + resource type - existing, err := s.db.GetSavedFilters(ctx, database.GetSavedFiltersParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - ResourceType: resourceType, - }) - if err == nil { - for _, f := range existing { - if f.Name == name { - return database.SavedFilters{}, fmt.Errorf("filter with name '%s' already exists for this resource type", name) - } - } - } - - // Convert filters map to JSONB ([]byte) - filtersJSON, err := json.Marshal(filters) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("failed to marshal filters: %w", err) - } - - filter, err := s.db.CreateSavedFilter(ctx, database.CreateSavedFilterParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - Name: name, - ResourceType: resourceType, - Filters: pgtype.JSONB{Bytes: filtersJSON, Valid: true}, - }) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("failed to create saved filter: %w", err) - } - - return filter, nil -} - -// UpdateSavedFilter - Update an existing saved filter -func (s *FiltersService) UpdateSavedFilter(ctx context.Context, userID uuid.UUID, filterID uuid.UUID, name string, filters map[string]string) (database.SavedFilters, error) { - // Business logic: Verify filter exists and belongs to user - existing, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{ - ID: pgtype.UUID{Bytes: filterID, Valid: true}, - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - }) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err) - } - - // Business logic: Check name uniqueness (excluding current filter) - allFilters, err := s.db.GetSavedFilters(ctx, database.GetSavedFiltersParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - ResourceType: existing.ResourceType, - }) - if err == nil { - for _, f := range allFilters { - existingID := uuid.Must(uuid.FromBytes(f.ID.Bytes[:])) - if f.Name == name && existingID != filterID { - return database.SavedFilters{}, fmt.Errorf("filter with name '%s' already exists for this resource type", name) - } - } - } - - // Convert filters to JSONB - filtersJSON, err := json.Marshal(filters) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("failed to marshal filters: %w", err) - } - - updated, err := s.db.UpdateSavedFilter(ctx, database.UpdateSavedFilterParams{ - ID: pgtype.UUID{Bytes: filterID, Valid: true}, - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - Name: name, - Filters: pgtype.JSONB{Bytes: filtersJSON, Valid: true}, - }) - if err != nil { - return database.SavedFilters{}, fmt.Errorf("failed to update saved filter: %w", err) - } - - return updated, nil -} - -// DeleteSavedFilter - Delete a saved filter -func (s *FiltersService) DeleteSavedFilter(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) error { - err := s.db.DeleteSavedFilter(ctx, database.DeleteSavedFilterParams{ - ID: pgtype.UUID{Bytes: filterID, Valid: true}, - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - }) - if err != nil { - return fmt.Errorf("failed to delete saved filter: %w", err) - } - return nil -} -``` - -### Step 3: Create Filters Handler - -**File:** `internal/handlers/filters.go` - -```go -package handlers - -import ( - "bookhoard/internal/database" - "bookhoard/internal/services" - "encoding/json" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/labstack/echo/v5" -) - -type FiltersHandler struct { - db *database.Queries - filtersService *services.FiltersService -} - -func NewFiltersHandler(db *database.Queries) *FiltersHandler { - return &FiltersHandler{ - db: db, - filtersService: services.NewFiltersService(db), - } -} - -// CreateSavedFilterRequest - Request body for creating/updating filters -type CreateSavedFilterRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - ResourceType string `json:"resource_type" validate:"required,oneof=media-items collections devices"` - Filters map[string]string `json:"filters" validate:"required"` -} - -// SavedFilterResponse - Response format (matches database model structure) -type SavedFilterResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - ResourceType string `json:"resource_type"` - Filters json.RawMessage `json:"filters"` // JSONB as raw bytes - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// Helper function to check if request wants HTML response -func wantsHTML(header http.Header) bool { - accept := header.Get("Accept") - return accept != "" && (accept == "text/html" || accept.Contains("text/html")) -} - -// GetSavedFilters - GET /api/saved-filters?resource_type=media-items -// Supports both JSON (API) and HTML (HTMX) responses -func (h *FiltersHandler) GetSavedFilters(c echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) - - resourceType := c.QueryParam("resource_type") - if resourceType == "" { - if wantsHTML(c.Request().Header) { - return c.String(http.StatusBadRequest, "resource_type query parameter is required") - } - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "resource_type query parameter is required", - }) - } - - filters, err := h.filtersService.GetSavedFilters(c.Request().Context(), userUUID, resourceType) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch filters"}) - } - - // Return JSON for API requests - response := make([]SavedFilterResponse, len(filters)) - for i, f := range filters { - response[i] = SavedFilterResponse{ - ID: uuid.Must(uuid.FromBytes(f.ID.Bytes[:])), - Name: f.Name, - ResourceType: f.ResourceType, - Filters: json.RawMessage(f.Filters), // Return JSONB as-is - CreatedAt: f.CreatedAt.Time.Format(time.RFC3339), - UpdatedAt: f.UpdatedAt.Time.Format(time.RFC3339), - } - } - - return c.JSON(http.StatusOK, response) -} - -// CreateSavedFilter - POST /api/saved-filters -// Supports both JSON (API) and HTML (HTMX) responses -func (h *FiltersHandler) CreateSavedFilter(c echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) - - var req CreateSavedFilterRequest - if err := c.Bind(&req); err != nil { - if wantsHTML(c.Request().Header) { - return c.String(http.StatusBadRequest, "Invalid request body") - } - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - } - - if req.Name == "" || req.ResourceType == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "name and resource_type are required"}) - } - - filter, err := h.filtersService.CreateSavedFilter(c.Request().Context(), userUUID, req.Name, req.ResourceType, req.Filters) - if err != nil { - if err.Error() == "filter with name '"+req.Name+"' already exists for this resource type" { - if wantsHTML(c.Request().Header) { - return c.String(http.StatusConflict, "Filter with this name already exists") - } - return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create filter"}) - } - - // Return JSON response (JSONB handled automatically) - response := SavedFilterResponse{ - ID: uuid.Must(uuid.FromBytes(filter.ID.Bytes[:])), - Name: filter.Name, - ResourceType: filter.ResourceType, - Filters: json.RawMessage(filter.Filters), - CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339), - UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339), - } - - // Add HX-Redirect for HTMX requests - if c.Request().Header.Get("HX-Request") == "true" { - c.Response().Header().Set("HX-Redirect", "/bookshelf") - } - - return c.JSON(http.StatusCreated, response) -} - -// DeleteSavedFilter - DELETE /api/saved-filters/:id -// Supports both JSON (API) and HTML (HTMX) responses -func (h *FiltersHandler) DeleteSavedFilter(c echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) - - filterID, err := uuid.Parse(c.Param("id")) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter ID"}) - } - - err = h.filtersService.DeleteSavedFilter(c.Request().Context(), userUUID, filterID) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete filter"}) - } - - return c.NoContent(http.StatusNoContent) -} - -// UpdateSavedFilter - PUT /api/saved-filters/:id -// Supports both JSON (API) and HTML (HTMX) responses -func (h *FiltersHandler) UpdateSavedFilter(c echo.Context) error { - user := c.Get("user").(database.Users) - userUUID := uuid.UUID(user.ID.Bytes) - - filterID, err := uuid.Parse(c.Param("id")) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter ID"}) - } - - var req CreateSavedFilterRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - } - - filter, err := h.filtersService.UpdateSavedFilter(c.Request().Context(), userUUID, filterID, req.Name, req.Filters) - if err != nil { - if err.Error() == "filter with name '"+req.Name+"' already exists for this resource type" { - return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update filter"}) - } - - response := SavedFilterResponse{ - ID: filterID, - Name: filter.Name, - ResourceType: filter.ResourceType, - Filters: json.RawMessage(filter.Filters), - CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339), - UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339), - } - - return c.JSON(http.StatusOK, response) -} -``` - -### Step 3: Register Routes - -**File:** `internal/router/filters.go` (create new file) - -```go -package router - -func registerFiltersRoutes(cfg *Config) { - e := cfg.Echo - - // JWT middleware for protected routes - jwtMiddleware := createJWTMiddleware(cfg) - protected := e.Group("/api", jwtMiddleware) - - // Saved filters routes - filters := protected.Group("/saved-filters") - filters.GET("", cfg.FiltersHandler.GetSavedFilters) - filters.POST("", cfg.FiltersHandler.CreateSavedFilter) - filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter) - filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter) -} -``` - -**File:** `internal/router/router.go` - -In the `RegisterRoutes` function (around line 209), add the route registration: - -```go -func RegisterRoutes(cfg *Config) *handlers.Handler { - // ... existing middleware setup - - // Register route groups - registerAuthRoutes(cfg, rateLimitMiddleware) - registerLibraryRoutes(cfg) - registerDeviceRoutes(cfg) - registerSystemRoutes(cfg) - registerSyncRoutes(cfg) - registerCollectionsRoutes(cfg) - registerDashboardRoutes(cfg) - registerMediaRoutes(cfg) - registerSearchRoutes(cfg) - registerMatchingRoutes(cfg) - registerConflictRoutes(cfg) - registerAnalyticsRoutes(cfg) - registerQueueRoutes(cfg) - registerJobRoutes(cfg) - registerFiltersRoutes(cfg) // NEW: Add this line - registerOPDSRoutes(cfg) - registerWebSocketRoutes(cfg) - registerFrontendRoutes(cfg) - registerDocumentationRoutes(cfg) - // ... rest of routes -} -``` - -**File:** `internal/router/router.go` - -Add the import and registration: - -```go -import ( - "bookhoard/internal/router/filters" - // ... other imports -) - -func NewRouter(cfg *Config) *echo.Echo { - // ... existing setup - - // Register routes - registerFiltersRoutes(cfg) - - // ... other route registrations - - return e -} -``` - -### Step 5: Add Handler to Router and Test Setup - -**Part A: Update Config Struct** - -**File:** `internal/router/router.go` - -Update Config struct (add after existing handlers): - -```go -type Config struct { - // ... existing handlers - CollectionHandler *handlers.CollectionHandler - FiltersHandler *handlers.FiltersHandler // NEW - DashboardHandler *handlers.DashboardHandler - // ... rest of handlers -} -``` - -**Part B: Register Routes** - -**File:** `internal/router/filters.go` (already created in Step 4) - -Ensure routes are registered in the router.go `NewRouter` function. - -**Part C: Update Test Server Setup** - -**File:** `cmd/server/tests/test_helpers_test.go` - -In the `setupTestServer` function, after other handlers are created (around line 290): - -```go -// After: collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) - -// Create filters handler -filtersHandler := handlers.NewFiltersHandler(queries) -``` - -Then add to the router.Config struct initialization (around line 340): - -```go -routerConfig := &router.Config{ - // ... existing handlers - CollectionHandler: collectionHandler, - FiltersHandler: filtersHandler, // NEW - DashboardHandler: dashboardHandler, - // ... rest of handlers -} -``` - -### Step 6: Add Integration Tests - -**File:** `cmd/server/tests/filters_test.go` - -```go -package main - -import ( - "bytes" - "encoding/json" - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSavedFilters(t *testing.T) { - setup := setupTestServer(t) - client := &http.Client{} - - t.Run("GET /api/saved-filters without auth returns 401", func(t *testing.T) { - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil) - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("GET /api/saved-filters with user auth returns empty array initially", func(t *testing.T) { - httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil) - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var filters []map[string]interface{} - json.NewDecoder(resp.Body).Decode(&filters) - assert.Equal(t, 0, len(filters)) - }) - - t.Run("POST /api/saved-filters creates filter", func(t *testing.T) { - reqBody := map[string]interface{}{ - "name": "My Sci-Fi Books", - "resource_type": "media-items", - "filters": map[string]string{ - "genre_filter": "Science Fiction", - "sort": "title ASC", - }, - } - body, _ := json.Marshal(reqBody) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - - var filter map[string]interface{} - json.NewDecoder(resp.Body).Decode(&filter) - assert.Equal(t, "My Sci-Fi Books", filter["name"]) - assert.Equal(t, "media-items", filter["resource_type"]) - assert.NotEmpty(t, filter["id"]) - // Filters field will be JSON object (JSONB as raw bytes) - assert.NotEmpty(t, filter["filters"]) - }) - - t.Run("POST /api/saved-filters with duplicate name returns 409", func(t *testing.T) { - reqBody := map[string]interface{}{ - "name": "Duplicate Test", - "resource_type": "media-items", - "filters": map[string]string{"search": "test"}, - } - body, _ := json.Marshal(reqBody) - - // First request - httpReq1, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - httpReq1.Header.Set("Content-Type", "application/json") - httpReq1.Header.Set("Authorization", "Bearer "+setup.Token) - - resp1, err := client.Do(httpReq1) - require.NoError(t, err) - resp1.Body.Close() - - assert.Equal(t, http.StatusCreated, resp1.StatusCode) - - // Second request with same name - body2, _ := json.Marshal(reqBody) - httpReq2, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body2)) - httpReq2.Header.Set("Content-Type", "application/json") - httpReq2.Header.Set("Authorization", "Bearer "+setup.Token) - - resp2, err := client.Do(httpReq2) - require.NoError(t, err) - resp2.Body.Close() - - assert.Equal(t, http.StatusConflict, resp2.StatusCode) - }) - - t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) { - // Create filter first - createReq := map[string]interface{}{ - "name": "Original Name", - "resource_type": "media-items", - "filters": map[string]string{"search": "original"}, - } - body, _ := json.Marshal(createReq) - - createHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - createHTTP.Header.Set("Content-Type", "application/json") - createHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - - createResp, err := client.Do(createHTTP) - require.NoError(t, err) - defer createResp.Body.Close() - - assert.Equal(t, http.StatusCreated, createResp.StatusCode) - - var createdFilter map[string]interface{} - json.NewDecoder(createResp.Body).Decode(&createdFilter) - filterID := createdFilter["id"].(string) - - // Update filter - updateReq := map[string]interface{}{ - "name": "Updated Name", - "resource_type": "media-items", - "filters": map[string]string{"search": "updated"}, - } - updateBody, _ := json.Marshal(updateReq) - - updateHTTP, _ := http.NewRequest("PUT", setup.Server.URL+"/api/saved-filters/"+filterID, bytes.NewBuffer(updateBody)) - updateHTTP.Header.Set("Content-Type", "application/json") - updateHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - - updateResp, err := client.Do(updateHTTP) - require.NoError(t, err) - defer updateResp.Body.Close() - - assert.Equal(t, http.StatusOK, updateResp.StatusCode) - - var updatedFilter map[string]interface{} - json.NewDecoder(updateResp.Body).Decode(&updatedFilter) - assert.Equal(t, "Updated Name", updatedFilter["name"]) - }) - - t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) { - // Create filter - createReq := map[string]interface{}{ - "name": "To Be Deleted", - "resource_type": "media-items", - "filters": map[string]string{}, - } - body, _ := json.Marshal(createReq) - - createHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - createHTTP.Header.Set("Content-Type", "application/json") - createHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - - createResp, err := client.Do(createHTTP) - require.NoError(t, err) - defer createResp.Body.Close() - - var createdFilter map[string]interface{} - json.NewDecoder(createResp.Body).Decode(&createdFilter) - filterID := createdFilter["id"].(string) - - // Delete filter - deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) - deleteHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - - deleteResp, err := client.Do(deleteHTTP) - require.NoError(t, err) - deleteResp.Body.Close() - - assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode) - }) - - t.Run("User cannot access another user's filter", func(t *testing.T) { - // Create regular user - user := createRegularUserOnce(t, setup.DB) - userToken := loginUserWithCredentials(t, setup.Server, user.Email, user.Password) - - // User creates a filter - createReq := map[string]interface{}{ - "name": "User1 Private", - "resource_type": "media-items", - "filters": map[string]string{}, - } - body, _ := json.Marshal(createReq) - - createHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) - createHTTP.Header.Set("Content-Type", "application/json") - createHTTP.Header.Set("Authorization", "Bearer "+userToken) - - createResp, err := client.Do(createHTTP) - require.NoError(t, err) - defer createResp.Body.Close() - - var createdFilter map[string]interface{} - json.NewDecoder(createResp.Body).Decode(&createdFilter) - filterID := createdFilter["id"].(string) - - // Admin user tries to delete regular user's filter - deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) - deleteHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - - deleteResp, err := client.Do(deleteHTTP) - require.NoError(t, err) - deleteResp.Body.Close() - - assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode) - }) -} -``` - -**Test Coverage Summary:** -- ✅ No user context (401 unauthorized) -- ✅ Regular user context (CRUD operations) -- ✅ User isolation (cannot access other users' filters) -- ✅ Duplicate name validation -- ✅ Ownership verification - -**Test Helpers Used:** -- `setupTestServer(t)` - Creates test server with proper cleanup -- `createRegularUserOnce(t, db)` - Creates regular user with unique credentials -- `loginUserWithCredentials(t, server, email, password)` - Logs in and returns token -- `setup.Server.URL` - Base URL for HTTP requests -- `setup.Token` - Admin auth token -- `client := &http.Client{}` - HTTP client for requests - ---- - -## Frontend Updates - -### Step 1: Update bookshelf.ts - -**File:** `web/src/bookshelf.ts` - -**Update `loadSavedFilters` function:** - -```typescript -async function loadSavedFilters(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - // OLD: const response = await fetch("/api/bookshelf/filters", { - const response = await fetch("/api/saved-filters?resource_type=media-items", { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const filters = await response.json(); - localStorage.setItem("bookshelfFilters", JSON.stringify(filters)); - } - } catch (error) { - console.error("Failed to load saved filters:", error); - } -} -``` - -**Update `saveFilter` function:** - -```typescript -async function saveFilter(event: Event): Promise { - event.preventDefault(); - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } - - const filterForm = document.getElementById("filter-form") as HTMLFormElement; - const formData = new FormData(filterForm); - const filterData: Record = {}; - - formData.forEach((value, key) => { - filterData[key] = value.toString(); - }); - - // Add filter name from Alpine state - const filterName = (window as any).Alpine?.$store.bookshelf?.filterName; - if (!filterName) { - showToast("Please enter a filter name", "error"); - return; - } - - try { - // OLD: const response = await fetch("/api/bookshelf/filters", { - const response = await fetch("/api/saved-filters", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - name: filterName, - resource_type: "media-items", // NEW: specify resource type - filters: filterData, - }), - }); - - if (response.ok) { - showToast("Filter saved successfully", "success"); - // Close modal via Alpine - (window as any).Alpine?.$store.bookshelf.showSaveModal = false; - loadSavedFilters(); - } else { - showToast("Failed to save filter", "error"); - } - } catch (error) { - console.error("Failed to save filter:", error); - showToast("Error saving filter", "error"); - } -} -``` - ---- - -## Documentation - -### API Documentation - -**File:** `docs/developer/api/saved-filters/index.md` - -```markdown -# Saved Filters API - -## Overview - -The Saved Filters API allows users to save and load custom filter presets for any resource type (media-items, collections, devices, etc.). Filters are user-specific and automatically scoped via JWT authentication. - -**Base URL:** `/api/saved-filters` - -**Authentication:** JWT token required (Bearer token) - ---- - -## Endpoints - -### GET /api/saved-filters - -Retrieve all saved filters for the authenticated user and a specific resource type. - -**Query Parameters:** -- `resource_type` (string, required) - Filter by resource type (e.g., "media-items", "collections") - -**Response:** Array of saved filter objects - -**Example Request:** -\`\`\`bash -curl -H "Authorization: Bearer YOUR_TOKEN" \\ - "http://localhost:8765/api/saved-filters?resource_type=media-items" -\`\`\` - -**Example Response:** -\`\`\`json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "My Sci-Fi Books", - "resource_type": "media-items", - "filters": { - "genre_filter": "Science Fiction", - "sort": "title ASC" - }, - "created_at": "2024-03-20T12:00:00Z", - "updated_at": "2024-03-20T12:00:00Z" - } -] -\`\`\` - ---- - -### POST /api/saved-filters - -Create a new saved filter for the authenticated user. - -**Request Body:** -\`\`\`json -{ - "name": "My Custom Filter", - "resource_type": "media-items", - "filters": { - "search": "keyword", - "author_filter": "Author Name", - "genre_filter": "Genre", - "sort": "title ASC" - } -} -\`\`\` - -**Validation:** -- `name` (string, required, max 100 chars) - Must be unique per user + resource type -- `resource_type` (string, required) - Must be valid resource type -- `filters` (object, required) - Key-value pairs of filter criteria - -**Response:** Created filter object (HTTP 201) - -**Error Responses:** -- 409 Conflict - Filter name already exists for this user + resource type - ---- - -### PUT /api/saved-filters/:id - -Update an existing saved filter. - -**URL Parameters:** -- `id` (UUID, required) - Filter ID to update - -**Request Body:** Same as POST - -**Response:** Updated filter object - -**Error Responses:** -- 404 Not Found - Filter doesn't exist or doesn't belong to user -- 409 Conflict - New name conflicts with existing filter - ---- - -### DELETE /api/saved-filters/:id - -Delete a saved filter. - -**URL Parameters:** -- `id` (UUID, required) - Filter ID to delete - -**Response:** 204 No Content (success) - -**Error Responses:** -- 404 Not Found - Filter doesn't exist or doesn't belong to user - ---- - -## User Documentation - -**File:** `docs/user/library-browsing.md` (add section) - -\`\`\`markdown -## Saving Custom Filters - -The bookshelf page allows you to save custom filter presets for quick access. - -### How to Save a Filter - -1. Navigate to the **All Books** page -2. Set your desired filters (genre, author, series, etc.) -3. Click the **💾 Save Filter** button -4. Enter a name for your filter (e.g., "My Sci-Fi Books") -5. Click **Save** - -### Loading Saved Filters - -After saving filters, you can quickly load them from the saved filters dropdown (feature coming soon). For now, saved filters persist across page refreshes. - -### Filter Privacy - -Saved filters are **private to your account**. Other users cannot see or modify your filters. -\`\`\` - ---- - -## Bruno OpenCollection - -**File:** `bruno/saved-filters/saved-filters.bru` - -Create Bruno collection for API testing (see Bruno documentation for format). - ---- - -## Implementation Order - -### Phase 1: Database (15 min) -1. Add `saved_filters` table to `database/schema/schema.sql` (with trigger) -2. Add SQL queries to `internal/database/queries/queries.sql` -3. Run `sqlc generate` to regenerate Go code -4. Apply schema to local database: - ```bash - podman compose down -v # Delete volumes (WARNING: loses all data) - podman compose up -d # Start fresh with new schema - ``` - -### Phase 2: Backend (45 min) -1. Create `internal/services/filters.go` with business logic -2. Create `internal/handlers/filters.go` with HTTP handlers (supports JSON + HTML) -3. Create `internal/router/filters.go` with route registration -4. Update `internal/router/router.go`: - - Add `FiltersHandler` to Config struct - - Add `registerFiltersRoutes(cfg)` call in RegisterRoutes() -5. Update `cmd/server/tests/test_helpers_test.go`: - - Add `filtersHandler := handlers.NewFiltersHandler(queries)` in setupTestServer - - Add `FiltersHandler: filtersHandler` to router.Config - -### Phase 3: Frontend (10 min) -1. Update `web/src/bookshelf.ts` to use new endpoint -2. Add `resource_type: "media-items"` to saveFilter body -3. Update loadSavedFilters to use query parameter - -### Phase 4: Testing (30 min) -1. Create `cmd/server/tests/filters_test.go` with integration tests -2. Test with Bruno/Postman: - - GET `/api/saved-filters?resource_type=media-items` - - POST `/api/saved-filters` with test data - - PUT `/api/saved-filters/:id` - - DELETE `/api/saved-filters/:id` -3. Test bookshelf page: - - Load bookshelf page - - Create a filter - - Refresh page (filter should persist) - -### Phase 5: Documentation (10 min) -1. Create `docs/developer/api/saved-filters/index.md` -2. Update `docs/user/library-browsing.md` (add filter saving section) -3. Create Bruno OpenCollection YAML files -4. Verify docs render at `/docs` endpoint -5. Test docs search finds new content - ---- - -## Project Guidelines Compliance - -This implementation follows **PROJECT_GUIDELINES.md** and matches existing codebase patterns: - -✅ **Service Layer Architecture:** -- Business logic in `internal/services/filters.go` -- Handlers create services internally (NOT injected) -- Pattern: `filtersService: services.NewFiltersService(db)` -- Services return database models (NOT custom domain models) -- JSONB returned as `[]byte` from database model -- Matches: `DashboardHandler`, `CollectionHandler` patterns - -✅ **Handler Constructor Pattern:** -- Handler receives `db *database.Queries` (NOT services) -- Handler creates service: `filtersService: services.NewFiltersService(db)` -- Matches: `NewDashboardHandler(db)`, `NewCollectionHandler(db, ...)` - -✅ **JSONB Handling Pattern:** -- Service returns `database.SavedFilters` with `Filters []byte` -- Handler converts to `json.RawMessage` for JSON responses -- No `.AsMap()` calls (doesn't exist) -- JSON serialization handles `[]byte` automatically -- Matches: `collections.AutoAssignRules` pattern - -✅ **Error Handling Pattern:** -- All errors wrapped with context: `fmt.Errorf("failed to X: %w", err)` -- Service layer does business logic validation -- Handlers return appropriate HTTP status codes -- Matches: Collection service error patterns - -✅ **Config Struct Pattern:** -- Only `FiltersHandler` in Config (NOT `FiltersService`) -- Services are internal to handlers -- Matches: `Config` struct has handlers, not services - -✅ **Router Registration Pattern:** -- Route function `registerFiltersRoutes(cfg)` in `internal/router/filters.go` -- Called in `RegisterRoutes()` function -- Matches: `registerCollectionsRoutes(cfg)` pattern - -✅ **Content Negotiation:** -- Handlers support both JSON (API) and HTML (HTMX) -- Uses `wantsHTML()` helper to check Accept header -- Returns appropriate response format -- Matches: Collections handler pattern - -✅ **Database Schema Pattern:** -- Uses pgx v5 driver -- SQL queries via sqlc -- JSONB for flexible schema -- Trigger for auto-updating `updated_at` -- Proper indexing for performance -- Cascade delete on user removal - -✅ **Testing Requirements:** -- Integration tests in `cmd/server/tests/filters_test.go` -- Uses `setupTestServer(t)` helper (one call per test) -- Direct HTTP requests with `http.Client{}` -- Uses `setup.Server.URL` for base URL -- Uses `setup.Token` for admin authentication -- Test helpers: `createRegularUserOnce()`, `loginUserWithCredentials()` -- JSONB validated as JSON objects in assertions -- Matches: `collections_bulk_test.go`, `device_test.go` patterns - -✅ **Documentation Requirements:** -- API docs in `docs/developer/api/saved-filters/` -- User docs in `docs/user/library-browsing.md` -- Bruno OpenCollection YAML files included - -✅ **Frontend Standards:** -- TypeScript (not JavaScript) -- Procedural style (no OOP) -- SSR-first with Alpine.js for UI state -- TailwindCSS (no custom CSS) - ---- - -## Testing Checklist - ---- - -## Testing Checklist - -### API Tests (Bruno/Postman) - -**GET /api/saved-filters?resource_type=media-items** -```bash -# Should return empty array initially -curl -H "Authorization: Bearer $TOKEN" \ - http://localhost:8765/api/saved-filters?resource_type=media-items -``` - -**POST /api/saved-filters** -```bash -curl -X POST \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "My Sci-Fi Books", - "resource_type": "media-items", - "filters": { - "genre_filter": "Science Fiction", - "sort": "title ASC" - } - }' \ - http://localhost:8765/api/saved-filters -``` - -**GET /api/saved-filters?resource_type=media-items** -```bash -# Should return the filter just created -``` - -**DELETE /api/saved-filters/:id** -```bash -curl -X DELETE \ - -H "Authorization: Bearer $TOKEN" \ - http://localhost:8765/api/saved-filters/{filter_id} -``` - -### UI Tests - -1. **Load bookshelf page** - Should work without 404 errors -2. **Fill filters** - Set genre to "Science Fiction" -3. **Click "💾 Save Filter"** - Modal should open -4. **Enter name** - "Test Filter" -5. **Click Save** - Success toast, modal closes -6. **Refresh page** - Filter should be in saved list -7. **Clear filters** - Reset all fields -8. **Load saved filter** - Fields should populate -9. **Delete filter** - (if UI added) Filter removed - ---- - -## Future Enhancements - -### Potential Features (Not in Initial Scope) - -1. **Filter Management UI** - - List all saved filters - - Edit filter names - - Delete filters - - Duplicate filters - -2. **Shared Filters** - - Admin can create system-wide filters - - Users can subscribe to shared filters - -3. **Filter Analytics** - - Track most-used filters - - Suggest filters based on usage - -4. **Filter Groups** - - Organize filters into groups/folders - -5. **Advanced Query Builder** - - Visual filter builder - - AND/OR logic - - Nested conditions - ---- - -## Related Files - -- `BOOKSHELF_COLLECTIONS_FILTER_PLAN.md` - Original bookshelf implementation plan -- `internal/database/queries/queries.sql` - SQL query definitions -- `internal/database/schema/schema.sql` - Database schema -- `internal/handlers/filters.go` - New handler file -- `internal/router/filters.go` - New router file -- `web/src/bookshelf.ts` - Frontend updates - ---- - -## Notes - -- **JSONB Storage:** Using JSONB for filters allows flexible schema without migrations -- **JSONB Handling:** Service returns JSONB as `[]byte` (database model), JSON serialization handles it automatically -- **User Scoping:** All queries automatically filter by user_id from JWT token -- **Resource Validation:** Currently validates `resource_type` against known values; can be relaxed for extensibility -- **Error Handling:** All errors wrapped with context using `fmt.Errorf("failed to X: %w", err)` -- **Pagination:** Not implemented for GET list (add if users have many filters) -- **Content Negotiation:** Handlers support both JSON (API) and HTML (HTMX) responses based on Accept header -- **updated_at Trigger:** Database trigger automatically updates timestamp on modifications - ---- - -## Success Criteria - -✅ **Functional:** -- Bookshelf page loads without 404 errors -- Users can save custom filters -- Filters persist across page refreshes -- Filters are user-specific (private) -- API works with both JSON (API clients) and HTML (HTMX) - -✅ **Technical:** -- Generic endpoint works for any resource_type -- Database schema supports extensibility -- RESTful API design -- JWT authentication enforced -- JSONB properly handled as raw bytes -- Content negotiation works (JSON vs HTML) - -✅ **Code Quality:** -- Follows PROJECT_GUIDELINES.md -- Consistent with existing API patterns -- Proper error handling with context -- SQL queries use sqlc conventions -- Services return database models -- Handlers support both JSON and HTML responses diff --git a/UNIFIED_SEARCH_IMPLEMENTATION.md b/UNIFIED_SEARCH_IMPLEMENTATION.md new file mode 100644 index 0000000..f6d3e12 --- /dev/null +++ b/UNIFIED_SEARCH_IMPLEMENTATION.md @@ -0,0 +1,1227 @@ +# Unified Search and Filter Implementation Plan + +## Executive Summary + +**Goal:** Consolidate `/api/media-items/filtered` and `/api/media-items/search` endpoints into a single unified `/api/media-items/search` endpoint that supports: +- All-fuzzy filters (except years/booleans) +- Google-style "exact match in quotes" for search queries +- Field-specific fuzzy search for autocomplete dropdowns +- Combined search + filters functionality +- Backward compatibility with saved filters + +**Approach:** Surgical, incremental changes that reuse existing code, following PROJECT_GUIDELINES.md strictly. + +--- + +## Current State Analysis + +### Existing Endpoints + +#### 1. `/api/media-items/search` (internal/router/search.go:11) +- **Purpose:** Global fuzzy search across all libraries +- **Handler:** `SearchMediaItems` (internal/handlers/media.go:1419-1493) +- **Logic:** + - Uses `SearchMediaItems` query (ILIKE pattern matching) + - Falls back to `SearchMediaItemsFuzzy` query (pg_trgm `word_similarity()`) + - Threshold: 0.3 similarity +- **Parameters:** `q`, `library_id`, `limit`, `offset` +- **Used by:** Search box (incorrectly - currently calls `/filtered`) + +#### 2. `/api/media-items/filtered` (internal/router/media.go:17) +- **Purpose:** Exact match filtering +- **Handler:** `ListMediaItemsFiltered` (internal/handlers/media.go:705-766) +- **Logic:** + - `ListMediaItemsFiltered` query (queries.sql:227-268) + - Author/series: `ILIKE` (case-insensitive, NO wildcards) + - Genre/language: `=` (exact match) + - Years: `>=`, `<=` (exact range) + - Boolean: exact match +- **Parameters:** `author_filter`, `series_filter`, `genre_filter`, `language_filter`, `year_min`, `year_max`, `has_cover`, `sort`, `limit`, `offset` +- **Used by:** All filter fields, search box (incorrectly) + +### Current Frontend Implementation + +**Search box** (templates/bookshelf.templ:73-83): +```html + + hx-trigger="keyup changed delay:300ms" + hx-include="#filter-form"> +``` + +**Filter fields** (templates/bookshelf.templ:90-117): +```html + + + +``` + +### Database Schema + +**Indexes exist** (database/schema/schema.sql): +- B-tree indexes: author, genre, language, series, copyright_year (lines 365-373) +- GIN indexes: tags_search, contributors_search (lines 145-146) + +**Missing:** GIN indexes for pg_trgm fuzzy search on text fields + +### Saved Filters + +**Implementation:** Frontend-only (web/src/bookshelf.ts:44-173) +- Stores filter config as JSON in database +- Loads filter config into form via `loadFilter()` method +- Makes API call to `/api/saved-filters/:id` +- Populates form fields with exact values + +**Impact:** Should work seamlessly with new endpoint (only field names matter) + +--- + +## Implementation Phases + +### Phase 1: Database Schema - Add GIN Indexes + +**File:** `database/schema/schema.sql` + +**Add after line 146:** + +```sql +-- Add GIN indexes for pg_trgm fuzzy search performance +CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm + ON media_items USING GIN (author gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm + ON media_items USING GIN (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm + ON media_items USING GIN (series gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm + ON media_items USING GIN (genre gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm + ON media_items USING GIN (language gin_trgm_ops); +``` + +**Verification:** +```bash +podman compose down -v +podman compose up -d +podman exec bookhoard_db psql -U postgres -d bookhoard -c "\d+ media_items" +``` + +--- + +### Phase 2: SQL Queries - Add Unified Search Query + +**File:** `internal/database/queries/queries.sql` + +**Add after `SearchMediaItemsFuzzy` (line 460):** + +```sql +-- name: SearchMediaItemsUnified :many +SELECT mi.*, l.name as library_name, lt.name as library_type_name +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') +WHERE COALESCE(lv.is_visible, true) = true + AND mi.library_id = sqlc.narg('library_id') + -- Fuzzy author filter + AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3) + -- Fuzzy series filter + AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3) + -- Fuzzy genre filter + AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3) + -- Fuzzy language filter + AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3) + -- Year range (exact) + AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min')) + AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max')) + -- Boolean (exact) + AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL) + -- Search query (fuzzy or exact based on quotes) + AND ( + sqlc.narg('search_query') = '' OR + -- Fuzzy search (default) + sqlc.narg('is_exact_search') = false AND ( + word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR + word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR + word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR + EXISTS ( + SELECT 1 FROM unnest(mi.tags_search) AS tag + WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3 + LIMIT 1 + ) OR + EXISTS ( + SELECT 1 FROM unnest(mi.contributors_search) AS contributor + WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3 + LIMIT 1 + ) + ) OR + -- Exact search (with quotes) + sqlc.narg('is_exact_search') = true AND ( + mi.title ILIKE sqlc.narg('search_pattern') OR + mi.author ILIKE sqlc.narg('search_pattern') OR + mi.series ILIKE sqlc.narg('search_pattern') OR + sqlc.narg('search_pattern') = ANY(mi.tags_search) OR + sqlc.narg('search_pattern') = ANY(mi.contributors_search) + ) + ) +ORDER BY + CASE + WHEN sqlc.narg('search_query') != '' THEN + GREATEST( + CASE WHEN sqlc.narg('is_exact_search') = false THEN + word_similarity(sqlc.narg('search_query'), mi.title) + ELSE 0 END, + CASE WHEN sqlc.narg('is_exact_search') = false THEN + word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) + ELSE 0 END, + word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')), + word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) + ) + ELSE 0 + END DESC, + mi.title ASC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +``` + +**Add field value search query (for autocomplete dropdowns):** + +```sql +-- name: SearchFieldValues :many +SELECT DISTINCT + CASE sqlc.narg('field_type') + WHEN 'author' THEN mi.author + WHEN 'genre' THEN mi.genre + WHEN 'series' THEN mi.series + WHEN 'language' THEN mi.language + END as value, + COUNT(*) as count, + CASE sqlc.narg('field_type') + WHEN 'author' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) + WHEN 'genre' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) + WHEN 'series' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) + WHEN 'language' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) + END as score +FROM media_items mi +WHERE mi.library_id = sqlc.narg('library_id') + AND ( + (sqlc.narg('field_type') = 'author' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3) OR + (sqlc.narg('field_type') = 'genre' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3) OR + (sqlc.narg('field_type') = 'series' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3) OR + (sqlc.narg('field_type') = 'language' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3) + ) +GROUP BY value, score +HAVING value IS NOT NULL AND value != '' +ORDER BY score DESC, count DESC +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +``` + +**Regenerate Go code:** +```bash +go generate ./internal/database +``` + +**Verify:** Check `internal/database/queries.sql.go` for new functions + +--- + +### Phase 3: Create Search Service + +**File:** `internal/services/search.go` (NEW) + +```go +package services + +import ( + "bookhoard/internal/database" + "context" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// SearchService handles all search and filter operations +type SearchService struct { + db *database.Queries +} + +// NewSearchService creates a new search service instance +func NewSearchService(db *database.Queries) *SearchService { + return &SearchService{db: db} +} + +// SearchParams contains parameters for unified search +type SearchParams struct { + UserID pgtype.UUID + LibraryID pgtype.UUID + AuthorFilter string + SeriesFilter string + GenreFilter string + LanguageFilter string + YearMin int + YearMax int + HasCover bool + SearchQuery string + Limit int + Offset int +} + +// parseSearchQuery detects quoted strings for exact match search +// Returns (isExact, processedQuery) +// Examples: +// "asimov" → (false, "asimov") +// "\"Asimov, Isaac\"" → (true, "Asimov, Isaac") +func (s *SearchService) parseSearchQuery(query string) (bool, string) { + query = strings.TrimSpace(query) + if strings.HasPrefix(query, "\"") && strings.HasSuffix(query, "\"") && len(query) >= 2 { + return true, strings.Trim(query, "\"") + } + return false, query +} + +// SearchMediaItemsUnified handles combined search + filters +// Supports: +// - Fuzzy text filters (author, series, genre, language) +// - Exact filters (year range, has_cover boolean) +// - Fuzzy search query (or exact match with quotes) +// - Combined search + filters +func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) { + // Parse search query for exact match detection + isExact, searchQuery := s.parseSearchQuery(params.SearchQuery) + searchPattern := "" + if isExact { + searchPattern = "%" + searchQuery + "%" + } + + // Build database parameters + dbParams := database.SearchMediaItemsUnifiedParams{ + UserID: params.UserID, + LibraryID: params.LibraryID, + AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, + LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, + YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, + YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, + HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true}, + SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, + IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, + SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, + Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true}, + } + + // Execute unified search query + results, err := s.db.SearchMediaItemsUnified(ctx, dbParams) + if err != nil { + return nil, err + } + + return results, nil +} + +// FieldSearchParams contains parameters for field-specific search (autocomplete) +type FieldSearchParams struct { + UserID pgtype.UUID + LibraryID pgtype.UUID + FieldType string // "author", "genre", "series", "language" + SearchQuery string + Limit int + Offset int +} + +// FieldValue represents a single field value with metadata +type FieldValue struct { + Value string + Count int64 + Score float64 +} + +// SearchFieldValues handles field-specific search for autocomplete dropdowns +// Returns distinct values with counts and similarity scores +func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) { + // Build database parameters + dbParams := database.SearchFieldValuesParams{ + UserID: params.UserID, + LibraryID: params.LibraryID, + FieldType: pgtype.Text{String: params.FieldType, Valid: true}, + SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true}, + Limit: pgtype.Int4{Int32: params.Limit), Valid: true}, + Offset: pgtype.Int4{Int32: params.Offset), Valid: true}, + } + + // Execute field values query + results, err := s.db.SearchFieldValues(ctx, dbParams) + if err != nil { + return nil, err + } + + // Convert to service-level response type + fieldValues := make([]FieldValue, len(results)) + for i, r := range results { + fieldValues[i] = FieldValue{ + Value: r.Value, + Count: r.Count, + Score: r.Score, + } + } + + return fieldValues, nil +} +``` + +**Verification:** +```bash +go build ./internal/handlers +``` + +**Note:** Router initialization will also need updating (see Phase 7) + +#### 4.1 Fix Search Box Endpoint + +**File:** `templates/bookshelf.templ` + +**Change line 79:** +```html + + + + + +``` + +**Also fix pagination buttons (lines 298, 311):** +```html + +hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }" + + +hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }" +``` + +**Change filter field names (lines 92, 109, 126, etc.):** +```html + + + + + + + +``` + +#### 4.2 Add Autocomplete Dropdowns + +**File:** `templates/bookshelf.templ` + +**Replace author filter (lines 85-101):** + +```html + +
+ +
+ + + + +
+
+``` + +**Similar changes for genre, series, language filters** + +#### 4.3 Add Frontend Functions + +**File:** `web/src/bookshelf.ts` + +**Add at end of file:** + +```typescript +// Fetch field values for autocomplete dropdowns +async function fetchFieldValues( + field: string, + search: string, + datalistId: string +): Promise { + const token = localStorage.getItem("token"); + if (!token) { + console.error("Not authenticated"); + return; + } + + if (search.length < 2) return; // Wait for at least 2 characters + + const currentLibraryId = ( + document.getElementById("library-select") as HTMLSelectElement + )?.value; + if (!currentLibraryId) { + console.error("No library selected"); + return; + } + + try { + const response = await fetch( + `/api/media-items/search?${field}=${encodeURIComponent( + search + )}&library_id=${currentLibraryId}&limit=50`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + console.error("Failed to fetch field values"); + return; + } + + const data = await response.json(); + + // Update datalist + const datalist = document.getElementById(datalistId); + if (!datalist) { + console.error(`Datalist ${datalistId} not found`); + return; + } + + // Clear existing options + datalist.innerHTML = ""; + + // Add new options + data.results.forEach((item: { value: string; count: number }) => { + const option = document.createElement("option"); + option.value = item.value; + option.textContent = `${item.value} (${item.count})`; + datalist.appendChild(option); + }); + } catch (error) { + console.error("Error fetching field values:", error); + } +} + +// Fetch author values for autocomplete +async function fetchAuthorValues(input: HTMLInputElement): Promise { + const search = input.value; + await fetchFieldValues("authors", search, "author-datalist"); +} + +// Fetch genre values for autocomplete +async function fetchGenreValues(input: HTMLInputElement): Promise { + const search = input.value; + await fetchFieldValues("genres", search, "genre-datalist"); +} + +// Fetch series values for autocomplete +async function fetchSeriesValues(input: HTMLInputElement): Promise { + const search = input.value; + await fetchFieldValues("series", search, "series-datalist"); +} + +// Fetch language values for autocomplete +async function fetchLanguageValues(input: HTMLInputElement): Promise { + const search = input.value; + await fetchFieldValues("languages", search, "language-datalist"); +} + +// Register functions globally +(window as any).fetchAuthorValues = fetchAuthorValues; +(window as any).fetchGenreValues = fetchGenreValues; +(window as any).fetchSeriesValues = fetchSeriesValues; +(window as any).fetchLanguageValues = fetchLanguageValues; +``` + +**Verify TypeScript compilation:** +```bash +cd web && npm run build +``` + +--- + +### Phase 5: Tests + +**File:** `cmd/server/tests/search_unified_test.go` (NEW) + +```go +package main + +import ( + "bookhoard/internal/handlers" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnifiedSearch(t *testing.T) { + setup := setupDeviceTest(t) + defer setup.Server.Close() + + libraryID := setup.CreateLibrary(t, "Test Search Library", "ebooks") + _ = setup.CreateDevice(t, "Test Search Device", "koreader", "search-test-123") + + t.Run("Fuzzy author filter", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&author_filter=asimov", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match author") + }) + + t.Run("Fuzzy genre filter", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&genre_filter=scifi", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match genre") + }) + + t.Run("Exact match with quotes", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=%22Foundation%20and%20Empire%22", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should exact match quoted query") + }) + + t.Run("Combined search + filters", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=foundation&author_filter=asimov", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should combine search and filters") + }) + + t.Run("Field-specific search for dropdown - authors", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&authors=asimov", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should return author values") + + var response struct { + Results []struct { + Value string `json:"value"` + Count int64 `json:"count"` + Score float64 `json:"score"` + } `json:"results"` + Total int `json:"total"` + } + err := json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err, "Should unmarshal field values response") + assert.Greater(t, len(response.Results), 0, "Should have results") + }) + + t.Run("Year range filter (exact)", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&year_min=2000&year_max=2020", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should filter by year range") + }) + + t.Run("Boolean filter (exact)", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&has_cover=true", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover") + }) + + t.Run("Missing library_id", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil) + req.Header.Set("Authorization", "Bearer "+setup.UserToken) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "Should require library_id") + }) +} +``` + +**Verify tests:** +```bash +go test ./cmd/server/tests -v -run TestUnifiedSearch +``` + +--- + +### Phase 6: Documentation Updates + +#### 6.1 Update API Documentation + +**File:** `docs/developer/api/media-items/search_media_items.md` + +**Replace entire content:** + +```markdown +# Search Media Items (Unified) + +Search and filter media items with fuzzy matching support. + +**Note:** All text filters use fuzzy matching via PostgreSQL pg_trgm (threshold: 0.3 similarity). This handles typos and partial matches automatically. Use quotes for exact match. + +**Endpoint**: `GET /api/media-items/search` +**Auth**: Required + +## Query Parameters + +### Search Parameters + +| Parameter | Type | Required | Description | +| ---------- | ------- | -------- | ---------------------------------------------------- | +| q | string | No | Search query (fuzzy by default, exact in quotes) | +| library_id | string | Yes | Filter to specific library (UUID) | +| limit | integer | No | Number of results (default 50, max 200) | +| offset | integer | No | Number to skip for pagination | + +### Filter Parameters (All Fuzzy Except Years/Booleans) + +| Parameter | Type | Description | +| -------------- | ------- | --------------------------------------------------- | +| author_filter | string | Fuzzy match author field | +| series_filter | string | Fuzzy match series field | +| genre_filter | string | Fuzzy match genre field | +| language_filter| string | Fuzzy match language field | +| year_min | integer | Minimum copyright year (exact range) | +| year_max | integer | Maximum copyright year (exact range) | +| has_cover | boolean | Filter by cover image presence (exact boolean) | + +### Autocomplete Parameters (Field-Specific Search) + +| Parameter | Type | Description | +| ---------- | ------ | ---------------------------------------------- | +| authors | string | Search author values for autocomplete dropdown | +| genres | string | Search genre values for autocomplete dropdown | +| series | string | Search series values for autocomplete dropdown | +| languages | string | Search language values for autocomplete | + +## Request Headers + +| Header | Type | Required | Description | +| ------------- | ------ | -------- | ------------ | +| Authorization | string | Yes | Bearer token | + +## Search Behavior + +### Fuzzy Search (Default) + +Handles typos and partial matches automatically: + +- `"asimov"` → matches "Asimov, Isaac", "Asimov, Foundation" +- `"scifi"` → matches "Sci-Fi", "Science Fiction" +- `"azimov"` → matches "Asimov, Isaac" (typo tolerance) + +### Exact Search (With Quotes) + +Use double quotes for exact phrase matching: + +- `"\"Foundation and Empire\""` → only "Foundation and Empire" +- `"\"Asimov, Isaac\""` → only "Asimov, Isaac" + +### Filter Behavior + +**Text filters (fuzzy):** +- `author_filter=asimov` → fuzzy matches author field +- `genre_filter=scifi` → fuzzy matches genre field + +**Exact filters:** +- `year_min=2000&year_max=2010` → exact year range +- `has_cover=true` → exact boolean match + +## Example Requests + +### 1. Global Fuzzy Search + +Search all fields for "foundation": + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 2. Fuzzy Author Filter + +Find books by "asimov" (matches "Asimov, Isaac"): + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&author_filter=asimov +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 3. Combined Search + Filters + +Search "foundation" within books by "asimov": + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation&author_filter=asimov +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 4. Exact Match with Quotes + +Exact phrase search: + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q="Foundation%20and%20Empire" +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 5. Multiple Fuzzy Filters + +Fiction books from 2000-2010: + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&genre_filter=fiction&year_min=2000&year_max=2010 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 6. Field-Specific Search (Autocomplete) + +Get author values for dropdown: + +```http +GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&authors=asimov&limit=50 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Response:** +```json +{ + "results": [ + {"value": "Asimov, Isaac", "count": 47, "score": 0.8}, + {"value": "Asimov, Isaac & Robert Silverberg", "count": 2, "score": 0.75} + ], + "total": 2 +} +``` + +## Response (200 OK) + +**Media items search:** +```json +[ + { + "id": "uuid", + "title": "Foundation", + "author": "Asimov, Isaac", + "library_id": "...", + "library_name": "E-Books" + } +] +``` + +**Field values search (autocomplete):** +```json +{ + "results": [ + {"value": "Asimov, Isaac", "count": 47, "score": 0.8} + ], + "total": 1 +} +``` + +## Error Responses + +| Code | Description | +| ---- | ---------------------------- | +| 400 | Invalid library_id | +| 400 | Missing library_id | +| 401 | Invalid or expired token | +| 404 | No results found | +``` + +#### 6.2 Update Bruno Collection + +**File:** `bruno/media-items/Search All Libraries.yml` + +**Update params section:** + +```yaml +params: + - name: q + value: "foundation" + type: query + disabled: false + - name: library_id + value: "{{library_id}}" + type: query + disabled: false + - name: author_filter + value: "" + type: query + disabled: true + - name: genre_filter + value: "" + type: query + disabled: true + - name: year_min + value: "" + type: query + disabled: true + - name: year_max + value: "" + type: query + disabled: true +``` + +**Add new Bruno files:** + +`bruno/media-items/Fuzzy Author Filter.yml` +`bruno/media-items/Fuzzy Genre Filter.yml` +`bruno/media-items/Combined Search and Filters.yml` +`bruno/media-items/Exact Match With Quotes.yml` +`bruno/media-items/Field Values Search - Authors.yml` +`bruno/media-items/scenarios/Unified Search Scenarios.yml` + +#### 6.3 Delete Deprecated Filtered Endpoint Documentation + +**Files to delete:** +- `docs/developer/api/media-items/filtered_media_items.md` (if exists) +- Remove `/api/media-items/filtered` from `docs/developer/api/api-reference.md` +- Remove `/api/media-items/filtered` from `docs/developer/api-reference.md` + +--- + +### Phase 7: Update Handler Initialization + +**Files to update:** +- `cmd/server/main.go` (line ~122-126) +- `cmd/server/tests/test_helpers_test.go` (line ~468-472) + +**In both files, find the MediaHandler initialization:** + +```go +// BEFORE +mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) + +// AFTER +searchService := services.NewSearchService(queries) +mediaHandler := handlers.NewMediaHandler(queries, libraryService, searchService, worker) +``` + +**Note:** This follows the same pattern as `conversionService` which is created in main.go (line 118) and passed to handlers. + +**Verify compilation:** +```bash +go build ./cmd/server +go test ./cmd/server/tests -run TestNonExistent # Compile test only +``` + +--- + +### Phase 8: Cleanup + +#### 7.1 Delete Deprecated Filtered Endpoint + +**File:** `internal/router/media.go` + +**Delete line 17:** +```go +protected.GET("/media-items/filtered", cfg.MediaHandler.ListMediaItemsFiltered) +``` + +**File:** `internal/handlers/media.go` + +**Delete handler `ListMediaItemsFiltered` (lines 705-766):** +```go +// DELETE THIS FUNCTION +func (mh *MediaHandler) ListMediaItemsFiltered(c *echo.Context) error { + ... +} +``` + +**File:** `internal/database/queries/queries.sql` + +**Delete query `ListMediaItemsFiltered` (lines 227-268):** +```sql +-- DELETE THIS QUERY +-- name: ListMediaItemsFiltered :many +... +``` + +**Regenerate Go code:** +```bash +go generate ./internal/database +``` + +#### 7.2 Delete Deprecated Tests + +**File:** `cmd/server/tests/filtering_test.go` + +**Delete entire file** (tests now covered by `search_unified_test.go`) + +**OR** update tests to use `/api/media-items/search` endpoint if some test cases are still valuable + +#### 7.3 Delete Deprecated Bruno Files + +**Delete:** `bruno/media-items/scenarios/Filter Media Items.yml` (if exists) + +--- + +### Phase 10: Verification + +#### 8.1 Compile Check + +```bash +go build ./... +cd web && npm run build +``` + +#### 8.2 Run Tests + +```bash +go test ./cmd/server/tests -v -run TestUnifiedSearch +go test ./cmd/server/tests -v # All tests should pass +``` + +#### 8.3 Manual Testing Checklist + +- [ ] Search box uses `/api/media-items/search` +- [ ] Fuzzy author filter works (asimov → Asimov, Isaac) +- [ ] Fuzzy genre filter works (scifi → Sci-Fi) +- [ ] Exact match with quotes works ("Foundation and Empire") +- [ ] Combined search + filters works +- [ ] Autocomplete dropdowns populate correctly +- [ ] Year range filter works (exact) +- [ ] Boolean filter works (exact) +- [ ] Saved filters load correctly +- [ ] Pagination works +- [ ] No errors in browser console +- [ ] No errors in server logs + +#### 8.4 Documentation Verification + +```bash +# Start server +podman compose up -d + +# Access docs at http://localhost:8080/docs +# Verify search endpoint documentation renders correctly +# Verify search finds new documentation +``` + +--- + +**Commit 3: Add search service** +git add internal/services/search.go +git commit -m "feat: add SearchService for unified search functionality + +- Create SearchService with SearchMediaItemsUnified method +- Add SearchFieldValues method for autocomplete dropdowns +- Add parseSearchQuery helper for quote detection +- Move all business logic from handler to service layer +- Follow established service pattern (FiltersService, CollectionService)" + +```bash +# Commit 1: Database schema (GIN indexes) +git add database/schema/schema.sql +git commit -m "feat: add GIN indexes for pg_trgm fuzzy search performance + +- Add gin_trgm_ops indexes on author, title, series, genre, language +- Improves fuzzy search performance on large libraries +- Required for unified search/filter endpoint" + +# Commit 2: SQL queries +git add internal/database/queries/queries.sql +git commit -m "feat: add unified search SQL query with fuzzy filters + +- Add SearchMediaItemsUnified query with all-fuzzy filters +- Add SearchFieldValues query for autocomplete dropdowns +- Support exact match with quotes detection +- Combine search + filters in single query" + +# Commit 3: Add search service +git add internal/services/search.go +git commit -m "feat: add SearchService for unified search functionality + +- Create SearchService with SearchMediaItemsUnified method +- Add SearchFieldValues method for autocomplete dropdowns +- Add parseSearchQuery helper for quote detection +- Move all business logic from handler to service layer +- Follow established service pattern (FiltersService, CollectionService)" + +# Commit 4: Regenerate database code +git add internal/database/queries.sql.go internal/database/models.go +git commit -m "chore: regenerate database code from queries.sql" + +# Commit 5: Update handler to use search service +git add internal/handlers/media.go +git commit -m "refactor: update SearchMediaItems handler to use SearchService + +- Add searchService to MediaHandler struct +- Update NewMediaHandler constructor +- Refactor SearchMediaItems to delegate to service layer +- Add handleUnifiedSearch method (thin wrapper) +- Add handleFieldValuesSearch method (thin wrapper) +- Handler now only extracts params and calls service" + +# Commit 6: Update handler initialization +git add cmd/server/main.go cmd/server/tests/test_helpers_test.go +git commit -m "refactor: add SearchService to handler initialization + +- Instantiate SearchService in main.go and test_helpers_test.go +- Pass searchService to MediaHandler constructor +- Follow existing pattern (like conversionService) +- Update both production and test initialization" + +# Commit 7: Frontend templates +git add templates/bookshelf.templ +git commit -m "feat: fix search box to use /search endpoint with autocomplete + +- Change search box from /filtered to /search +- Change pagination buttons to use /search +- Add datalist elements for autocomplete +- Add Alpine.js event handlers for dropdown population" + +# Commit 8: Frontend TypeScript +git add web/src/bookshelf.ts +git commit -m "feat: add autocomplete dropdown support for filter fields + +- Add fetchFieldValues function for API calls +- Add fetchAuthorValues, fetchGenreValues, etc. +- Register functions globally for template access +- Populate datalist elements with fuzzy search results" + +# Commit 9: Tests +git add cmd/server/tests/search_unified_test.go +git commit -m "test: add comprehensive tests for unified search endpoint + +- Test fuzzy author/genre filters +- Test exact match with quotes +- Test combined search + filters +- Test field-specific search for dropdowns +- Test year range and boolean filters +- Use setupTestServer helper following PROJECT_GUIDELINES.md" + +# Commit 10: Documentation +git add docs/developer/api/media-items/search_media_items.md +git add bruno/media-items/Search\ All\ Libraries.yml +git add bruno/media-items/Fuzzy\ Author\ Filter.yml +git add bruno/media-items/Fuzzy\ Genre\ Filter.yml +git add bruno/media-items/Combined\ Search\ and\ Filters.yml +git add bruno/media-items/Exact\ Match\ With\ Quotes.yml +git add bruno/media-items/Field\ Values\ Search\ -\ Authors.yml +git commit -m "docs: update search API documentation with fuzzy filters + +- Document all-fuzzy filters (except years/booleans) +- Document exact match with quotes +- Document combined search + filters +- Document field-specific search for autocomplete +- Add comprehensive examples +- Update Bruno collection with new endpoints" + +# Commit 11: Delete deprecated code +git add internal/router/media.go +git add internal/handlers/media.go +git add internal/database/queries/queries.sql +git add internal/database/queries.sql.go +git add internal/database/models.go +git add cmd/server/tests/filtering_test.go +git commit -m "refactor: remove deprecated /filtered endpoint + +- Delete /media-items/filtered route registration +- Delete ListMediaItemsFiltered handler +- Delete ListMediaItemsFiltered SQL query +- Delete filtering_test.go (covered by search_unified_test.go) +- Regenerate database code after query deletion" + +# Commit 12: Final verification +git add . +git commit -m "chore: final verification of unified search implementation + +- All tests pass +- Documentation renders correctly +- Bruno collection updated +- No compilation errors +- Manual testing complete" +``` + +--- + +## Risk Mitigation + +### Potential Issues + +1. **Saved filters breaking:** Frontend-only, should work seamlessly +2. **Performance degradation:** GIN indexes should prevent this +3. **Breaking mobile apps:** `/filtered` endpoint will be deleted +4. **Test coverage gaps:** Comprehensive tests in Phase 5 + +### Rollback Plan + +If issues arise: +```bash +# Revert to previous commit +git revert HEAD + +# Or restore specific files +git show HEAD~1:internal/handlers/media.go > internal/handlers/media.go +git show HEAD~1:templates/bookshelf.templ > templates/bookshelf.templ +``` + +--- + +## Timeline Estimate + +- Phase 1 (Database): 30 minutes +- Phase 2 (SQL Queries): 1 hour +- Phase 3 (Search Service): 2 hours +- Phase 4 (Handler Updates): 1 hour +- Phase 5 (Router Updates): 15 minutes +- Phase 6 (Frontend): 2 hours +- Phase 7 (TypeScript): 1 hour +- Phase 8 (Tests): 2 hours +- Phase 9 (Documentation): 1 hour +- Phase 10 (Cleanup): 30 minutes +- Phase 11 (Verification): 1 hour + +**Total: ~12 hours** + +--- + +## Success Criteria + +✅ All business logic in `services/search.go` (service layer pattern) +✅ Handler is thin - only extracts params and calls service +✅ All text filters use fuzzy matching (pg_trgm) +✅ Exact match with quotes works +✅ Combined search + filters work +✅ Autocomplete dropdowns populate correctly +✅ Years/booleans remain exact match +✅ Saved filters load correctly +✅ All tests pass using `setupTestServer` helper +✅ Documentation updated +✅ No breaking changes to saved filters +✅ Deprecated `/filtered` endpoint removed +✅ Bruno collection updated