package services import ( "bookhoard/internal/database" "context" "encoding/json" "errors" "fmt" "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) *DashboardService { return &DashboardService{ db: db, collectionService: NewCollectionService(db), } } // 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. var manualNonExcluded []database.MediaItems for _, row := range manualItems { manualNonExcluded = append(manualNonExcluded, row.MediaItems) } var autoItems []database.MediaItems if len(coll.AutoAssignRules) > 0 { var rules []Rule if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID) if err == nil { for _, item := range allLibraryItems { alreadyInCollection := false for _, manualItem := range manualNonExcluded { manualUUID := uuid.UUID(manualItem.ID.Bytes) itemUUID := uuid.UUID(item.ID.Bytes) if manualUUID == itemUUID { alreadyInCollection = true break } } if alreadyInCollection { continue } evaluations := s.collectionService.EvaluateRules(item, rules) for _, eval := range evaluations { if eval.Matches { autoItems = append(autoItems, item) break } } } } } } var finalItems []database.MediaItems finalItems = append(finalItems, manualNonExcluded...) finalItems = append(finalItems, autoItems...) if len(finalItems) > limit { finalItems = finalItems[:limit] } return finalItems, 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 }