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).
This commit is contained in:
1 parent
a86bdac87e
commit
7c7e282e79
11 files changed
+445
-36
No files matched your search
@@ -63,7 +63,7 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle
|
||||
// CreateCollection creates a new collection. filterTree (item 3.2), when
|
||||
// present, makes it smart — the qf engine is the only membership source
|
||||
// (the legacy rules-array storage retired 2026-10-31).
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
|
||||
// Convert view settings to JSONB ([]byte)
|
||||
var settingsJSON []byte
|
||||
if len(viewSettings) > 0 {
|
||||
@@ -75,13 +75,14 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
}
|
||||
|
||||
collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -270,6 +271,18 @@ func IsCollectionSmart(coll database.Collections) bool {
|
||||
return len(coll.FilterTree) > 0
|
||||
}
|
||||
|
||||
// Search exposes the composed search engine the service was built with
|
||||
// (nil when the constructor omitted it) — callers composing secondary
|
||||
// services that evaluate trees (the dashboard service) pass it through.
|
||||
func (s *CollectionService) Search() *SearchService { return s.search }
|
||||
|
||||
// IsCollectionSmartColl is IsCollectionSmart on the service handle —
|
||||
// the dashboard service composes this service and calls the detector
|
||||
// through it (no import cycle, one detection definition).
|
||||
func (s *CollectionService) IsCollectionSmartColl(coll database.Collections) bool {
|
||||
return IsCollectionSmart(coll)
|
||||
}
|
||||
|
||||
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
||||
// tree-bearing collections evaluate through the unified filter engine.
|
||||
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
@@ -285,7 +298,9 @@ func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll dat
|
||||
return nil, errors.New("tree collections require the search service (constructor wiring)")
|
||||
}
|
||||
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
||||
} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// episodeFinished is THE canonical finished definition (same predicate
|
||||
}
|
||||
|
||||
// episodeFinished is THE canonical finished definition (same predicate
|
||||
// as hide_finished): viewer progress percentage >= 0.999.
|
||||
func episodeFinished(viewerPercentage float64) bool {
|
||||
return viewerPercentage >= 0.999
|
||||
@@ -487,6 +502,121 @@ func compareNumbers(a, b, op string) bool {
|
||||
}
|
||||
|
||||
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
||||
|
||||
// ── Custom-section-builder adapter (2026-10-31) ──────────────────
|
||||
// The builder posts the legacy rules vocabulary; these convert it to a
|
||||
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
||||
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
||||
// the builder's tree migration revived them as the single write path.)
|
||||
|
||||
// The builder's match_type select: "any" (OR) | "all" (AND).
|
||||
const (
|
||||
RuleMatchAny = "any"
|
||||
RuleMatchAll = "all"
|
||||
)
|
||||
|
||||
// The builder posts the legacy rules vocabulary; these convert it to a
|
||||
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
||||
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
||||
// the builder's tree migration revived them as the single write path.)
|
||||
|
||||
// RulesToFilterTree converts a legacy rules array + its match mode
|
||||
// into a qf tree so a collection upgrades to the unified engine at its
|
||||
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
||||
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
||||
// played/downloaded→booleans, published_within→published_date
|
||||
// within_days); legacy operators without an engine equivalent
|
||||
// (starts_with/ends_with) fall back to contains — documented, and the
|
||||
// operator labels stay in the rules docs.
|
||||
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
||||
children := make([]FilterChild, 0, len(rules))
|
||||
scope := ""
|
||||
for _, r := range rules {
|
||||
child, err := ruleToFilterChild(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if episodeOnlyFields[r.Field] {
|
||||
scope = ScopeEpisodes
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, fmt.Errorf("no rules to convert")
|
||||
}
|
||||
mode := RuleMatchAny
|
||||
if matchMode == RuleMatchAll {
|
||||
mode = RuleMatchAll
|
||||
}
|
||||
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
||||
}
|
||||
|
||||
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
||||
child := FilterChild{Field: r.Field}
|
||||
switch r.Field {
|
||||
case "feed":
|
||||
// The rules format stores comma-separated feed IDs in one value.
|
||||
child.Op = OpIn
|
||||
child.Values = splitRuleValues(r.Value)
|
||||
return child, nil
|
||||
case "downloaded":
|
||||
child.Op = isTrueFalse(r.Value == "true")
|
||||
return child, nil
|
||||
case "played":
|
||||
child.Op = isTrueFalse(r.Value == "true")
|
||||
return child, nil
|
||||
case "published_within":
|
||||
child.Field = "published_date"
|
||||
child.Op = OpWithinDays
|
||||
child.Values = []string{r.Value}
|
||||
return child, nil
|
||||
}
|
||||
// Registry fields: legacy operators → engine operators.
|
||||
switch r.Operator {
|
||||
case "equals":
|
||||
child.Op = OpIs
|
||||
case "not_equals":
|
||||
child.Op = OpIsNot
|
||||
case "contains":
|
||||
child.Op = OpContains
|
||||
case "not_contains":
|
||||
child.Op = OpNotContains
|
||||
case "greater_than":
|
||||
child.Op = OpAtLeast
|
||||
case "less_than":
|
||||
child.Op = OpAtMost
|
||||
case "in":
|
||||
child.Op = OpIn
|
||||
child.Values = splitRuleValues(r.Value)
|
||||
case "starts_with", "ends_with":
|
||||
// No engine equivalent — contains is the honest nearest.
|
||||
child.Op = OpContains
|
||||
default:
|
||||
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
||||
}
|
||||
child.Values = []string{r.Value}
|
||||
return child, nil
|
||||
}
|
||||
|
||||
func isTrueFalse(b bool) string {
|
||||
if b {
|
||||
return OpIsTrue
|
||||
}
|
||||
return OpIsFalse
|
||||
}
|
||||
|
||||
func splitRuleValues(v string) []string {
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
||||
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -292,13 +293,43 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
}
|
||||
|
||||
// The query filters excluded rows in SQL and embeds the media row,
|
||||
// so the items are MediaItems as-is. (The legacy auto-assign branch
|
||||
// retired 2026-10-31 with the rules-array storage: smart user
|
||||
// collections evaluate on their detail page; sections render their
|
||||
// stored membership.)
|
||||
// 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 {
|
||||
items = append(items, row.MediaItems)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user