diff --git a/SAVED_FILTERS_IMPLEMENTATION.md b/SAVED_FILTERS_IMPLEMENTATION.md new file mode 100644 index 0000000..6261c07 --- /dev/null +++ b/SAVED_FILTERS_IMPLEMENTATION.md @@ -0,0 +1,1339 @@ +# 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