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
188 lines
6.5 KiB
Go
188 lines
6.5 KiB
Go
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)
|
|
}
|