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
This commit is contained in:
@@ -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<string, string> = 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 = `
|
||||
<input type="hidden" name="limit" value="50"/>
|
||||
<input type="hidden" name="offset" value="0"/>
|
||||
`;
|
||||
|
||||
// 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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user