diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 6785182..82c3004 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -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, diff --git a/internal/handlers/filters.go b/internal/handlers/filters.go new file mode 100644 index 0000000..53aa0b5 --- /dev/null +++ b/internal/handlers/filters.go @@ -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) +} diff --git a/internal/router/filters.go b/internal/router/filters.go new file mode 100644 index 0000000..22a72ec --- /dev/null +++ b/internal/router/filters.go @@ -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) +} diff --git a/internal/router/router.go b/internal/router/router.go index c729a59..29f7f75 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/services/filters.go b/internal/services/filters.go new file mode 100644 index 0000000..17c0f17 --- /dev/null +++ b/internal/services/filters.go @@ -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 +}