Files
bookhoard/internal/services/collection_service.go
T
John O'Keefe a86bdac87e refactor(collections): retire the legacy rules surface — tree-only storage
The pre-tree rules machinery is deleted: no row ever carried a rules
array or a non-default match mode (verified in the dev DB), the web
never read the envelope keys, and the Android app consumes none of it.
Dead code gets no documentation.

Removed:
- PUT /api/collections/:id/match-mode — endpoint, route, and the
  UpdateCollectionMatchMode query. The tree's root mode owns any|all.
- auto_assign_rules / rule_match_mode from every envelope
  (create/list/detail/update), from the create/update request shapes,
  and from the schema: both columns DROPPED (schema.sql carries
  idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc
  regenerated).
- The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule +
  validateRule + UpdateCollectionRulesJSON), ParseRules /
  EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR
  preload branch), the legacy branch of EvaluateSmartMembership, and
  SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence;
  create-path isSmart = collection_type=smart OR a filter_tree.

Kept (live consumers): POST /api/collections/preview still evaluates
rules payloads in memory (the custom-section builder's preview), and
include_auto stays on the list (the Android app sends it).

The custom-section builder's SAVE was already broken — its
stringified auto_assign_rules payload failed request binding (probed
live: 400 invalid request). Create now rejects that payload loudly
with a 400 naming the retirement, instead of silently creating a
hollow static section; custom-section-builder.md carries the flag.
The builder's tree migration remains the flagged follow-up.

Test-side (asymmetry rule — deleted behavior, deleted tests):
TestPodcastSmartCollections rewritten onto trees (mode lives in the
tree root), match-mode subtests and tree_wins_over_legacy_rules
deleted, TestRulesToFilterTree deleted, contract-pin envelope keys
retracted per the contract_pins 2026-10-31 retirement entry. Mirrors
hand-synced: openapi.yaml, bruno create/update, docs/api/*.md,
docs/schema.go, api.d.ts. Full container suite green (155s).
2026-10-31 19:06:48 -04:00

649 lines
22 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, 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,
})
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
}
// 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)
} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// 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
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
}