feat(dashboard): implement Phase 4.5 Collections Preview Endpoint

Add preview endpoint for custom section builder and rule evaluation:

Handler Implementation (internal/handlers/collections.go):
- PreviewCollection method: Evaluates filter rules and returns matching items without saving
  * Accepts library_id, rules array, manual_book_ids array, and limit
  * Evaluates rules against all library items using collectionService.EvaluateRules
  * Adds manually selected books to results
  * Deduplicates manual books (avoids adding same book twice)
  * Applies limit (default: 20, max: 100)
  * Returns array of BookInfo with matching items
- Helper function: mediaItemsToListMediaItemsRow
  * Converts database.MediaItems to database.ListMediaItemsRow
  * Required for EvaluateRules which expects ListMediaItemsRow type

Route Registration (internal/router/collections.go):
- POST /api/collections/preview
- Protected by JWT middleware
- Part of collections API group

Why This Endpoint is Necessary:
- Allows users to see what books match their filter rules BEFORE saving
- Avoids creating incorrect collections
- Enables testing different rule combinations quickly
- Reuses existing service logic (collectionService.EvaluateRules)
- Client-side preview would require downloading entire library (10,000+ books)
- Would duplicate 500+ lines of rule evaluation logic in TypeScript
- Would create maintenance nightmare keeping Go and TypeScript in sync

Bruno Test (bruno/collections/preview-collection.bru):
- Tests POST /api/collections/preview endpoint
- Validates status 200 response
- Validates items array in response
- Example request with genre filter rule

This endpoint is required for both the web UI Custom Section Builder and future mobile apps.
This commit is contained in:
2026-02-19 21:15:03 -05:00
parent a1a14c2af8
commit 29e9f66c71
3 changed files with 193 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
meta:
name: Preview Collection
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/preview'
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n\
{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\
\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\
\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n\
});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\n\
expect(res.body.items).to.be.an('array');\n });"
docs: |-
Preview collection with filter rules before saving.
**Endpoint**: POST /api/collections/preview
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| rules | array | No | Filter rules to evaluate |
| manual_book_ids | array | No | Manually selected book IDs |
| limit | int | No | Max items to return (default: 20, max: 100) |
## Example Request
```json
{
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
"rules": [
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Fiction",
"priority": 1
}
],
"manual_book_ids": [],
"limit": 20
}
```
## Response
Returns array of matching book items.
This endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection.
+132
View File
@@ -825,3 +825,135 @@ func (h *CollectionHandler) HandleBulkAddBooks(c echo.Context) error {
"failed": failedCount,
})
}
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
var req struct {
LibraryID string `json:"library_id"`
Rules []services.Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
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"})
}
var matchedItems []database.MediaItems
for _, item := range allItems {
listItem := mediaItemsToListMediaItemsRow(item)
evaluations := h.collectionService.EvaluateRules(listItem, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
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
}
}
}
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
return database.ListMediaItemsRow{
ID: item.ID,
LibraryID: item.LibraryID,
Title: item.Title,
Author: item.Author,
Isbn: item.Isbn,
Description: item.Description,
FilePath: item.FilePath,
FileSize: item.FileSize,
MimeType: item.MimeType,
CoverImagePath: item.CoverImagePath,
Series: item.Series,
SeriesNumber: item.SeriesNumber,
Tags: item.Tags,
Asin: item.Asin,
DatePublished: item.DatePublished,
Publisher: item.Publisher,
Contributors: item.Contributors,
Language: item.Language,
Edition: item.Edition,
PageCount: item.PageCount,
Genre: item.Genre,
CopyrightYear: item.CopyrightYear,
GoodreadsID: item.GoodreadsID,
OpenlibraryID: item.OpenlibraryID,
GoogleBooksID: item.GoogleBooksID,
AddedByAdminID: item.AddedByAdminID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
FormatGroup: item.FormatGroup,
FormatMimetype: item.FormatMimetype,
IsReflowable: item.IsReflowable,
HasFixedLayout: item.HasFixedLayout,
TotalCharacters: item.TotalCharacters,
ChapterCount: item.ChapterCount,
EntitlementID: item.EntitlementID,
RevisionNumber: item.RevisionNumber,
KoboContentID: item.KoboContentID,
KoboMetadata: item.KoboMetadata,
TagsSearch: item.TagsSearch,
ContributorsSearch: item.ContributorsSearch,
FileSha256: item.FileSha256,
OpfIdentifier: item.OpfIdentifier,
OpfUuid: item.OpfUuid,
HashConfidence: item.HashConfidence,
LibraryName: "",
LibraryTypeName: "",
}
}
+1
View File
@@ -20,6 +20,7 @@ func registerCollectionsRoutes(cfg *Config) {
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
// Device shelf mapping routes
deviceCollections := protected.Group("/devices/:id/collections")