# 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