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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user