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).
473 lines
15 KiB
Go
473 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type DashboardService struct {
|
|
db *database.Queries
|
|
collectionService *CollectionService
|
|
}
|
|
|
|
func NewDashboardService(db *database.Queries, search ...*SearchService) *DashboardService {
|
|
return &DashboardService{
|
|
db: db,
|
|
collectionService: NewCollectionService(db, search...),
|
|
}
|
|
}
|
|
|
|
// viewSettingsFlag reads a boolean key from a collection's view_settings
|
|
// JSONB. Malformed or absent → false.
|
|
func viewSettingsFlag(raw []byte, key string) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return false
|
|
}
|
|
b, ok := settings[key].(bool)
|
|
return ok && b
|
|
}
|
|
|
|
// viewSettingsHasKey reports whether the key exists at all (any value) —
|
|
// used for provenance markers like imported_from_series.
|
|
func viewSettingsHasKey(raw []byte, key string) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return false
|
|
}
|
|
_, ok := settings[key]
|
|
return ok
|
|
}
|
|
|
|
type DashboardSection struct {
|
|
CollectionID uuid.UUID
|
|
CollectionName string
|
|
Items []database.MediaItems
|
|
QueryType string
|
|
Priority int
|
|
IsSystem bool
|
|
Title string
|
|
Description string
|
|
Icon string
|
|
// A2c: the collection's own ordering-construct marker — the section is
|
|
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
|
|
// badge by the frontend.
|
|
IsPlaylist bool
|
|
// A2c §3.5: hide_finished filtered every item out of a NON-empty
|
|
// collection — render "playlist complete" instead of dropping silently.
|
|
Complete bool
|
|
}
|
|
|
|
func (s *DashboardService) GetDashboardSections(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
libraryID pgtype.UUID,
|
|
limit int,
|
|
collectionOrder []string,
|
|
hiddenCollections []string,
|
|
) ([]DashboardSection, error) {
|
|
var results []DashboardSection
|
|
|
|
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, coll := range systemCollections {
|
|
items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
results = append(results, DashboardSection{
|
|
CollectionID: coll.ID.Bytes,
|
|
CollectionName: coll.Name,
|
|
Items: items,
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: coll.IsSystemCollection.Bool,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
})
|
|
}
|
|
|
|
userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, coll := range userCollections {
|
|
items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// A2c §3.5: hide_finished filtered everything out — but the
|
|
// collection is NOT empty. That's a finished playlist: render a
|
|
// completion marker instead of silently vanishing.
|
|
if len(items) == 0 {
|
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
|
if hideFinished {
|
|
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
|
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
|
|
results = append(results, DashboardSection{
|
|
CollectionID: coll.ID.Bytes,
|
|
CollectionName: coll.Name,
|
|
Items: []database.MediaItems{},
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: false,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
|
Complete: true,
|
|
})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
results = append(results, DashboardSection{
|
|
CollectionID: coll.ID.Bytes,
|
|
CollectionName: coll.Name,
|
|
Items: items,
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: false,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
|
})
|
|
}
|
|
|
|
results = s.FilterHiddenCollections(results, hiddenCollections)
|
|
results = s.reorderCollections(results, collectionOrder)
|
|
|
|
if len(collectionOrder) == 0 {
|
|
results = s.sortByPriority(results)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *DashboardService) FilterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection {
|
|
if len(hidden) == 0 {
|
|
return sections
|
|
}
|
|
|
|
var filtered []DashboardSection
|
|
for _, section := range sections {
|
|
isHidden := false
|
|
for _, h := range hidden {
|
|
if section.CollectionName == h {
|
|
isHidden = true
|
|
break
|
|
}
|
|
}
|
|
if !isHidden {
|
|
filtered = append(filtered, section)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection {
|
|
if len(order) == 0 {
|
|
return sections
|
|
}
|
|
|
|
var ordered []DashboardSection
|
|
remaining := make(map[string]DashboardSection)
|
|
for _, section := range sections {
|
|
remaining[section.CollectionName] = section
|
|
}
|
|
|
|
for _, name := range order {
|
|
if section, exists := remaining[name]; exists {
|
|
ordered = append(ordered, section)
|
|
delete(remaining, name)
|
|
}
|
|
}
|
|
|
|
for _, section := range sections {
|
|
if _, exists := remaining[section.CollectionName]; exists {
|
|
ordered = append(ordered, section)
|
|
}
|
|
}
|
|
|
|
return ordered
|
|
}
|
|
|
|
func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection {
|
|
sorted := make([]DashboardSection, len(sections))
|
|
copy(sorted, sections)
|
|
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
for j := 0; j < len(sorted)-i-1; j++ {
|
|
if sorted[j].Priority > sorted[j+1].Priority {
|
|
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
return sorted
|
|
}
|
|
|
|
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
|
switch coll.QueryType.String {
|
|
case "continue-reading":
|
|
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: libraryID,
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
case "recently-added":
|
|
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
|
LibraryID: libraryID,
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
case "recently-read":
|
|
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: libraryID,
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
case "not-started":
|
|
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: libraryID,
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
case "continue-series":
|
|
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
|
|
LibraryID: libraryID,
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]database.MediaItems, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, continueSeriesRowToMediaItems(row))
|
|
}
|
|
return items, nil
|
|
default:
|
|
return []database.MediaItems{}, nil
|
|
}
|
|
}
|
|
|
|
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
|
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
|
|
|
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
|
|
// against the VIEWER's reading progress. The section then shows the
|
|
// head of the reading queue in playlist order.
|
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
|
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
|
LibraryID: libraryID,
|
|
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The query filters excluded rows in SQL and embeds the media row,
|
|
// so the items are MediaItems as-is.
|
|
items := make([]database.MediaItems, 0, len(manualItems))
|
|
seen := make(map[uuid.UUID]bool, len(manualItems))
|
|
for _, row := range manualItems {
|
|
id := uuid.UUID(row.MediaItems.ID.Bytes)
|
|
if !seen[id] {
|
|
items = append(items, row.MediaItems)
|
|
seen[id] = true
|
|
}
|
|
}
|
|
|
|
// Smart sections (custom sections are rules + manual picks): the
|
|
// tree's LIVE membership rides along, scoped to the dashboard's
|
|
// active library and the viewing user (played rules are per-user).
|
|
// Evaluated items dedupe against the stored ones and the section
|
|
// caps at its limit. (Custom-section builder tree migration,
|
|
// 2026-10-31 — the rules-array branch this replaces evaluated the
|
|
// same way, minus the library scoping.)
|
|
if s.collectionService.IsCollectionSmartColl(coll) {
|
|
matches, evalErr := s.collectionService.EvaluateSmartMembership(ctx, coll, SmartEvalOptions{
|
|
LibraryID: libraryID,
|
|
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
if evalErr != nil {
|
|
log.Printf("dashboard: smart section %s evaluation failed: %v", coll.Name, evalErr)
|
|
}
|
|
for _, m := range matches {
|
|
id := uuid.UUID(m.ID.Bytes)
|
|
if !seen[id] {
|
|
items = append(items, m)
|
|
seen[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(items) > limit {
|
|
items = items[:limit]
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
|
// Absent library = the global (All Libraries) scope: a separate
|
|
// one-row-per-user table, so an accidental per-library write can never
|
|
// masquerade as global.
|
|
if !libraryID.Valid {
|
|
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return database.UserDashboardPreferences{}, err
|
|
}
|
|
return database.UserDashboardPreferences{
|
|
UserID: g.UserID,
|
|
HiddenCollections: g.HiddenCollections,
|
|
CollectionOrder: g.CollectionOrder,
|
|
ItemsPerSection: g.ItemsPerSection,
|
|
UpdatedAt: g.UpdatedAt,
|
|
}, nil
|
|
}
|
|
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: libraryID,
|
|
})
|
|
}
|
|
|
|
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
|
if !params.LibraryID.Valid {
|
|
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
|
|
UserID: params.UserID,
|
|
HiddenCollections: params.HiddenCollections,
|
|
CollectionOrder: params.CollectionOrder,
|
|
ItemsPerSection: params.ItemsPerSection,
|
|
})
|
|
if err != nil {
|
|
return database.UserDashboardPreferences{}, err
|
|
}
|
|
return database.UserDashboardPreferences{
|
|
UserID: g.UserID,
|
|
HiddenCollections: g.HiddenCollections,
|
|
CollectionOrder: g.CollectionOrder,
|
|
ItemsPerSection: g.ItemsPerSection,
|
|
UpdatedAt: g.UpdatedAt,
|
|
}, nil
|
|
}
|
|
return s.db.UpsertDashboardPreferences(ctx, params)
|
|
}
|
|
|
|
func (s *DashboardService) SanitizeDashboardPreferences(hiddenCollections, collectionOrder []string) ([]string, []string) {
|
|
// Deduplicate collection_order while preserving order
|
|
seen := make(map[string]bool)
|
|
var sanitizedOrder []string
|
|
for _, id := range collectionOrder {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
sanitizedOrder = append(sanitizedOrder, id)
|
|
}
|
|
}
|
|
|
|
// Deduplicate hidden_collections
|
|
seen = make(map[string]bool)
|
|
var sanitizedHidden []string
|
|
for _, id := range hiddenCollections {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
sanitizedHidden = append(sanitizedHidden, id)
|
|
}
|
|
}
|
|
|
|
return sanitizedHidden, sanitizedOrder
|
|
}
|
|
|
|
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error {
|
|
defaultMetadata := map[string]struct {
|
|
Description string
|
|
Icon string
|
|
Color string
|
|
Priority int32
|
|
QueryType string
|
|
}{
|
|
"Continue Reading": {"Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", 1, "continue-reading"},
|
|
"Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"},
|
|
"Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"},
|
|
"Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"},
|
|
"Continue Series": {"Next book in series you're reading", "📚", "#bb9af7", 5, "continue-series"},
|
|
}
|
|
|
|
meta, exists := defaultMetadata[collectionName]
|
|
if !exists {
|
|
return fmt.Errorf("unknown collection: %s", collectionName)
|
|
}
|
|
|
|
// System collections are PERMANENT rows identified by the stable
|
|
// (user_id, query_type) pair — restore never deletes or re-creates,
|
|
// so the row id (and every reference to it: device shelf mappings,
|
|
// dashboard order/hidden prefs, /collections/{id} links) never churns.
|
|
// Resolution includes tombstoned rows: restoring a deleted section
|
|
// un-deletes the exact same row.
|
|
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
|
})
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Provision: the only path that mints a system-collection row.
|
|
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: collectionName,
|
|
Description: pgtype.Text{String: meta.Description, Valid: true},
|
|
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
|
Color: pgtype.Text{String: meta.Color, Valid: true},
|
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
|
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
|
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
|
})
|
|
return cerr
|
|
}
|
|
|
|
// "full": clear item assignments (a fresh section has none) and reset
|
|
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
|
|
// un-tombstone, so a deleted section comes back as the same row.
|
|
if resetType != "keep_books" {
|
|
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
|
ID: row.ID,
|
|
Name: collectionName,
|
|
Description: pgtype.Text{String: meta.Description, Valid: true},
|
|
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
|
Color: pgtype.Text{String: meta.Color, Valid: true},
|
|
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
|
})
|
|
return err
|
|
}
|