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.
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
- Generic & Extensible: Single endpoint handles all resource types via
resource_typefield - User-Scoped: Each user owns their filters (auto-scoped via JWT)
- RESTful: Standard CRUD operations (GET, POST, PUT, DELETE)
- 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_idforeign key with CASCADE delete - filters removed when user deletedresource_typestring - allows any resource type without schema changesfiltersJSONB - 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 cleanupcreateRegularUserOnce(t, db)- Creates regular user with unique credentialsloginUserWithCredentials(t, server, email, password)- Logs in and returns tokensetup.Server.URL- Base URL for HTTP requestssetup.Token- Admin auth tokenclient := &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)
- Create
internal/services/filters.gowith business logic - Create
internal/handlers/filters.gowith HTTP handlers (supports JSON + HTML) - Create
internal/router/filters.gowith route registration - Update
internal/router/router.go:- Add
FiltersHandlerto Config struct - Add
registerFiltersRoutes(cfg)call in RegisterRoutes()
- Add
- Update
cmd/server/tests/test_helpers_test.go:- Add
filtersHandler := handlers.NewFiltersHandler(queries)in setupTestServer - Add
FiltersHandler: filtersHandlerto router.Config
- Add
Phase 3: Frontend (10 min)
- Update
web/src/bookshelf.tsto use new endpoint - Add
resource_type: "media-items"to saveFilter body - Update loadSavedFilters to use query parameter
Phase 4: Testing (30 min)
- Create
cmd/server/tests/filters_test.gowith integration tests - Test with Bruno/Postman:
- GET
/api/saved-filters?resource_type=media-items - POST
/api/saved-filterswith test data - PUT
/api/saved-filters/:id - DELETE
/api/saved-filters/:id
- GET
- Test bookshelf page:
- Load bookshelf page
- Create a filter
- Refresh page (filter should persist)
Phase 5: Documentation (10 min)
- Create
docs/developer/api/saved-filters/index.md - Update
docs/user/library-browsing.md(add filter saving section) - Create Bruno OpenCollection YAML files
- Verify docs render at
/docsendpoint - 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
[]bytefrom database model - Matches:
DashboardHandler,CollectionHandlerpatterns
✅ 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.SavedFilterswithFilters []byte - Handler converts to
json.RawMessagefor JSON responses - No
.AsMap()calls (doesn't exist) - JSON serialization handles
[]byteautomatically - Matches:
collections.AutoAssignRulespattern
✅ 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
FiltersHandlerin Config (NOTFiltersService) - Services are internal to handlers
- Matches:
Configstruct has handlers, not services
✅ Router Registration Pattern:
- Route function
registerFiltersRoutes(cfg)ininternal/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.URLfor base URL - Uses
setup.Tokenfor admin authentication - Test helpers:
createRegularUserOnce(),loginUserWithCredentials() - JSONB validated as JSON objects in assertions
- Matches:
collections_bulk_test.go,device_test.gopatterns
✅ 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
- Load bookshelf page - Should work without 404 errors
- Fill filters - Set genre to "Science Fiction"
- Click "💾 Save Filter" - Modal should open
- Enter name - "Test Filter"
- Click Save - Success toast, modal closes
- Refresh page - Filter should be in saved list
- Clear filters - Reset all fields
- Load saved filter - Fields should populate
- Delete filter - (if UI added) Filter removed
Future Enhancements
Potential Features (Not in Initial Scope)
-
Filter Management UI
- List all saved filters
- Edit filter names
- Delete filters
- Duplicate filters
-
Shared Filters
- Admin can create system-wide filters
- Users can subscribe to shared filters
-
Filter Analytics
- Track most-used filters
- Suggest filters based on usage
-
Filter Groups
- Organize filters into groups/folders
-
Advanced Query Builder
- Visual filter builder
- AND/OR logic
- Nested conditions
Related Files
BOOKSHELF_COLLECTIONS_FILTER_PLAN.md- Original bookshelf implementation planinternal/database/queries/queries.sql- SQL query definitionsinternal/database/schema/schema.sql- Database schemainternal/handlers/filters.go- New handler fileinternal/router/filters.go- New router fileweb/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_typeagainst 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