feat(api): implement saved filters backend service and handlers

Add complete backend implementation for saved filters CRUD operations
with proper service layer architecture and RESTful API endpoints.

Service Layer (internal/services/filters.go):
- NewFiltersService() constructor following project patterns
- GetSavedFilters(): Retrieve all filters for user + resource type
- CreateSavedFilter(): Create filter with duplicate name validation
- UpdateSavedFilter(): Update filter with ownership verification
- DeleteSavedFilter(): Delete filter with user scoping

Business Logic:
- Filter name uniqueness enforced per user + resource type
- User ownership validation on all operations (JWT user_id)
- JSONB marshaling/unmarshaling for flexible filter storage
- Proper error wrapping with context messages

Handler Layer (internal/handlers/filters.go):
- NewFiltersHandler() constructor (receives db.Queries)
- GetSavedFilters: GET /api/saved-filters?resource_type=X
- CreateSavedFilter: POST /api/saved-filters
- UpdateSavedFilter: PUT /api/saved-filters/:id
- DeleteSavedFilter: DELETE /api/saved-filters/:id

Content Negotiation:
- Supports both JSON (API clients) and HTML (HTMX) responses
- wantsHTML() helper checks Accept header
- HX-Redirect header for HTMX form submissions
- Proper status codes (200, 201, 204, 400, 401, 404, 409)

Router Configuration:
- registerFiltersRoutes() function in internal/router/filters.go
- JWT middleware protection on all endpoints
- RESTful route structure: /api/saved-filters
- Registered in main router.go RegisterRoutes() function
- Added FiltersHandler to router.Config struct

Test Infrastructure:
- Added FiltersHandler to test server setup (test_helpers_test.go)
- FiltersHandler initialized in setupTestServer() function
- Router.Config includes FiltersHandler for integration tests

Code Quality:
- Follows PROJECT_GUIDELINES.md service layer patterns
- Uses database models (not custom domain models)
- JSONB returned as []byte (matches collections pattern)
- All errors wrapped with context using fmt.Errorf
- Handlers create services internally (not dependency injection)

Part of: Saved Filters Implementation (Phase 2: Backend)
Related: #saved-filters-feature
This commit is contained in:
2026-03-21 00:16:10 -04:00
parent e17a96123f
commit ddfc832b68
5 changed files with 329 additions and 0 deletions
+2
View File
@@ -466,6 +466,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
services.WorkerInstance = worker
jobsHandler := handlers.NewJobsHandler(queries, worker)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
@@ -514,6 +515,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
OPDSHandler: opdsHandler,
+187
View File
@@ -0,0 +1,187 @@
package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"encoding/json"
"net/http"
"strings"
"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" || strings.Contains(accept, "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)
}
+16
View File
@@ -0,0 +1,16 @@
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)
}
+2
View File
@@ -52,6 +52,7 @@ type Config struct {
QueueHandler *handlers.QueueHandler
CollectionHandler *handlers.CollectionHandler
Worker *services.Worker
FiltersHandler *handlers.FiltersHandler
DashboardHandler *handlers.DashboardHandler
DashboardService *services.DashboardService
OPDSHandler *handlers.OPDSHandler
@@ -215,6 +216,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg)
registerJobRoutes(cfg)
registerFiltersRoutes(cfg)
registerOPDSRoutes(cfg)
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
+122
View File
@@ -0,0 +1,122 @@
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: filtersJSON,
})
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: filtersJSON,
})
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
}