Files
bookhoard/internal/services/collection_service.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

779 lines
26 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrCollectionNameTaken: the user already has a collection with this name.
var ErrCollectionNameTaken = errors.New("collection name already exists")
// Rule represents an auto-assign rule for collections
type Rule struct {
ID string `json:"id"`
Field string `json:"field"` // "genre", "series", "author", "language", "publisher", "copyright_year", "tags"
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
Value string `json:"value"`
Priority int `json:"priority"` // 1-10, higher values take precedence
// Enabled gates the rule in the LIVE evaluation used by smart
// collections; absent (JSON omitempty) means enabled — rules written
// before the field existed keep matching.
Enabled *bool `json:"enabled,omitempty"`
}
// RuleEvaluation represents the result of evaluating a rule
type RuleEvaluation struct {
RuleID string `json:"rule_id"`
Matches bool `json:"matches"`
Confidence float64 `json:"confidence"`
}
// CollectionService handles collection management
type CollectionService struct {
db *database.Queries
// search powers the unified-engine path (item 3.2): tree-driven
// smart collections evaluate via ComposedLiveMedia. Optional —
// nil disables tree evaluation (legacy-only services, which keeps
// every existing constructor call site valid).
search *SearchService
}
// NewCollectionService creates a new collection service. search is
// optional (variadic for constructor compatibility) — required for
// TREE-driven smart collections (item 3.2), ignored otherwise.
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
svc := &CollectionService{db: db}
if len(search) > 0 {
svc.search = search[0]
}
return svc
}
// 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, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
// Convert view settings to JSONB ([]byte)
var settingsJSON []byte
if len(viewSettings) > 0 {
var err error
settingsJSON, err = json.Marshal(viewSettings)
if err != nil {
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
}
}
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,
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
})
if err != nil {
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
// server failure: surface it as 409 so callers can recover.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return database.Collections{}, ErrCollectionNameTaken
}
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
}
return collection, nil
}
// GetCollection gets a collection by ID
func (s *CollectionService) GetCollection(ctx context.Context, collectionID uuid.UUID) (database.Collections, error) {
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to get collection: %v", err)
}
return collection, nil
}
// GetUserCollections gets all collections for a user
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.Collections, error) {
collections, err := s.db.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, fmt.Errorf("failed to get user collections: %v", err)
}
return collections, nil
}
// UpdateCollection rewrites the collection row. filterTree semantics:
// nil = leave the stored tree untouched (forms don't send trees).
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
// A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive.
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
if filterTree == nil {
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
}
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
merged := stored
for k, v := range viewSettings {
merged[k] = v
}
viewSettings = merged
}
}
// Same convention as filterTree: a NIL view_settings payload
// means untouched — keep the stored doc. Without this, an edit
// that carries no settings wrote NULL over the row and silently
// dropped every stored key (chain_order, imported provenance);
// the 2026-10-31 chain-order test pinned exactly this clobber.
if viewSettings == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
viewSettings = stored
}
}
}
// Convert view settings to JSONB ([]byte)
var settingsJSON []byte
if len(viewSettings) > 0 {
var err error
settingsJSON, err = json.Marshal(viewSettings)
if err != nil {
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
}
}
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
ID: pgtype.UUID{Bytes: collectionID, 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,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to update collection: %w", err)
}
return collection, nil
}
// DeleteCollection deletes a collection
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
// System collections (the 5 dashboard sections) are PERMANENT: a
// delete tombstones the row (invisible everywhere, restorable with
// the same id) instead of destroying it and every reference to it.
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil // already gone: keep delete idempotent
}
return fmt.Errorf("failed to fetch collection: %w", err)
}
if row.IsSystemCollection.Bool {
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
}
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
}
// AddBookToCollection adds a book to a collection
func (s *CollectionService) AddBookToCollection(ctx context.Context, collectionID, mediaItemID, userID uuid.UUID) error {
_, err := s.db.AddBookToCollection(ctx, database.AddBookToCollectionParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
AddedByUserID: pgtype.UUID{Bytes: userID, Valid: true},
})
if err != nil {
return fmt.Errorf("failed to add book to collection: %v", err)
}
return nil
}
// RemoveBookFromCollection removes a book from a collection
func (s *CollectionService) RemoveBookFromCollection(ctx context.Context, collectionID, mediaItemID uuid.UUID) error {
err := s.db.RemoveBookFromCollection(ctx, database.RemoveBookFromCollectionParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
})
if err != nil {
return fmt.Errorf("failed to remove book from collection: %v", err)
}
return nil
}
// GetCollectionBooks gets all books in a collection
func (s *CollectionService) GetCollectionBooks(ctx context.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
books, err := s.db.GetCollectionItems(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return nil, fmt.Errorf("failed to get collection books: %v", err)
}
return books, nil
}
// GetBookCollections gets all collections that contain a specific book
func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID uuid.UUID) ([]database.Collections, error) {
collections, err := s.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
if err != nil {
return nil, fmt.Errorf("failed to get book collections: %v", err)
}
return collections, nil
} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// SmartEvalOptions scopes a smart collection's live evaluation.
type SmartEvalOptions struct {
// LibraryID, when valid, restricts matching to one library (the
// detail page's library switcher).
LibraryID pgtype.UUID
// VisibleIDs is the requesting user's visible-library set (family
// lens). Empty means no visible-library filter.
VisibleIDs []pgtype.UUID
// ViewerUserID scopes the candidate query's progress join — the
// played/unplayed rule is per-user. Zero UUID = no viewer (played
// rules then see "unplayed" for everything, honestly).
ViewerUserID pgtype.UUID
}
// IsCollectionSmart is the ONE smart detection: a collection is smart
// when it carries the unified filter tree (item 3.2). Every surface
// routes through this. (The legacy rules-array branch retired
// 2026-10-31 — the tree is the only smart storage.)
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) {
return s.evaluateTreeMembership(ctx, coll, opts)
}
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
tree, err := ParseFilterTree(string(coll.FilterTree))
if err != nil {
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
}
if s.search == nil {
return nil, errors.New("tree collections require the search service (constructor wiring)")
}
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
}
// 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
}
// FieldMatches is THE single rule evaluator (the rules-builder preview
// delegates here too — one vocabulary, one semantics). Episode/user-state
// fields (feed/played/downloaded/published_within) evaluate directly;
// everything else extracts a string and hits the generic comparator.
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
switch field {
case "feed":
if !item.SourceFeedID.Valid {
return false
}
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
for _, raw := range strings.Split(value, ",") {
if strings.TrimSpace(raw) == feedID {
return true
}
}
return false
case "played":
wantPlayed := value == "true"
return episodeFinished(viewerPercentage) == wantPlayed
case "downloaded":
wantDownloaded := value == "true"
return (item.DownloadState == "downloaded") == wantDownloaded
case "published_within":
if !item.DatePublished.Valid {
return false
}
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || days < 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
return item.DatePublished.Time.After(cutoff)
}
var fieldValue string
switch field {
case "title":
fieldValue = item.Title
case "description":
if item.Description.Valid {
fieldValue = item.Description.String
}
case "genre":
if item.Genre.Valid {
fieldValue = item.Genre.String
}
case "series":
if item.Series.Valid {
fieldValue = item.Series.String
}
case "author":
if item.Author.Valid {
fieldValue = item.Author.String
}
case "language":
if item.Language.Valid {
fieldValue = item.Language.String
}
case "publisher":
if item.Publisher.Valid {
fieldValue = item.Publisher.String
}
case "copyright_year":
if item.CopyrightYear.Valid {
fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if len(item.Tags) > 0 {
fieldValue = strings.Join(item.Tags, ", ")
}
}
return s.evaluateRule(fieldValue, operator, value)
}
// EvaluateRules evaluates auto-assign rules for a media item — the
// rules-builder preview's per-rule view. viewerPercentage is the
// requesting user's progress for this item (0 = none), so played /
// unplayed rules preview honestly (plan item 2.4).
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
var evaluations []RuleEvaluation
// Sort rules by priority (higher priority first)
sortedRules := make([]Rule, len(rules))
copy(sortedRules, rules)
for i := range len(sortedRules) {
for j := i + 1; j < len(sortedRules); j++ {
if sortedRules[i].Priority < sortedRules[j].Priority {
sortedRules[i], sortedRules[j] = sortedRules[j], sortedRules[i]
}
}
}
for _, rule := range sortedRules {
// Disabled rules never match (kept for context in the UI only).
if rule.Enabled != nil && !*rule.Enabled {
evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false})
continue
}
eval := RuleEvaluation{
RuleID: rule.ID,
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
}
// Calculate confidence based on rule type and priority
if eval.Matches {
baseConfidence := 0.7
switch rule.Field {
case "genre", "series":
baseConfidence = 0.9
case "author", "publisher":
baseConfidence = 0.8
}
// Add priority boost (0.01 per priority point, max 0.1)
priorityBoost := float64(rule.Priority) * 0.01
if priorityBoost > 0.1 {
priorityBoost = 0.1
}
eval.Confidence = baseConfidence + priorityBoost
if eval.Confidence > 1.0 {
eval.Confidence = 1.0
}
}
evaluations = append(evaluations, eval)
}
return evaluations
}
// evaluateRule evaluates a single rule
func (s *CollectionService) evaluateRule(fieldValue, operator, ruleValue string) bool {
switch operator {
case "equals":
return containsIgnoreCase(fieldValue, ruleValue) && len(fieldValue) == len(ruleValue)
case "contains":
return containsIgnoreCase(fieldValue, ruleValue)
case "starts_with":
return hasPrefixIgnoreCase(fieldValue, ruleValue)
case "ends_with":
return hasSuffixIgnoreCase(fieldValue, ruleValue)
case "greater_than":
return compareNumbers(fieldValue, ruleValue, ">")
case "less_than":
return compareNumbers(fieldValue, ruleValue, "<")
default:
return false
}
}
// containsIgnoreCase checks if a string contains another (case-insensitive)
func containsIgnoreCase(s, substr string) bool {
s = toLower(s)
substr = toLower(substr)
return contains(s, substr)
}
// hasPrefixIgnoreCase checks if a string starts with a prefix (case-insensitive)
func hasPrefixIgnoreCase(s, prefix string) bool {
s = toLower(s)
prefix = toLower(prefix)
return hasPrefix(s, prefix)
}
// hasSuffixIgnoreCase checks if a string ends with a suffix (case-insensitive)
func hasSuffixIgnoreCase(s, suffix string) bool {
s = toLower(s)
suffix = toLower(suffix)
return hasSuffix(s, suffix)
}
// compareNumbers compares two numeric strings
func compareNumbers(a, b, op string) bool {
aFloat, errA := parseFloat(a)
bFloat, errB := parseFloat(b)
if errA != nil || errB != nil {
return false
}
switch op {
case ">":
return aFloat > bFloat
case "<":
return aFloat < bFloat
case ">=":
return aFloat >= bFloat
case "<=":
return aFloat <= bFloat
default:
return false
}
}
// 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},
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
})
if err != nil {
return database.DeviceShelfMappings{}, fmt.Errorf("failed to create shelf mapping: %v", err)
}
return mapping, nil
}
// GetDeviceShelfMappings gets all shelf mappings for a device
func (s *CollectionService) GetDeviceShelfMappings(ctx context.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
mappings, err := s.db.GetDeviceShelfMappings(ctx, pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return nil, fmt.Errorf("failed to get shelf mappings: %v", err)
}
return mappings, nil
}
// UpdateDeviceShelfMapping updates a shelf mapping
func (s *CollectionService) UpdateDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
mapping, err := s.db.UpdateDeviceShelfMapping(ctx, database.UpdateDeviceShelfMappingParams{
ID: pgtype.UUID{Bytes: mappingID, Valid: true},
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
})
if err != nil {
return database.DeviceShelfMappings{}, fmt.Errorf("failed to update shelf mapping: %v", err)
}
return mapping, nil
}
// DeleteDeviceShelfMapping deletes a shelf mapping
func (s *CollectionService) DeleteDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID) error {
err := s.db.DeleteDeviceShelfMapping(ctx, pgtype.UUID{Bytes: mappingID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete shelf mapping: %v", err)
}
return nil
}
func toLower(s string) string {
if len(s) == 0 {
return s
}
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
result[i] = c + 32
} else {
result[i] = c
}
}
return string(result)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) >= 0
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func hasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
func parseFloat(s string) (float64, error) {
var result float64
var sign float64 = 1
var divisor float64 = 1
var decimalPlaces = 0
var seenDecimal bool
// Skip leading whitespace
start := 0
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n') {
start++
}
// Handle sign
if start < len(s) && s[start] == '-' {
sign = -1
start++
} else if start < len(s) && s[start] == '+' {
start++
}
// Parse digits
for i := start; i < len(s); i++ {
c := s[i]
if c >= '0' && c <= '9' {
result = result*10 + float64(c-'0')
if seenDecimal {
decimalPlaces++
divisor *= 10
}
} else if c == '.' && !seenDecimal {
seenDecimal = true
} else {
break
}
}
return sign * result / divisor, nil
}
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
// collection row's owner is the only user whose drag actions rewrite
// positions.
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return false, err
}
return uuid.UUID(collection.UserID.Bytes) == userID, nil
}
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
// complete ordered id list; positions become 1..n in payload order. Ids
// not in the payload keep their relative order after — the SQL only
// touches the given ids, and NULL positions sort last. Never deletes.
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
if len(orderedIDs) == 0 {
return nil
}
ids := make([]pgtype.UUID, 0, len(orderedIDs))
for _, id := range orderedIDs {
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
}
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
MediaItemIds: ids,
}); err != nil {
return fmt.Errorf("failed to reorder collection items: %v", err)
}
return nil
}