Files
bookhoard/SAVED_FILTERS_IMPLEMENTATION.md
T
john-okeefe 09449eff39 docs: add saved filters implementation plan
Add comprehensive implementation plan for generic saved filters feature:
- Generic /api/saved-filters endpoint with resource_type field
- Service layer architecture with business logic
- JSONB storage for flexible filter schemas
- Integration test patterns
- Support for both JSON (API) and HTML (HTMX) responses
- Database schema with auto-updating updated_at trigger

This plan follows PROJECT_GUIDELINES.md and matches existing codebase patterns
(service layer, handler constructors, error handling, testing patterns).

Related to bookshelf page save filter functionality.
2026-03-20 22:57:55 -04:00

42 KiB

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

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

-- 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

-- 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

-- 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

-- 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

-- 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:

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

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

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)

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:

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:

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):

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):

// 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):

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

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:

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:

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

# 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

# Should return empty array initially
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8765/api/saved-filters?resource_type=media-items

POST /api/saved-filters

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

# Should return the filter just created

DELETE /api/saved-filters/:id

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

  • 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