Files
bookhoard/internal/handlers/dashboard.go
T
John O'Keefe 7c7e282e79 feat(dashboard): custom-section builder saves again — the flagged tree migration
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).
2026-10-31 19:40:43 -04:00

326 lines
10 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"log"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db, search...),
}
}
func (h *DashboardHandler) GetSections(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20
if prefs.ItemsPerSection.Valid {
limit = int(prefs.ItemsPerSection.Int32)
}
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
sections, err := h.dashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
}
// Per-type wording: system-collection display swaps on audiobook scope.
libraryType := ""
if libraryID != "" {
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
libraryType = lib.TypeName
}
}
}
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
sectionData := BuildSections(sections, libraryID, libraryType, libTypes)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}
func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
// nil (JSON null/absent) = the global All Libraries scope;
// empty string is a LOST value, not a scope — rejected loudly.
LibraryID *string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
var libUUID pgtype.UUID
if req.LibraryID != nil {
if *req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
parsed, err := uuid.Parse(*req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
// Sanitize preferences (remove duplicates)
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
req.HiddenCollections = cleanHidden
req.CollectionOrder = cleanOrder
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: libUUID,
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
return c.JSON(http.StatusOK, prefs)
}
func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
CollectionName string `form:"collection_name" json:"collection_name"`
ResetType string `form:"reset_type" json:"reset_type"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if req.CollectionName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
}
if req.ResetType == "" {
req.ResetType = "full"
}
validCollections := map[string]bool{
"Continue Reading": true,
"Recently Added": true,
"Recently Read": true,
"Not Started": true,
"Continue Series": true,
}
if !validCollections[req.CollectionName] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
}
if req.ResetType != "full" && req.ResetType != "keep_books" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
}
// 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.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}
// BuildSections maps dashboard service sections to card data. When the
// active library is audiobooks, system-collection titles/descriptions
// swap to the listening wording at display time — the stored names stay
// generic so restores and settings keep working across library types.
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string, libTypes map[string]string) []SectionData {
var result []SectionData
audioScope := libraryType == "audiobooks"
for _, ds := range sections {
if audioScope && ds.IsSystem {
switch ds.Title {
case "Continue Reading":
ds.Title = "Continue Listening"
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
case "Recently Read":
ds.Title = "Recently Listened"
ds.Description = "Audiobooks you've finished (progress >= 1)"
}
}
bookCards := BookCardsFromMediaItems(ds.Items, libTypes)
result = append(result, SectionData{
ID: ds.CollectionName,
CollectionID: ds.CollectionID.String(),
IsSystem: ds.IsSystem,
Title: ds.Title,
Description: ds.Description,
Icon: ds.Icon,
Items: bookCards,
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
Priority: ds.Priority,
IsPlaylist: ds.IsPlaylist,
Complete: ds.Complete,
})
}
return result
}
// activeConflictSet returns the set of media item IDs (as strings) that have an
// active (unresolved) progress sync conflict for the given user. A single query
// is issued; resolved conflicts are filtered out in memory.
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
if err != nil {
return nil
}
set := make(map[string]bool, len(conflicts))
for _, c := range conflicts {
if c.ResolutionStatus.String == "unresolved" {
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
}
}
return set
}
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
// active progress sync conflict for the user. It performs a single query
// regardless of how many books are passed.
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
if len(books) == 0 {
return books
}
set := activeConflictSet(ctx, db, userID)
for i := range books {
if set[books[i].MediaItemID] {
books[i].HasConflict = true
}
}
return books
}
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
// used by the dashboard which renders books grouped into sections.
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
if len(sections) == 0 {
return sections
}
set := activeConflictSet(ctx, db, userID)
if len(set) == 0 {
return sections
}
for s := range sections {
for i := range sections[s].Items {
if set[sections[s].Items[i].MediaItemID] {
sections[s].Items[i].HasConflict = true
}
}
}
return sections
}
func getViewAllURL(collectionID string, libraryID string) string {
if collectionID != "" {
if libraryID != "" {
return "/collections/" + collectionID + "?library_id=" + libraryID
}
return "/collections/" + collectionID
}
return ""
}
func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
// Return default preferences instead of 404 when none exist
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
}
// Normalize nil slices to empty arrays for JSON
hiddenCollections := prefs.HiddenCollections
if hiddenCollections == nil {
hiddenCollections = []string{}
}
collectionOrder := prefs.CollectionOrder
if collectionOrder == nil {
collectionOrder = []string{}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"hidden_collections": hiddenCollections,
"collection_order": collectionOrder,
"items_per_section": prefs.ItemsPerSection.Int32,
})
}