package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type CollectionHandler struct { db *database.Queries collectionService *services.CollectionService libraryService *services.LibraryService connManager *wsync.ConnectionManager } func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler { return &CollectionHandler{ db: db, collectionService: services.NewCollectionService(db, search...), libraryService: libraryService, connManager: connManager, } } type CreateCollectionRequest struct { Name string `form:"name" json:"name" validate:"required"` Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" // Retired custom-section-builder payload (2026-10-31): the rules // ARRAY storage is gone — the filter tree is the only smart // membership. The field exists ONLY so the builder's legacy save // fails LOUDLY (400) instead of silently creating a hollow static // section. Remove it at the builder's tree migration. AutoAssignRules json.RawMessage `json:"auto_assign_rules"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` // The unified filter tree (item 3.2): when present the collection's // smart membership runs through the qf engine. INVALID trees are a // 400 (ParseFilterTree at the handler); a tree supersedes // auto_assign_rules. Persisted as-is (any). RawMessage for // round-trip safety. JSON bodies bind FilterTree; FORM bodies // (the create modal's urlencoded hx-post) bind FilterTreeForm — // echo's binder cannot set an any-typed field from a form string // (the create-modal seed POST 400'd "invalid request" on it, // 2026-10-31). FilterTree any `json:"filter_tree"` // FilterTreeForm mirrors filter_tree for application/x-www-form- // urlencoded submissions: the raw JSON as a string, validated by // the same parseRequestFilterTree path. FilterTreeForm string `form:"filter_tree"` } // treeFilterJSON returns the request's filter tree as JSON storage // bytes (nil when none): object payloads marshal; strings (a legacy // double-encoded client, the custom-section builder's known bug class) // are written verbatim after object-attempt. func treeFilterJSON(payload any) ([]byte, error) { switch v := payload.(type) { case nil: return nil, nil case map[string]interface{}: b, err := json.Marshal(v) if err != nil { return nil, err } return b, nil case string: return []byte(v), nil default: return nil, fmt.Errorf("filter_tree must be an object") } } // parseRequestFilterTree validates the request's tree against the // engine grammar (ParseFilterTree) so an invalid tree is a 400, never // a stored 500 at render time. func parseRequestFilterTree(payload any) ([]byte, error) { b, err := treeFilterJSON(payload) if err != nil || b == nil { return b, err } if _, err := services.ParseFilterTree(string(b)); err != nil { return nil, fmt.Errorf("invalid filter_tree: %w", err) } return b, nil } // resolveTreePayload — the tree payload from either transport: JSON // bodies carry the object in FilterTree, form bodies (the create // modal's urlencoded hx-post) the raw JSON string in FilterTreeForm. func resolveTreePayload(jsonTree any, formTree string) any { if formTree != "" { return formTree } return jsonTree } type UpdateCollectionRequest struct { Name string `form:"name" json:"name" validate:"required"` // The unified filter tree (item 3.2): when present the collection's // smart membership runs through the qf engine. INVALID trees are a // 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind // FilterTreeForm (echo's binder cannot set an any-typed field from // a form string — see CreateCollectionRequest). FilterTree any `json:"filter_tree"` FilterTreeForm string `form:"filter_tree"` Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` // A2c §3.3: playlist toggles — merged into view_settings server-side // (nil = untouched). []string because the form posts a hidden "false" // sibling next to the checkbox; the LAST value wins. JSON clients may // send a single-element array (or omit — untouched). HideFinished []string `form:"hide_finished" json:"hide_finished"` ShowUpNext []string `form:"show_up_next" json:"show_up_next"` } type AddBooksRequest struct { BookIDs []string `json:"book_ids" validate:"required"` } type CreateDeviceMappingRequest struct { CollectionID string `json:"collection_id" validate:"required"` DeviceShelfName string `json:"device_shelf_name" validate:"required"` SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` } type UpdateDeviceMappingRequest struct { DeviceShelfName string `json:"device_shelf_name" validate:"required"` SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` } // DeviceMappingSummary is one entry in GET /api/devices/:id/collections. Hoisted // to package scope so the docs schema registry can pin the response shape. type DeviceMappingSummary struct { ID uuid.UUID `json:"id"` CollectionID uuid.UUID `json:"collection_id"` CollectionName string `json:"collection_name"` DeviceShelfName string `json:"device_shelf_name"` SyncDirection string `json:"sync_direction"` CreatedAt string `json:"created_at"` } // CollectionSummary is one entry in GET /api/collections. Package-level so // the docs schema registry (internal/docs) can pin the response shape. type CollectionSummary struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Icon string `json:"icon"` CreatedAt string `json:"created_at"` BookCount int `json:"book_count"` IsSmart bool `json:"is_smart"` } // BookCollectionSummary is one entry in GET /api/collections/:id/books — // the collections a book belongs to (list view: no rules/count fields). type BookCollectionSummary struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Icon string `json:"icon"` } type SectionData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` IsSystem bool `json:"is_system"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` Items []BookInfo `json:"items"` ViewAllURL string `json:"view_all_url"` Priority int `json:"priority"` // A2c: curated playlist marker (imported reading-order collection) and // the hide_finished completion state — dashboard.ts renders both. IsPlaylist bool `json:"is_playlist"` Complete bool `json:"complete"` } 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()}) } // The custom-section builder still posts the RETIRED rules payload // (its save was already failing to bind — verified 2026-10-31). // Reject loudly rather than silently creating a hollow section. if len(req.AutoAssignRules) > 0 { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "auto_assign_rules was retired — collections carry filter trees now (the custom-section builder needs its flagged tree migration)", }) } treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) if treeErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) } collection, err := h.collectionService.CreateCollection( c.Request().Context(), userUUID, req.Name, req.Description, req.Color, req.Icon, req.ViewSettings, treeJSON, ) if err != nil { if errors.Is(err, services.ErrCollectionNameTaken) { return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } bookCount := int32(0) isSmart := req.CollectionType == "smart" || treeJSON != nil response := map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), "name": collection.Name, "description": utils.PgTextString(collection.Description), "color": utils.PgTextString(collection.Color), "icon": utils.PgTextString(collection.Icon), "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], "view_settings": collection.ViewSettings, "book_count": bookCount, "created_at": collection.CreatedAt.Time.String(), } // Add redirect header for HTMX requests if c.Request().Header.Get("HX-Request") == "true" { // A smart collection starts with an empty ruleset; the builder is // where it becomes real. Static collections head back to the grid. if isSmart { c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules") } else { c.Response().Header().Set("HX-Redirect", "/collections") } } return c.JSON(http.StatusCreated, response) } func (h *CollectionHandler) GetCollections(c *echo.Context) error { includeAuto := c.QueryParam("include_auto") == "true" sortBy := c.QueryParam("sort_by") libraryID := c.QueryParam("library_id") collections, err := h.GetCollectionsData(c) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } response := make([]CollectionSummary, 0, len(collections)) for _, col := range collections { isSmart := services.IsCollectionSmart(col) if !includeAuto && isSmart { // Legacy default for API clients (the Android app pre-dates // smart collections): rule-driven collections stay opt-in so // their membership could never be stale for old readers. continue } bookCount := 0 var libUUID pgtype.UUID if libraryID != "" { if libParsed, libErr := uuid.Parse(libraryID); libErr == nil { libUUID = pgtype.UUID{Bytes: libParsed, Valid: true} } } if isSmart { visibleIDs, visErr := h.userVisibleLibraryIDs(c) if visErr == nil { matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr == nil { bookCount = len(matches) } } } else { count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, LibraryID: libUUID, }) if countErr == nil { bookCount = int(count) } } response = append(response, CollectionSummary{ ID: col.ID.Bytes, Name: col.Name, Description: utils.PgTextString(col.Description), Color: utils.PgTextString(col.Color), Icon: utils.PgTextString(col.Icon), CreatedAt: col.CreatedAt.Time.String(), BookCount: bookCount, IsSmart: isSmart, }) } if sortBy == "name" { for i := 0; i < len(response); i++ { for j := i + 1; j < len(response); j++ { if response[i].Name > response[j].Name { response[i], response[j] = response[j], response[i] } } } } return c.JSON(http.StatusOK, map[string]interface{}{ "collections": response, "total": len(response), }) } func (h *CollectionHandler) GetCollection(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } collection, err := h.GetCollectionData(c, collectionID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } libraryID := c.QueryParam("library_id") var bookList []BookInfo var libUUID pgtype.UUID if libraryID != "" { parsed, parseErr := uuid.Parse(libraryID) if parseErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } libUUID = pgtype.UUID{Bytes: parsed, Valid: true} } isSmart := services.IsCollectionSmart(collection) libTypes := LibraryTypeMap(c.Request().Context(), h.db) if isSmart { visibleIDs, err := h.userVisibleLibraryIDs(c) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()}) } bookList = BookCardsFromMediaItems(matches, libTypes) } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) dashboardSvc := services.NewDashboardService(h.db) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) if secErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()}) } for _, section := range sections { if section.CollectionID == collectionID { bookList = BookCardsFromMediaItems(section.Items, libTypes) break } } } else if libUUID.Valid { // A2c §3.3: the detail page honors the same per-collection // hide_finished as the dashboard section, against the viewer's // progress. The viewer_percentage rides along for card graying. hideFinished := false if len(collection.ViewSettings) > 0 { var vs map[string]interface{} if json.Unmarshal(collection.ViewSettings, &vs) == nil { if b, ok := vs["hide_finished"].(bool); ok { hideFinished = b } } } var viewerID pgtype.UUID if u, ok := c.Get("user").(database.Users); ok { viewerID = u.ID } collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, LibraryID: libUUID, ViewerUserID: viewerID, HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished}, Limit: pgtype.Int4{Int32: 10000, Valid: true}, }) if collErr != nil { bookList = BookCardsFromCollectionDashRows(nil, libTypes) } else { // The query filters excluded rows in SQL; cards build straight // from the embedded media rows. bookList = BookCardsFromCollectionDashRows(collItems, libTypes) } } else { books, booksErr := h.GetCollectionBooksData(c, collectionID) if booksErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()}) } bookList = BookCardsFromCollectionItemRows(books, libTypes) } if u, ok := c.Get("user").(database.Users); ok { bookList = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookList) } var viewSettings map[string]interface{} if len(collection.ViewSettings) > 0 { err := json.Unmarshal(collection.ViewSettings, &viewSettings) if err != nil { return err } } // A2c §3.4: the collection's reading queue for the Up Next strip. // Library visibility enforced — a hidden library's books never surface // here even if they sit in a visible collection. upNext := []map[string]interface{}{} if !isSmart { hideFinished := false if b, ok := viewSettings["hide_finished"].(bool); ok { hideFinished = b } _ = hideFinished visibleIDs, visErr := h.userVisibleLibraryIDs(c) if visErr == nil { user := c.Get("user").(database.Users) rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{ CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, VisibleLibraryIds: visibleIDs, Limit: pgtype.Int4{Int32: 10, Valid: true}, }) if upErr == nil { for _, row := range rows { upNext = append(upNext, map[string]interface{}{ "id": uuid.UUID(row.MediaItems.ID.Bytes).String(), "title": row.MediaItems.Title, "author": utils.PgTextString(row.MediaItems.Author), "cover": utils.PgTextString(row.MediaItems.CoverImagePath), "format": row.MediaItems.FormatGroup, "progress": row.ViewerPercentage, }) } } } } return c.JSON(http.StatusOK, map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), "name": collection.Name, "description": utils.PgTextString(collection.Description), "color": utils.PgTextString(collection.Color), "icon": utils.PgTextString(collection.Icon), "view_settings": viewSettings, "filter_tree": treeEnvelope(collection.FilterTree), "books": bookList, "book_count": len(bookList), "is_smart": isSmart, "up_next": upNext, "created_at": collection.CreatedAt.Time.String(), }) } func (h *CollectionHandler) UpdateCollection(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } var req UpdateCollectionRequest 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()}) } // A2c §3.3: playlist toggles ride the edit form as top-level fields and // merge into view_settings server-side (the form can't bind a map). // The form posts a hidden "false" sibling before the checkbox, so the // LAST value is the checkbox state. if v := optionalFlag(req.HideFinished); v != nil { if req.ViewSettings == nil { req.ViewSettings = map[string]interface{}{} } req.ViewSettings["hide_finished"] = *v } if v := optionalFlag(req.ShowUpNext); v != nil { if req.ViewSettings == nil { req.ViewSettings = map[string]interface{}{} } req.ViewSettings["show_up_next"] = *v } treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) if treeErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) } collection, err := h.collectionService.UpdateCollection( c.Request().Context(), collectionID, req.Name, req.Description, req.Color, req.Icon, req.ViewSettings, treeJSON, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Collections RLS: another user's collection is INVISIBLE — // the update touched no rows. Answer 404, not a 500 that // reads like a bug (2026-10-31 collections RLS). return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } response := map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), "name": collection.Name, "description": utils.PgTextString(collection.Description), "color": utils.PgTextString(collection.Color), "icon": utils.PgTextString(collection.Icon), "view_settings": json.RawMessage(collection.ViewSettings), "created_at": collection.CreatedAt.Time.String(), } // Add redirect header for HTMX requests if c.Request().Header.Get("HX-Request") == "true" { c.Response().Header().Set("HX-Redirect", "/collections") } return c.JSON(http.StatusCreated, response) } // ValidChainOrders — the collection chain's listening-order vocabulary // (plan §10 R2: a collection's playback order is CURATED on the // collection, not a player-side knob). Mirrors OrderEpisodes' orders; // "" means native (the pre-chain-order default). var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true} // CollectionChainOrder resolves a collection row's curated chain order // from its view_settings JSONB — "" (native) when absent/unknown. The // episode chain producer routes OrderEpisodes through this; the // view-settings PUT writes the key (SetCollectionChainOrder). func CollectionChainOrder(viewSettings []byte) string { if len(viewSettings) == 0 { return "" } var vs map[string]interface{} if json.Unmarshal(viewSettings, &vs) != nil { return "" } if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] { return s } return "" } // SetCollectionChainOrder — PUT /api/collections/:id/view-settings with // {"chain_order": "..."}: merges the key into the collection's stored // view_settings (the playlist toggles share that JSONB — a blind write // would clobber them). The episode chain producer // (buildSmartCollectionSequenceContext) honors it. func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } var req struct { ChainOrder string `json:"chain_order"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if !ValidChainOrders[req.ChainOrder] { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "chain_order must be one of: native, newest, oldest, reverse"}) } collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } // Same owner gate as the sibling config mutation (ReorderItems) — // my endpoint writes the row, so it must not let a non-owner flip // someone else's chain order. if u, ok := c.Get("user").(database.Users); ok { owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes)) if err != nil || !owns { return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"}) } } else { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"}) } settings := map[string]interface{}{} if len(collection.ViewSettings) > 0 { if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"}) } } if req.ChainOrder == "" || req.ChainOrder == "native" { // Both spellings mean the default — drop the key entirely so the // stored doc stays clean. delete(settings, "chain_order") } else { settings["chain_order"] = req.ChainOrder } blob, err := json.Marshal(settings) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{ ID: pgtype.UUID{Bytes: collectionID, Valid: true}, ViewSettings: blob, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "chain_order": req.ChainOrder, "view_settings": settings, }) } func (h *CollectionHandler) DeleteCollection(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if c.Request().Header.Get("HX-Request") == "true" { // CSP: no inline scripts in partials — auth_forms.ts navigates. html := `