From 29e9f66c71bcb336f66a83c90b4e5cf862aeb341 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 19 Feb 2026 21:15:03 -0500 Subject: [PATCH] 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. --- bruno/collections/preview-collection.bru | 60 +++++++++++ internal/handlers/collections.go | 132 +++++++++++++++++++++++ internal/router/collections.go | 1 + 3 files changed, 193 insertions(+) create mode 100644 bruno/collections/preview-collection.bru diff --git a/bruno/collections/preview-collection.bru b/bruno/collections/preview-collection.bru new file mode 100644 index 0000000..b786741 --- /dev/null +++ b/bruno/collections/preview-collection.bru @@ -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. diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 7dd5e6c..3edd69c 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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: "", + } +} diff --git a/internal/router/collections.go b/internal/router/collections.go index 5dc6206..03490e2 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -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")