# 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<void> {
  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<void> {
  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<string, string> = {};

  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
