feat(dashboard): implement Phase 2 dashboard service layer
Create DashboardService with business logic for Carousel-style dashboard: Service Methods: - NewDashboardService: Create service instance with injected dependencies - GetDashboardSections: Fetch all collections (system + user) with their items * Gets system collections (user_id = NULL) by query type * Gets user collections with manual + auto-assigned items * Filters hidden collections based on user preferences * Reorders collections based on user custom order * Sorts by priority if no custom order exists - GetDashboardPreferences: Fetch user dashboard preferences for library - UpsertDashboardPreferences: Save or update user dashboard preferences - RestoreSystemCollection: Reset user's copy of system collection to defaults Helper Methods: - filterHiddenCollections: Remove hidden collections from results - reorderCollections: Reorder sections based on user preference - sortByPriority: Sort sections by priority (lower numbers first) - getCollectionItemsByQueryType: Return items for system collections by query type - getUserCollectionItems: Return items for user collections (manual + auto-assign) Type Conversion Helpers: - mediaItemsToListMediaItemsRow: Convert MediaItems to ListMediaItemsRow for rule evaluation - getCollectionItemsRowToMediaItems: Convert GetCollectionItemsForDashboardRow to MediaItems Architecture Compliance: - Service layer holds all business logic (reusable by SSR, API, mobile) - Returns database types (type safety at DB layer) - Handler converts to API types (clean JSON contracts) - Uses existing database queries and collection service - Procedural/imperative style (no OOP) - Follows existing pattern from collections.go
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
|
||||
return database.ListMediaItemsRow{
|
||||
ID: item.ID,
|
||||
LibraryID: item.LibraryID,
|
||||
Title: item.Title,
|
||||
Author: item.Author,
|
||||
Isbn: item.Isbn,
|
||||
Description: item.Description,
|
||||
FilePath: item.FilePath,
|
||||
FileSize: item.FileSize,
|
||||
MimeType: item.MimeType,
|
||||
CoverImagePath: item.CoverImagePath,
|
||||
Series: item.Series,
|
||||
SeriesNumber: item.SeriesNumber,
|
||||
Tags: item.Tags,
|
||||
Asin: item.Asin,
|
||||
DatePublished: item.DatePublished,
|
||||
Publisher: item.Publisher,
|
||||
Contributors: item.Contributors,
|
||||
Language: item.Language,
|
||||
Edition: item.Edition,
|
||||
PageCount: item.PageCount,
|
||||
Genre: item.Genre,
|
||||
CopyrightYear: item.CopyrightYear,
|
||||
GoodreadsID: item.GoodreadsID,
|
||||
OpenlibraryID: item.OpenlibraryID,
|
||||
GoogleBooksID: item.GoogleBooksID,
|
||||
AddedByAdminID: item.AddedByAdminID,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
FormatGroup: item.FormatGroup,
|
||||
FormatMimetype: item.FormatMimetype,
|
||||
IsReflowable: item.IsReflowable,
|
||||
HasFixedLayout: item.HasFixedLayout,
|
||||
TotalCharacters: item.TotalCharacters,
|
||||
ChapterCount: item.ChapterCount,
|
||||
EntitlementID: item.EntitlementID,
|
||||
RevisionNumber: item.RevisionNumber,
|
||||
KoboContentID: item.KoboContentID,
|
||||
KoboMetadata: item.KoboMetadata,
|
||||
TagsSearch: item.TagsSearch,
|
||||
ContributorsSearch: item.ContributorsSearch,
|
||||
FileSha256: item.FileSha256,
|
||||
OpfIdentifier: item.OpfIdentifier,
|
||||
OpfUuid: item.OpfUuid,
|
||||
HashConfidence: item.HashConfidence,
|
||||
LibraryName: "",
|
||||
LibraryTypeName: "",
|
||||
}
|
||||
}
|
||||
|
||||
func getCollectionItemsRowToMediaItems(item database.GetCollectionItemsForDashboardRow) database.MediaItems {
|
||||
return database.MediaItems{
|
||||
ID: item.ID,
|
||||
LibraryID: item.LibraryID,
|
||||
Title: item.Title,
|
||||
Author: item.Author,
|
||||
Isbn: item.Isbn,
|
||||
Description: item.Description,
|
||||
FilePath: item.FilePath,
|
||||
FileSize: item.FileSize,
|
||||
MimeType: item.MimeType,
|
||||
CoverImagePath: item.CoverImagePath,
|
||||
Series: item.Series,
|
||||
SeriesNumber: item.SeriesNumber,
|
||||
Tags: item.Tags,
|
||||
Asin: item.Asin,
|
||||
DatePublished: item.DatePublished,
|
||||
Publisher: item.Publisher,
|
||||
Contributors: item.Contributors,
|
||||
Language: item.Language,
|
||||
Edition: item.Edition,
|
||||
PageCount: item.PageCount,
|
||||
Genre: item.Genre,
|
||||
CopyrightYear: item.CopyrightYear,
|
||||
GoodreadsID: item.GoodreadsID,
|
||||
OpenlibraryID: item.OpenlibraryID,
|
||||
GoogleBooksID: item.GoogleBooksID,
|
||||
AddedByAdminID: item.AddedByAdminID,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
FormatGroup: item.FormatGroup,
|
||||
FormatMimetype: item.FormatMimetype,
|
||||
IsReflowable: item.IsReflowable,
|
||||
HasFixedLayout: item.HasFixedLayout,
|
||||
TotalCharacters: item.TotalCharacters,
|
||||
ChapterCount: item.ChapterCount,
|
||||
EntitlementID: item.EntitlementID,
|
||||
RevisionNumber: item.RevisionNumber,
|
||||
KoboContentID: item.KoboContentID,
|
||||
KoboMetadata: item.KoboMetadata,
|
||||
TagsSearch: item.TagsSearch,
|
||||
ContributorsSearch: item.ContributorsSearch,
|
||||
FileSha256: item.FileSha256,
|
||||
OpfIdentifier: item.OpfIdentifier,
|
||||
OpfUuid: item.OpfUuid,
|
||||
HashConfidence: item.HashConfidence,
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardService struct {
|
||||
db *database.Queries
|
||||
collectionService *CollectionService
|
||||
}
|
||||
|
||||
func NewDashboardService(db *database.Queries) *DashboardService {
|
||||
return &DashboardService{
|
||||
db: db,
|
||||
collectionService: NewCollectionService(db),
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardSection struct {
|
||||
CollectionID uuid.UUID
|
||||
CollectionName string
|
||||
Items []database.MediaItems
|
||||
QueryType string
|
||||
Priority int
|
||||
IsSystem bool
|
||||
Title string
|
||||
Description string
|
||||
Icon string
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardSections(
|
||||
ctx context.Context,
|
||||
userID, libraryID uuid.UUID,
|
||||
limit int,
|
||||
collectionOrder []string,
|
||||
hiddenCollections []string,
|
||||
) ([]DashboardSection, error) {
|
||||
var results []DashboardSection
|
||||
|
||||
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
|
||||
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: uuid.UUID(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
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, DashboardSection{
|
||||
CollectionID: uuid.UUID(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,
|
||||
})
|
||||
}
|
||||
|
||||
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, libraryID uuid.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: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
})
|
||||
case "recently-added":
|
||||
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
})
|
||||
case "recently-read":
|
||||
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
})
|
||||
case "not-started":
|
||||
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
})
|
||||
default:
|
||||
return []database.MediaItems{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
|
||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var manualNonExcluded []database.MediaItems
|
||||
for _, item := range manualItems {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
manualNonExcluded = append(manualNonExcluded, getCollectionItemsRowToMediaItems(item))
|
||||
}
|
||||
}
|
||||
|
||||
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, pgtype.UUID{Bytes: libraryID, Valid: true})
|
||||
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
|
||||
}
|
||||
|
||||
listItem := mediaItemsToListMediaItemsRow(item)
|
||||
evaluations := s.collectionService.EvaluateRules(listItem, 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, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
|
||||
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
||||
return s.db.UpsertDashboardPreferences(ctx, params)
|
||||
}
|
||||
|
||||
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
|
||||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user