Files
bookhoard/internal/handlers/filters.go
T
john-okeefe 0960e36f30 feat: add GET /api/saved-filters/:id endpoint with comprehensive tests
Implement missing GET endpoint for retrieving individual saved filters by ID.
This completes the CRUD API for saved filters and enables mobile/SPA clients
to fetch filter details on-demand.

Backend Implementation:
- Add GetSavedFilterByID() handler method (internal/handlers/filters.go)
  - Parse filter ID from URL parameter
  - Validate UUID format, return 400 for invalid IDs
  - Call service layer for business logic + ownership verification
  - Return 404 if filter not found or doesn't belong to user
  - Return 200 with filter object including filters JSONB

- Add GetSavedFilterByID() service method (internal/services/filters.go)
  - Call existing database query GetSavedFilterByID
  - Verify filter exists and belongs to user
  - Return descriptive error: "filter not found or access denied"
  - Reuses existing database query (no new SQL needed)

- Register GET /:id route (internal/router/filters.go)
  - Add route before existing GET "" route
  - Follows RESTful routing conventions

Integration Tests (cmd/server/tests/filters_test.go):
- Test success case: Create filter, retrieve by ID, verify data
- Test error case: Invalid UUID format returns 400
- Test error case: Non-existent filter returns 404
- Test error case: No authentication returns 401
- Test security case: Cross-user access returns 404 (not 403)
  - Admin creates filter, regular user tries to access
  - Uses setup.Token (admin) and setup.RegularToken
  - Verifies information leakage prevention

API Design:
- Endpoint: GET /api/saved-filters/:id
- Authentication: JWT token required
- Response format: SavedFilterResponse with filters as JSON
- Error responses: 400 (invalid ID), 401 (no auth), 404 (not found)
- Security: Returns 404 for cross-user access (hides existence)

Benefits:
- Completes CRUD API for saved filters
- Enables future mobile/SPA clients
- Follows existing handler/service/test patterns
- Comprehensive security testing
- No database changes required (reuses existing queries)

Follows PROJECT_GUIDELINES.md service layer architecture and testing patterns.
2026-03-21 22:37:19 -04:00

233 lines
7.9 KiB
Go

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 err.Error() == "filter not found or access denied" {
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)
}