Files
bookhoard/internal/services/filters.go
T
john-okeefe ddfc832b68 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
2026-03-21 00:16:10 -04:00

123 lines
4.0 KiB
Go

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
}