Files
bookhoard/internal/services/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

136 lines
4.5 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
}
// GetSavedFilterByID - Retrieve a single saved filter by ID
func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) (database.SavedFilters, error) {
filter, 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)
}
return filter, 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
}