Archaeology, prompted by the user asking when it broke: the builder's save NEVER worked. The page was born (2026-02-19,a92bf99a) posting auto_assign_rules as a JSON-encoded STRING into an endpoint that has bound []Rule since its creation (2026-01-31,6495cc2c), plus fields (manual_book_ids, match_type, library_id, show_on_dashboard) the endpoint never accepted. The preview button hit a real endpoint, so the page looked alive while Save always failed at request binding. Verified live before this change: the page's exact payload 400'd "invalid request" against the unmodified server. The fix adapts the builder's payload at write time — the legacy rules vocabulary converts to a filter tree (RulesToFilterTree restored from the retirement commit as the single write path, unit test restored with it): - match_type seeds the converted tree's root mode (any default). - manual_book_ids attach to the created collection after create (unparsable ids skipped — the AddBooks leniency). - show_on_dashboard persists (new create field). - Converted trees validate under ParseFilterTree: an unhonorable rule is a 400 at write naming the problem, never a stored 500 at render. Dashboard sections: smart user sections now evaluate their tree LIVE — GetUserCollectionItems merges stored membership with the composed engine's matches (deduped, library-scoped, capped at the section limit). This exposed a REAL production wiring bug: every production NewDashboardService call omitted the search service, so tree evaluation through the dashboard could never have worked (the test harness wired it correctly — the gap was production-only and latent, because nothing in the dashboard evaluated trees before this migration). The dashboard handler and both inline sites now receive the search service, and evaluation failures log instead of silently emptying a section. Supersedes the same-day loud-400 stance for the builder's payload (contract_pins migration entry); custom-section-builder.md documents the save contract; the create_collection API doc gains the adapted fields. New TestCustomSectionBuilderSave/BadRule pin the payload → tree → dashboard-render path end to end. Full container suite green (155s).
1356 lines
47 KiB
Go
1356 lines
47 KiB
Go
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"
|
|
// Custom-section builder payload (adapted 2026-10-31): the page
|
|
// posts the legacy rules vocabulary — rules as a JSON-encoded array
|
|
// (a string), a match_type select, hand-picked book ids, and the
|
|
// dashboard pin. The server converts rules → filter tree at write
|
|
// time and attaches the manual picks; storage stays tree-only.
|
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
|
MatchType string `json:"match_type"`
|
|
ManualBookIDs []string `json:"manual_book_ids"`
|
|
LibraryID string `json:"library_id"` // selection context only — not stored
|
|
ShowOnDashboard bool `json:"show_on_dashboard"`
|
|
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"`
|
|
}
|
|
|
|
// parseBuilderRules decodes the custom-section builder's rules payload:
|
|
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
|
|
// array (it has done so since the page was written, 2026-02-19) — while
|
|
// JSON clients may post the array directly. Null/empty = no rules.
|
|
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil, nil
|
|
}
|
|
var rules []services.Rule
|
|
if raw[0] == '"' {
|
|
var encoded string
|
|
if err := json.Unmarshal(raw, &encoded); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(encoded) == "" {
|
|
return nil, nil
|
|
}
|
|
return rules, json.Unmarshal([]byte(encoded), &rules)
|
|
}
|
|
return rules, json.Unmarshal(raw, &rules)
|
|
}
|
|
|
|
// 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()})
|
|
}
|
|
|
|
// Custom-section builder adapter: convert the posted rules to a
|
|
// filter tree at WRITE time (the save-fix — the page's payload had
|
|
// never once bound successfully before this).
|
|
builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
|
|
if rulesErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
|
|
}
|
|
matchMode := req.MatchType
|
|
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
|
|
matchMode = services.RuleMatchAny
|
|
}
|
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
|
if treeErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
|
}
|
|
if treeJSON == nil && len(builderRules) > 0 {
|
|
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
|
|
if cerr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
|
|
}
|
|
b, merr := json.Marshal(tree)
|
|
if merr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
|
|
}
|
|
// The strict-parse guarantee holds for converted trees too: an
|
|
// invalid tree is a 400 at write, never a stored 500 at render.
|
|
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
|
|
}
|
|
treeJSON = b
|
|
}
|
|
collection, err := h.collectionService.CreateCollection(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
req.Name,
|
|
req.Description,
|
|
req.Color,
|
|
req.Icon,
|
|
req.ViewSettings,
|
|
req.ShowOnDashboard,
|
|
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()})
|
|
}
|
|
|
|
// Manual picks: a section = rules + hand-picked books. Attach after
|
|
// create; unparsable ids are skipped (same leniency as AddBooks).
|
|
for _, bookIDStr := range req.ManualBookIDs {
|
|
bookID, perr := uuid.Parse(bookIDStr)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
|
|
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
|
|
}
|
|
|
|
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, h.collectionService.Search())
|
|
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 := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
|
|
return c.HTML(http.StatusOK, html)
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) AddBooks(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"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req AddBooksRequest
|
|
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()})
|
|
}
|
|
|
|
addedCount := 0
|
|
var addedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
|
|
if err == nil {
|
|
addedCount++
|
|
addedBookIDs = append(addedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if addedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_added",
|
|
"book_ids": addedBookIDs,
|
|
"count": addedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) RemoveBook(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"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
bookID, err := uuid.Parse(c.Param("bookId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
// Get user from context for WebSocket broadcast
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Broadcast to user's other devices via WebSocket
|
|
if h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "book_removed",
|
|
"book_id": bookID.String(),
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
type BulkRemoveBooksRequest struct {
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
}
|
|
|
|
func (h *CollectionHandler) BulkRemoveBooks(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"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req BulkRemoveBooksRequest
|
|
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()})
|
|
}
|
|
|
|
removedCount := 0
|
|
var removedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err == nil {
|
|
removedCount++
|
|
removedBookIDs = append(removedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if removedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_removed",
|
|
"book_ids": removedBookIDs,
|
|
"count": removedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"removed": removedCount,
|
|
"total": len(req.BookIDs),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
|
|
deviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := make([]DeviceMappingSummary, 0, len(mappings))
|
|
for _, m := range mappings {
|
|
response = append(response, DeviceMappingSummary{
|
|
ID: m.ID.Bytes,
|
|
CollectionID: m.CollectionID.Bytes,
|
|
CollectionName: m.CollectionName,
|
|
DeviceShelfName: utils.PgTextString(m.DeviceShelfName),
|
|
SyncDirection: utils.PgTextString(m.SyncDirection),
|
|
CreatedAt: m.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"device_id": deviceID.String(),
|
|
"mappings": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) CreateDeviceMapping(c *echo.Context) error {
|
|
deviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
var req CreateDeviceMappingRequest
|
|
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()})
|
|
}
|
|
|
|
collectionID, err := uuid.Parse(req.CollectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
mapping, err := h.collectionService.CreateDeviceShelfMapping(
|
|
c.Request().Context(),
|
|
collectionID,
|
|
deviceID,
|
|
req.DeviceShelfName,
|
|
req.SyncDirection,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
|
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(mapping.SyncDirection),
|
|
"created_at": mapping.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) UpdateDeviceMapping(c *echo.Context) error {
|
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
|
}
|
|
|
|
var req UpdateDeviceMappingRequest
|
|
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()})
|
|
}
|
|
|
|
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
|
|
c.Request().Context(),
|
|
mappingID,
|
|
req.DeviceShelfName,
|
|
req.SyncDirection,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
|
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(mapping.SyncDirection),
|
|
"created_at": mapping.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) DeleteDeviceMapping(c *echo.Context) error {
|
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
|
}
|
|
|
|
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
|
|
bookID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := make([]BookCollectionSummary, 0, len(collections))
|
|
for _, col := range collections {
|
|
response = append(response, BookCollectionSummary{
|
|
ID: col.ID.Bytes,
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"book_id": bookID.String(),
|
|
"collections": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionData(c *echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
|
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
|
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
|
// family lens). Smart-collection evaluation funnels every book candidate
|
|
// through it so hidden libraries contribute nothing.
|
|
// userVisibleLibraryIDs adapts the collection endpoints' identity
|
|
// source to the canonical resolver (H4.5).
|
|
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
_, ids, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
|
return ids, err
|
|
}
|
|
|
|
// UserVisibleLibraryIDs is the exported entry point for the router's
|
|
// frontend page handlers, which resolve the visible-library set before
|
|
// evaluating smart-collection rule matches.
|
|
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
return h.userVisibleLibraryIDs(c)
|
|
}
|
|
|
|
// CollectionService returns the underlying service so the router's
|
|
// frontend handlers can share the exact same evaluation the API uses.
|
|
func (h *CollectionHandler) CollectionService() *services.CollectionService {
|
|
return h.collectionService
|
|
}
|
|
|
|
// rejectSmartMutation returns a 400 for smart collections: membership is
|
|
// derived live from rules, so stored link mutations have nothing to act
|
|
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
|
|
// books is deliberately unsupported (collection_items.excluded awaits it).
|
|
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
|
}
|
|
if services.IsCollectionSmart(collection) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are tree-based — membership is managed by the collection's filter tree, not by hand")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// treeEnvelope renders the stored filter tree for the envelope: nil
|
|
// (static collections) serializes as null, trees as the object.
|
|
func treeEnvelope(t []byte) any {
|
|
if len(t) == 0 {
|
|
return nil
|
|
}
|
|
var v interface{}
|
|
if json.Unmarshal(t, &v) == nil {
|
|
return v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
Operations []struct {
|
|
CollectionID string `json:"collection_id" validate:"required"`
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
} `json:"operations" validate:"required"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if len(req.Operations) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
|
}
|
|
|
|
// Smart collections are rejected up front per operation so the
|
|
// caller sees a clean, actionable error instead of silent no-ops.
|
|
for _, op := range req.Operations {
|
|
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
|
|
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
successCount := 0
|
|
failedCount := 0
|
|
|
|
for _, op := range req.Operations {
|
|
collectionID, err := uuid.Parse(op.CollectionID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"status": "error",
|
|
"error": "Invalid collection UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
for _, bookIDStr := range op.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": "Invalid book UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
collectionUUID := pgtype.UUID{Bytes: collectionID, Valid: true}
|
|
bookUUID := pgtype.UUID{Bytes: bookID, Valid: true}
|
|
|
|
_, err = h.db.AddBookToCollection(c.Request().Context(), database.AddBookToCollectionParams{
|
|
CollectionID: collectionUUID,
|
|
MediaItemID: bookUUID,
|
|
AddedByUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"action": "books_added_bulk",
|
|
"count": successCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
"added": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|
user := c.Get("user")
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
// Book-scope preview (the rules page remains books-first until the
|
|
// filter-unification pass); no viewer progress loaded here, so
|
|
// played/unplayed rules preview at the no-progress default.
|
|
var matchedItems []database.MediaItems
|
|
for _, item := range allItems {
|
|
evaluations := h.collectionService.EvaluateRules(item, 0, 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 := BookCardsFromMediaItems(matchedItems, LibraryTypeMap(c.Request().Context(), h.db))
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
bookCards = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookCards)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
|
}
|
|
|
|
// ReorderItems applies a static collection's manual playlist order
|
|
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
|
// ids omitted keep their relative order appended after (the service never
|
|
// deletes membership).
|
|
func (h *CollectionHandler) ReorderItems(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"})
|
|
}
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
ItemIDs []string `json:"item_ids"`
|
|
}
|
|
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
|
}
|
|
|
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
|
if err != nil || !owns {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
|
}
|
|
|
|
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
|
for _, raw := range req.ItemIDs {
|
|
id, parseErr := uuid.Parse(raw)
|
|
if parseErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
|
}
|
|
ordered = append(ordered, id)
|
|
}
|
|
|
|
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
|
}
|
|
|
|
// optionalFlag interprets a multi-valued form flag: last value wins
|
|
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
|
|
func optionalFlag(values []string) *bool {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
v := strings.EqualFold(values[len(values)-1], "true")
|
|
return &v
|
|
}
|