package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "encoding/json" "errors" "net/http" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "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) } // GetSavedFilterByID - GET /api/saved-filters/:id // Retrieve a single saved filter by ID func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) // Parse filter ID from URL parameter filterID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "invalid filter ID", }) } // Use service layer (includes business logic + ownership verification) filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID) if err != nil { if errors.Is(err, services.ErrFilterNotFound) { return c.JSON(http.StatusNotFound, map[string]string{ "error": "filter not found", }) } return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to fetch filter", }) } // Return JSON response (JSONB handled automatically) response := SavedFilterResponse{ ID: filterID, Name: filter.Name, ResourceType: filter.ResourceType, Filters: json.RawMessage(filter.Filters), // Return JSONB as-is CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339), UpdatedAt: filter.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 { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "filter not found"}) } 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) }