docs: Add Phase 4.6 - CreateCollection manual books support

Add comprehensive documentation for Phase 4.6 which enables the
CreateCollection endpoint to support manual book selection alongside
auto-assign rules. This is required for the Custom Section Builder.

Key additions:
- Phase 4.6: Update CreateCollection Endpoint (30-45 min)
  - Add ManualBookIDs field to CreateCollectionRequest struct
  - Implement graceful handling of invalid book IDs
  - Add validation (max 50 book IDs) to prevent DoS
  - Reuse existing AddBookToCollection service method
  - Maintain backward compatibility (field is optional)

- Updated Phase 12.5: Collections Bruno tests
  - create-collection-with-manual-books.bru
  - create-collection-too-many-books.bru (validation test)
  - create-collection-invalid-book-id.bru
  - create-collection-rules-only.bru
  - create-collection-unauthorized.bru

- Added section 13.3: Collections API documentation
  - manual_book_ids field documentation
  - Validation limits (max 50 items)
  - Example combining auto-assign + manual books
  - Error handling explanation

Design decisions:
- Graceful degradation: Collection created even if some books fail
- Reuse existing infrastructure: No new service methods needed
- Backward compatible: Optional field doesn't break existing clients
- UI constraint: 50 book limit prevents abuse while allowing flexibility
This commit is contained in:
2026-02-19 20:48:32 -05:00
parent da33e2c126
commit f34914166d
+520 -92
View File
@@ -1069,6 +1069,138 @@ post:
---
### **Phase 4.6: Update CreateCollection Endpoint** (30-45 min)
**REQUIRED for Custom Section Builder**: The `CreateCollection` endpoint must support adding manual books when creating a collection.
**Why this is needed:**
- Custom Section Builder allows users to select books manually AND use filter rules
- Both features can be combined (rules + manual selection)
- Single API call is cleaner than separate create + add operations
**File: `internal/handlers/collections.go`** (MODIFY existing)
**Step 1: Add `ManualBookIDs` field to `CreateCollectionRequest`**
After line 40, add the new field:
```go
type CreateCollectionRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
ViewSettings map[string]interface{} `json:"view_settings"`
ManualBookIDs []string `json:"manual_book_ids" validate:"max=50"` // NEW
}
```
**Step 2: Update `CreateCollection` handler**
Modify the `CreateCollection` function (lines 73-112) to handle manual books:
```go
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req CreateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// NEW: Add manual books if provided
if len(req.ManualBookIDs) > 0 {
collectionUUID := uuid.UUID(collection.ID.Bytes)
addedCount := 0
for _, bookIDStr := range req.ManualBookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
// Skip invalid book IDs, log error
c.Logger().Errorf("Invalid book ID %s: %v", bookIDStr, err)
continue
}
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionUUID, bookID, userUUID)
if err != nil {
// Log error but continue adding other books
c.Logger().Errorf("Failed to add book %s to collection: %v", bookIDStr, err)
} else {
addedCount++
}
}
c.Logger().Infof("Added %d/%d manual books to collection %s", addedCount, len(req.ManualBookIDs), collection.Name)
}
bookCount := int32(0)
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": textToString(collection.Description),
"color": textToString(collection.Color),
"icon": textToString(collection.Icon),
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
})
}
```
**Key Implementation Details:**
- ✅ Reuses existing `AddBookToCollection` service method
- ✅ Validates request (max 50 book IDs)
- ✅ Returns 400 if more than 50 book IDs provided
- ✅ Gracefully handles invalid book IDs (skips them, logs error)
- ✅ Continues adding remaining books if one fails
- ✅ Backward compatible (field is optional)
- ✅ No database schema changes needed
**Validation Rule:**
```go
// Add to validator in main.go (around line 130)
v.RegisterValidation("max", func(fl validator.FieldLevel) bool {
field := fl.Field()
if field.Kind() != reflect.Slice {
return true
}
return field.Len() <= 50
})
```
**Testing:**
```bash
# Verify compilation
go build ./internal/handlers/...
# Manual test with Bruno
cd bruno/collections
bru run --env local create-collection-with-manual-books.bru
```
---
### **Phase 5: Bruno API Tests** (1 hour)
**File: `bruno/dashboard/**`** (update existing tests)
@@ -1183,6 +1315,7 @@ type Config struct {
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
DashboardService *services.DashboardService // NEW: For dashboard data fetching
DashboardHandler *handlers.DashboardHandler // NEW: For dashboard API endpoints
}
```
@@ -2750,111 +2883,135 @@ No additional work needed - this section references the preview endpoint added e
---
### **Phase 10.6: Final Integration & Testing** (1 hour)
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
Rules []Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
**CRITICAL**: Before proceeding to Phase 11 (Unit Tests), verify all components integrate correctly.
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
#### Verification Checklist
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
**Build Verification:**
- [ ] TypeScript modules compile: `npm run build:ts`
- Verify: `web/static/dashboard.js` exists
- Verify: `web/static/custom-section-builder.js` exists
- Check for no compilation errors
- [ ] Templates generate successfully: `templ generate --path templates`
- Verify: `templates/dashboard_templ.go` exists
- Verify: `templates/custom_section_templ.go` exists
- [ ] Go build succeeds: `go build ./cmd/server`
- Verify: No compilation errors
- Check all imports resolve correctly
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
**Bruno API Tests:**
- [ ] Dashboard endpoints pass: `cd bruno/dashboard && bru run --env local`
- get-sections-success.bru
- get-sections-missing-library-id.bru
- get-sections-unauthorized.bru
- put-preferences-success.bru
- restore-system-collection-success.bru
- restore-system-collection-invalid-name.bru
- [ ] Collections preview tests pass:
- preview-collection-success.bru
- preview-collection-manual-selection.bru
- preview-collection-combined.bru
- preview-collection-invalid-library.bru
// Get all library items
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
**Manual Integration Testing:**
- [ ] Dashboard loads successfully
- Navigate to `/dashboard?library_id=<valid_uuid>`
- Verify 4 system collections appear
- Verify collections show books correctly
- [ ] Library switching works
- Select different library from dropdown
- Verify page updates without full reload
- Verify loading spinner appears/disappears
- [ ] Dashboard settings modal functions
- Open settings modal
- Toggle collection visibility
- Drag to reorder collections
- Save preferences
- Verify changes persist on page reload
- [ ] System collection restore works
- Customize a system collection (hide it)
- Click "Restore" button
- Confirm restoration
- Verify collection reappears with defaults
- [ ] Custom section builder works end-to-end
- Navigate to `/custom-section`
- Add filter rules
- Search and select books manually
- Click "Refresh Preview"
- Verify preview shows matching books
- Save custom section
- Verify section appears on dashboard
// Evaluate rules for each item
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
**Type Safety Verification:**
- [ ] API responses match TypeScript types
- Check `is_system` is boolean (not string)
- Check `media_item_id` field exists (not `id`)
- Verify field names match (`hidden_collections`, `collection_order`)
- [ ] No TypeScript type errors
- Check browser console for type errors
- Verify all API calls use correct field names
// Add manually selected books
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
**Database Verification:**
- [ ] System collections exist
```sql
SELECT name, query_type, priority, is_system_collection
FROM collections
WHERE user_id IS NULL;
```
Should return 4 rows
- [ ] User preferences table exists
```sql
\d user_dashboard_preferences
```
Verify all columns present
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
// Check if already in matched items
alreadyAdded := false
for _, added := range matchedItems {
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
if addedUUID == bookUUID {
alreadyAdded = true
break
}
}
if !alreadyAdded {
matchedItems = append(matchedItems, item)
}
break
}
}
}
**Performance Smoke Test:**
- [ ] Dashboard loads within 2 seconds
- Test with library containing 100+ items
- Verify carousel scrolling is smooth
- Check no memory leaks in browser console
// Limit results
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
**Error Handling Verification:**
- [ ] Invalid library_id shows error
- [ ] Unauthorized requests return 401
- [ ] Network errors show toast notifications
- [ ] Empty collections display "No items" message
// Convert to BookInfo for response
var bookCards []BookInfo
for _, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards = append(bookCards, BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
})
}
#### Troubleshooting Common Issues
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
```
**Issue: Collections not appearing**
- Check `show_on_dashboard = true` in database
- Verify user hasn't hidden collection in preferences
- Check browser console for JavaScript errors
Add route to `internal/router/dashboard.go`:
**Issue: TypeScript compilation fails**
- Verify all type definitions in `web/src/types/api.d.ts`
- Check import statements use correct paths
- Ensure no missing dependencies in `package.json`
```go
// In registerDashboardRoutes function:
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
```
**Issue: Templates don't generate**
- Verify template syntax is correct
- Check for unclosed tags
- Run `go install github.com/a-h/templ/cmd/templ@latest` to update templ
**Key Points**:
- ✅ 13+ filter fields provide exceeding flexibility
- ✅ Live preview without saving
- ✅ Search + multi-select for manual book addition
- ✅ AND/OR logic support
- ✅ Procedural TypeScript (no OOP)
- ✅ TailwindCSS classes only
- ✅ Uses shared types (BookInfo from collections.go)
- ✅ Inline onclick handlers acceptable per PROJECT_GUIDELINES.md flexibility
**Issue: Bruno tests fail**
- Verify server is running
- Check environment variables in `bruno/.env`
- Ensure test database has seed data
#### Success Criteria
Phase 10.6 is complete when:
- ✅ All builds succeed (Go, TypeScript, Templates)
- ✅ All Bruno tests pass
- ✅ Manual testing confirms features work
- ✅ No console errors in browser
- ✅ Dashboard loads within 2 seconds
- ✅ Custom section builder creates sections successfully
- ✅ System collection restore works
**IMPORTANT**: Do not proceed to Phase 11 until all verification items pass. Integration issues discovered here are easier to fix before writing comprehensive unit tests.
---
@@ -3976,6 +4133,178 @@ bru run --env local
- ✅ `media_item_id` field present (not `id`)
- ✅ Error cases handled correctly
#### 12.5 Create Collections Endpoint Tests
**IMPORTANT**: Tests for CreateCollection with manual_book_ids support
**File: `bruno/collections/create-collection-with-manual-books.bru`** (NEW)
```yaml
name: Create Collection with Manual Books
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Sci-Fi Favorites"
description: "My favorite sci-fi books"
icon: "🚀"
color: "#9333ea"
auto_assign_rules:
- id: rule1
field: genre
operator: equals
value: Sci-Fi
priority: 1
manual_book_ids:
- {{bookId1}}
- {{bookId2}}
view_settings: {}
assertions:
- status: 201
- jsonpath: "$.id"
exists: true
- jsonpath: "$.name"
equals: "Sci-Fi Favorites"
```
**File: `bruno/collections/create-collection-too-many-books.bru`** (NEW)
```yaml
name: Create Collection - Too Many Manual Books (Validation Test)
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Test Collection"
manual_book_ids:
# Generate 51 book IDs to exceed max limit
- {{bookId1}}
- {{bookId2}}
- {{bookId3}}
- {{bookId4}}
- {{bookId5}}
# ... (total of 51 IDs)
assertions:
- status: 400
- jsonpath: "$.error"
exists: true
```
**File: `bruno/collections/create-collection-invalid-book-id.bru`** (NEW)
```yaml
name: Create Collection - Invalid Book IDs
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Test Collection"
manual_book_ids:
- invalid-uuid-format
- {{bookId1}}
- another-invalid-uuid
assertions:
- status: 201
- jsonpath: "$.id"
exists: true
# Collection should be created, valid books added, invalid IDs skipped
```
**File: `bruno/collections/create-collection-rules-only.bru`** (NEW)
```yaml
name: Create Collection - Auto-Assign Rules Only
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "High Rated Books"
description: "Books with rating > 4"
icon: "⭐"
color: "#FFD700"
auto_assign_rules:
- id: rule1
field: rating
operator: greater_than
value: "4"
priority: 1
# manual_book_ids not provided (optional field)
assertions:
- status: 201
- jsonpath: "$.auto_assign_rules"
exists: true
```
**Update Bruno test directory structure**:
```
bruno/
├── dashboard/
│ ├── get-sections-success.bru
│ ├── get-sections-missing-library-id.bru
│ ├── get-sections-invalid-library-id.bru
│ ├── get-sections-unauthorized.bru
│ ├── put-preferences-success.bru
│ ├── put-preferences-unauthorized.bru
│ ├── restore-system-collection-success.bru
│ ├── restore-system-collection-invalid-name.bru
│ ├── restore-system-collection-unauthorized.bru
│ ├── preview-collection-success.bru
│ ├── preview-collection-manual-selection.bru
│ ├── preview-collection-combined.bru
│ ├── preview-collection-invalid-library.bru
│ └── preview-collection-unauthorized.bru
└── collections/ # NEW DIRECTORY
├── create-collection-with-manual-books.bru
├── create-collection-too-many-books.bru
├── create-collection-invalid-book-id.bru
├── create-collection-rules-only.bru
├── create-collection-unauthorized.bru
└── get-collections.bru
```
**Run Bruno tests**:
```bash
# Test dashboard endpoints
cd bruno/dashboard
bru run --env local
# Test collections endpoints
cd bruno/collections
bru run --env local
```
#### 12.5 Create Collections Preview Tests
**File: `bruno/dashboard/preview-collection-success.bru`**
@@ -4319,7 +4648,106 @@ Key features:
- AND/OR logic support for combining rules
```
#### 13.3 User Documentation
#### 13.3 Collections API Documentation Update
**File: `docs/developer/api/collections/create_collection.md`** (UPDATE existing)
**Add `manual_book_ids` field to request body table:**
```markdown
## Request Body
| Field | Type | Required | Description |
|--------|------|-----------|-------------|
| name | string | Yes | Collection name (max 255 chars) |
| description | string | No | Collection description |
| color | string | No | Hex color code (e.g., "#FF5733") |
| icon | string | No | Emoji icon (e.g., "🚀", "📖") |
| auto_assign_rules | array | No | Array of rule objects |
| manual_book_ids | array | No | Array of book UUIDs to manually add (max 50) |
| view_settings | object | No | Per-device display preferences |
```
**Add validation section:**
```markdown
## Validation
- `manual_book_ids` array is limited to 50 items
- Returns `400 Bad Request` if more than 50 book IDs provided
- Invalid book UUIDs are skipped (don't prevent collection creation)
- Duplicate book IDs are automatically ignored (database constraint)
```
**Add example with manual books:**
```markdown
### Example Request (Auto-Assign Rules + Manual Books)
```json
{
"name": "Sci-Fi Favorites",
"description": "My favorite sci-fi books plus manual picks",
"icon": "🚀",
"color": "#9333ea",
"auto_assign_rules": [
{
"field": "genre",
"operator": "equals",
"value": "Sci-Fi",
"priority": 1
}
],
"manual_book_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001"
],
"view_settings": {
"kobo": {
"view_mode": "grid"
}
}
}
```
**Notes:**
- You can combine `auto_assign_rules` AND `manual_book_ids`
- Manual books are added regardless of whether they match the auto-assign rules
- Invalid book IDs are skipped with errors logged
- Maximum 50 manual books per collection (UI constraint)
```
**Add error response example:**
```markdown
### Error Responses
| Code | Description |
|------|-------------|
| 400 | Invalid request (validation failed, > 50 manual books) |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 500 | Internal server error |
**Example: Too Many Manual Books**
Request:
```json
{
"name": "Test",
"manual_book_ids": [ ... 51 book IDs ... ]
}
```
Response (400):
```json
{
"error": "Validation failed"
}
```
```
#### 13.4 User Documentation
**File: `docs/user/dashboard.md`** (UPDATE existing)