Add comprehensive documentation for Phase 4.6 which enables the CreateCollection endpoint to support manual book selection alongside auto-assign rules. This is required for the Custom Section Builder. Key additions: - Phase 4.6: Update CreateCollection Endpoint (30-45 min) - Add ManualBookIDs field to CreateCollectionRequest struct - Implement graceful handling of invalid book IDs - Add validation (max 50 book IDs) to prevent DoS - Reuse existing AddBookToCollection service method - Maintain backward compatibility (field is optional) - Updated Phase 12.5: Collections Bruno tests - create-collection-with-manual-books.bru - create-collection-too-many-books.bru (validation test) - create-collection-invalid-book-id.bru - create-collection-rules-only.bru - create-collection-unauthorized.bru - Added section 13.3: Collections API documentation - manual_book_ids field documentation - Validation limits (max 50 items) - Example combining auto-assign + manual books - Error handling explanation Design decisions: - Graceful degradation: Collection created even if some books fail - Reuse existing infrastructure: No new service methods needed - Backward compatible: Optional field doesn't break existing clients - UI constraint: 50 book limit prevents abuse while allowing flexibility
5518 lines
180 KiB
Markdown
5518 lines
180 KiB
Markdown
# 🎬 Carousel-Style Dashboard Redesign Plan
|
||
|
||
## Overview
|
||
|
||
Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with:
|
||
- **Unified Collections Architecture**: Both system defaults and user-created sections are collections
|
||
- **4 System Collections**: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable)
|
||
- User collections as sections (manual or filter-based)
|
||
- Separate dashboard per library
|
||
- Full accessibility, keyboard nav, and touch gestures
|
||
- **SSR-first architecture** (data pre-populated server-side, TypeScript for updates)
|
||
- **Drag-and-drop reordering** with user preference persistence
|
||
|
||
---
|
||
|
||
## ⚠️ Prerequisites: TypeScript Conversion First
|
||
|
||
**IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first.
|
||
|
||
**Required Infrastructure from TypeScript Conversion Plan:**
|
||
- ✅ `web/src/api.ts` - Centralized API client with auth
|
||
- ✅ `web/src/toast.ts` - Toast notification system
|
||
- ✅ `web/src/events.ts` - Event delegation utilities
|
||
- ✅ `web/src/storage.ts` - localStorage wrapper
|
||
- ✅ `web/src/dom.ts` - DOM utilities (escapeHtml, etc.)
|
||
- ✅ `web/src/types/api.d.ts` - Type definitions for all API responses
|
||
- ✅ Event delegation pattern established (data attributes)
|
||
- ✅ TypeScript compilation pipeline in place (`npm run build:ts`)
|
||
|
||
**Execution Order:**
|
||
1. Complete TypeScript Conversion Plan (20-25.5 days)
|
||
2. Execute this updated Carousel Dashboard Plan (3-4 days)
|
||
|
||
**Timeline:** 23-29.5 days total (no rework, consistent patterns)
|
||
|
||
---
|
||
|
||
## 🏗️ Architecture Compliance
|
||
|
||
### Project Guidelines Alignment
|
||
|
||
This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
|
||
|
||
**Key Compliance Points:**
|
||
|
||
✅ **Full-Stack Task** (backend modifications approved):
|
||
- Database schema changes (unified collections architecture)
|
||
- New service layer for reusable business logic
|
||
- New API endpoints for mobile app compatibility
|
||
- Bruno tests already created in `bruno/dashboard/`
|
||
|
||
✅ **Frontend Standards** (Updated for Post-TypeScript Conversion):
|
||
- **TailwindCSS classes ONLY** - no custom CSS
|
||
- **TypeScript** in `web/src/` (no inline JavaScript)
|
||
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
|
||
- **SSR for initial page load** - server pre-populates data (like collections, progress pages)
|
||
- **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render)
|
||
- **NO HTMX for dynamic interactions** - library selector, modal saves use pure TypeScript
|
||
- **Event delegation pattern** - `data-action` attributes
|
||
- **API client** - `(window as any).api` from `web/src/api.ts`
|
||
- **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts`
|
||
- **Type definitions** - `import type { ... } from './types/api'`
|
||
|
||
✅ **Code Organization**:
|
||
- **Handler types in internal/handlers/collections.go** - SectionData, BookInfo (single source of truth)
|
||
- **Templates use handler types directly** - no duplicate types in templates package
|
||
- **Service returns structured data** - collections with items already matched
|
||
- **Handler converts types for JSON** - simple type conversion only
|
||
- **TypeScript in web/src/** - follows TypeScript Conversion Plan structure
|
||
- **Type definitions in web/src/types/api.d.ts** - recreate handler JSON for TypeScript
|
||
|
||
✅ **Database Operations**:
|
||
- **Merge into existing schema.sql** - no migration files
|
||
- **Atomic schema changes** - complete success or rejection
|
||
- **Pre-production app** - database will be recreated after schema changes
|
||
- **pgx v5 standards** - proper connection handling
|
||
|
||
✅ **API Documentation**:
|
||
- **Bruno tests** in `bruno/dashboard/`
|
||
- Three-context testing (no user, user, admin)
|
||
- Backward compatibility for mobile apps
|
||
- `docs/developer/api/** documentation updates
|
||
|
||
---
|
||
|
||
## 🎯 Unified Collections Architecture
|
||
|
||
### Key Design Principle
|
||
|
||
**Simplified Concept**: Both system defaults and user-created sections are **collections**. This eliminates the duplication of having separate "smart sections" and "collections" concepts.
|
||
|
||
### Architecture Details
|
||
|
||
**Collections Table Structure:**
|
||
```sql
|
||
CREATE TABLE collections (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created
|
||
name VARCHAR(100) NOT NULL,
|
||
description TEXT,
|
||
color VARCHAR(7),
|
||
icon VARCHAR(50),
|
||
auto_assign_rules JSONB,
|
||
show_on_dashboard BOOLEAN DEFAULT false,
|
||
query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc.
|
||
priority INT DEFAULT 100,
|
||
is_system_collection BOOLEAN DEFAULT false,
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
UNIQUE(user_id, name)
|
||
);
|
||
```
|
||
|
||
**Key Fields:**
|
||
- `user_id NULL` = System-owned collections (4 defaults)
|
||
- `user_id NOT NULL` = User-created collections
|
||
- `query_type` = Determines how items are fetched ('filter', 'recent', 'progress-based')
|
||
- `is_system_collection` = Flags system collections for restore defaults functionality
|
||
- `show_on_dashboard` = Controls visibility on dashboard
|
||
- `priority` = Display order (lower = higher priority)
|
||
|
||
### Benefits of Unified Architecture
|
||
|
||
1. **Single Table, Single Concept** - No duplication between "smart sections" and "collections"
|
||
2. **Same Mechanism** - System defaults use same code path as user collections
|
||
3. **Editable System Collections** - Users can customize default sections
|
||
4. **Restore Defaults** - Can reset system collections if user messes up
|
||
5. **Simpler Queries** - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X)
|
||
6. **Extensible** - Easy to add new system collections
|
||
|
||
---
|
||
|
||
## 📋 Implementation Plan
|
||
|
||
### **Phase 1: Database Schema Changes** (2-3 hours)
|
||
|
||
#### 1.1 Update Schema File (Not Migrations)
|
||
**File: `database/schema/schema.sql`** (MODIFY existing file)
|
||
|
||
**CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database:
|
||
```bash
|
||
podman compose down -v # Delete volumes (loses all data)
|
||
podman compose up -d # Start fresh with new schema
|
||
```
|
||
|
||
**Add/Modify in schema.sql**:
|
||
|
||
```sql
|
||
-- Table: user_dashboard_preferences
|
||
CREATE TABLE user_dashboard_preferences (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
|
||
hidden_collections TEXT[] DEFAULT '{}', -- Changed from hidden_sections
|
||
collection_order TEXT[] DEFAULT '{}', -- Changed from section_order
|
||
items_per_section INT DEFAULT 20,
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
updated_at TIMESTAMP DEFAULT NOW(),
|
||
UNIQUE(user_id, library_id)
|
||
);
|
||
|
||
-- Index for fast lookups
|
||
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
||
|
||
-- Modify collections table to support unified architecture
|
||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||
ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL; -- Allow NULL for system collections
|
||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
|
||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter';
|
||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100;
|
||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false;
|
||
|
||
-- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id
|
||
ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key;
|
||
ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name);
|
||
|
||
-- Index for dashboard queries
|
||
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
||
WHERE show_on_dashboard = true;
|
||
|
||
-- Add excluded column to collection_items for user overrides
|
||
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false;
|
||
|
||
-- Index for excluding auto-assigned items
|
||
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
|
||
WHERE excluded = true;
|
||
|
||
-- Insert 4 system collections (pre-seeded defaults)
|
||
-- These are user_id NULL to indicate system ownership
|
||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES
|
||
(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'),
|
||
(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'),
|
||
(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'),
|
||
(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null')
|
||
ON CONFLICT (user_id, name) DO NOTHING;
|
||
```
|
||
|
||
**Schema Changes Summary:**
|
||
- ✅ Added `user_id` to collections table (nullable for system collections)
|
||
- ✅ Added `show_on_dashboard` boolean
|
||
- ✅ Added `query_type` text field
|
||
- ✅ Added `priority` integer field
|
||
- ✅ Added `is_system_collection` boolean flag
|
||
- ✅ Removed `smart_section_types` table entirely
|
||
- ✅ Pre-seeded 4 system collections
|
||
- ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections)
|
||
|
||
#### 1.2 Regenerate Database Code
|
||
```bash
|
||
cd internal/database
|
||
sqlc generate
|
||
```
|
||
|
||
Verify:
|
||
- ✅ `models.go` has updated Collections struct
|
||
- ✅ `queries.sql` is ready for new queries
|
||
- ✅ No compilation errors
|
||
|
||
---
|
||
|
||
### **Phase 2: Service Layer** (3-4 hours)
|
||
|
||
**File: `internal/services/dashboard_service.go`** (new file)
|
||
|
||
**COMPLIANCE**: All business logic in reusable service (per guidelines)
|
||
|
||
**ARCHITECTURE NOTE**: Following existing pattern from `collections.go`:
|
||
- Service returns structured data (collections with their items already matched)
|
||
- Handler converts types for JSON serialization
|
||
- Single unified method (simpler, less buggy)
|
||
|
||
```go
|
||
package services
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"bookhoard/internal/database"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
)
|
||
|
||
type DashboardService struct {
|
||
db *database.Queries
|
||
collectionService *CollectionService
|
||
}
|
||
|
||
// NewDashboardService creates service instance
|
||
func NewDashboardService(db *database.Queries) *DashboardService {
|
||
return &DashboardService{
|
||
db: db,
|
||
collectionService: NewCollectionService(db),
|
||
}
|
||
}
|
||
|
||
// DashboardSection represents a collection with its items (for dashboard display)
|
||
type DashboardSection struct {
|
||
CollectionID uuid.UUID
|
||
CollectionName string
|
||
Items []database.MediaItems
|
||
QueryType string
|
||
Priority int
|
||
IsSystem bool
|
||
Title string
|
||
Description string
|
||
Icon string
|
||
}
|
||
|
||
// GetDashboardSections fetches all collections (system + user) with their items
|
||
// Returns structured data where items are already matched to collections
|
||
func (s *DashboardService) GetDashboardSections(
|
||
ctx context.Context,
|
||
userID, libraryID uuid.UUID,
|
||
limit int,
|
||
collectionOrder []string,
|
||
hiddenCollections []string,
|
||
) ([]DashboardSection, error) {
|
||
var results []DashboardSection
|
||
|
||
// Get system collections (user_id = NULL)
|
||
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Process system collections
|
||
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,
|
||
Title: coll.Name,
|
||
Description: coll.Description.String,
|
||
Icon: coll.Icon.String,
|
||
})
|
||
}
|
||
|
||
// Get user collections marked for dashboard
|
||
userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Process user collections
|
||
for _, coll := range userCollections {
|
||
items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
if len(items) == 0 {
|
||
continue // Skip empty collections
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
// Apply user preferences: filter hidden collections
|
||
results = s.filterHiddenCollections(results, hiddenCollections)
|
||
|
||
// Apply user preferences: reorder collections
|
||
results = s.reorderCollections(results, collectionOrder)
|
||
|
||
// Sort by priority if no custom order
|
||
if len(collectionOrder) == 0 {
|
||
results = s.sortByPriority(results)
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
// filterHiddenCollections removes hidden collections from results
|
||
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
|
||
}
|
||
|
||
// reorderCollections reorders sections based on user preference
|
||
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
|
||
}
|
||
|
||
// sortByPriority sorts sections by priority (lower numbers first)
|
||
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
|
||
}
|
||
|
||
// getCollectionItemsByQueryType returns items for system collections based on query_type
|
||
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
|
||
}
|
||
}
|
||
|
||
// getUserCollectionItems returns items for user collections (manual + auto-assign)
|
||
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])
|
||
|
||
// Get manually added items
|
||
manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{
|
||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||
Limit: int32(limit),
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Filter out excluded items
|
||
var manualNonExcluded []database.MediaItems
|
||
for _, item := range manualItems {
|
||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||
manualNonExcluded = append(manualNonExcluded, item)
|
||
}
|
||
}
|
||
|
||
// Evaluate auto-assign rules if collection has any
|
||
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 {
|
||
// Skip if already in manual items
|
||
alreadyInCollection := false
|
||
for _, manualItem := range manualNonExcluded {
|
||
if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] {
|
||
alreadyInCollection = true
|
||
break
|
||
}
|
||
}
|
||
if alreadyInCollection {
|
||
continue
|
||
}
|
||
|
||
// Evaluate rules
|
||
evaluations := s.collectionService.EvaluateRules(item, rules)
|
||
for _, eval := range evaluations {
|
||
if eval.Matches {
|
||
autoItems = append(autoItems, item)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Merge manual and auto items
|
||
var finalItems []database.MediaItems
|
||
finalItems = append(finalItems, manualNonExcluded...)
|
||
finalItems = append(finalItems, autoItems...)
|
||
|
||
if len(finalItems) > limit {
|
||
finalItems = finalItems[:limit]
|
||
}
|
||
|
||
return finalItems, nil
|
||
}
|
||
|
||
// GetDashboardPreferences fetches user preferences for a library
|
||
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},
|
||
})
|
||
}
|
||
|
||
// UpsertDashboardPreferences saves or updates user preferences for a library
|
||
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
||
return s.db.UpsertDashboardPreferences(ctx, params)
|
||
}
|
||
|
||
// RestoreSystemCollection resets a single system collection to defaults for a user
|
||
// collectionName is the name of the system collection to restore (e.g., "continue-reading")
|
||
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
|
||
// Delete user-owned copy of this specific system collection
|
||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||
Name: collectionName,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// System collection (user_id = NULL) will automatically appear on dashboard
|
||
// No need to recreate it
|
||
return nil
|
||
}
|
||
```
|
||
|
||
**Key Points**:
|
||
- ✅ Service layer holds all business logic
|
||
- ✅ Returns database types (type safety at DB layer)
|
||
- ✅ Handler converts to API types (clean JSON contracts)
|
||
- ✅ Reusable by SSR, API, mobile
|
||
- ✅ No direct database access from handlers
|
||
- ✅ Uses existing database queries
|
||
- ✅ Procedural/imperative style (no OOP)
|
||
- ✅ Follows existing pattern from collections.go
|
||
|
||
---
|
||
|
||
### **Phase 3: Database Queries** (1-2 hours)
|
||
|
||
**File: `internal/database/queries/queries.sql`** (ADD to existing file)
|
||
|
||
```sql
|
||
-- name: GetDashboardPreferences :one
|
||
SELECT * FROM user_dashboard_preferences
|
||
WHERE user_id = $1 AND library_id = $2;
|
||
|
||
-- name: UpsertDashboardPreferences :one
|
||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (user_id, library_id)
|
||
DO UPDATE SET
|
||
hidden_collections = EXCLUDED.hidden_collections,
|
||
collection_order = EXCLUDED.collection_order,
|
||
items_per_section = EXCLUDED.items_per_section,
|
||
updated_at = NOW()
|
||
RETURNING *;
|
||
|
||
-- name: UpdateDashboardPreferences :one
|
||
UPDATE user_dashboard_preferences
|
||
SET hidden_collections = $2,
|
||
collection_order = $3,
|
||
items_per_section = $4,
|
||
updated_at = NOW()
|
||
WHERE user_id = $1 AND library_id = $5
|
||
RETURNING *;
|
||
|
||
-- name: GetSystemCollectionsForDashboard :many
|
||
SELECT * FROM collections
|
||
WHERE user_id IS NULL
|
||
AND show_on_dashboard = true
|
||
ORDER BY priority ASC;
|
||
|
||
-- name: GetUserCollectionsForDashboard :many
|
||
SELECT c.* FROM collections c
|
||
WHERE c.user_id = $1
|
||
AND c.show_on_dashboard = true
|
||
AND c.is_system_collection = false
|
||
ORDER BY priority ASC;
|
||
|
||
-- name: DeleteUserSystemCollection :exec
|
||
DELETE FROM collections
|
||
WHERE user_id = $1
|
||
AND name = $2
|
||
AND is_system_collection = true;
|
||
|
||
-- Smart section queries (for system collections)
|
||
|
||
-- name: GetContinueReadingItems :many
|
||
SELECT DISTINCT mi.* FROM media_items mi
|
||
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
|
||
WHERE mi.library_id = $1
|
||
AND rp.user_id = $2
|
||
AND rp.percentage > 0
|
||
AND rp.percentage < 1
|
||
ORDER BY rp.last_read_at DESC
|
||
LIMIT $3;
|
||
|
||
-- name: GetRecentlyAddedItems :many
|
||
SELECT mi.* FROM media_items mi
|
||
WHERE mi.library_id = $1
|
||
ORDER BY mi.created_at DESC
|
||
LIMIT $2;
|
||
|
||
-- name: GetRecentlyReadItems :many
|
||
SELECT DISTINCT mi.* FROM media_items mi
|
||
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
|
||
WHERE mi.library_id = $1
|
||
AND rp.user_id = $2
|
||
AND rp.percentage >= 1
|
||
ORDER BY rp.last_read_at DESC
|
||
LIMIT $3;
|
||
|
||
-- name: GetNotStartedItems :many
|
||
SELECT mi.* FROM media_items mi
|
||
WHERE mi.library_id = $1
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM reading_progress rp
|
||
WHERE rp.media_item_id = mi.id
|
||
AND rp.user_id = $2
|
||
AND rp.percentage > 0
|
||
)
|
||
ORDER BY mi.created_at DESC
|
||
LIMIT $3;
|
||
|
||
-- name: GetCollectionItems :many
|
||
SELECT mi.*, ci.excluded FROM media_items mi
|
||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||
WHERE ci.collection_id = $1
|
||
AND mi.library_id = $2
|
||
ORDER BY ci.added_at DESC
|
||
LIMIT $3;
|
||
|
||
-- name: GetLibraryItems :many
|
||
SELECT mi.* FROM media_items mi
|
||
WHERE mi.library_id = $1
|
||
ORDER BY mi.created_at DESC;
|
||
```
|
||
|
||
Regenerate: `cd internal/database && sqlc generate`
|
||
|
||
---
|
||
|
||
### **Phase 4: API Handler** (2-3 hours)
|
||
|
||
**Step 1: Add SectionData to collections.go** (15 min)
|
||
|
||
**File: `internal/handlers/collections.go`** (MODIFY existing)
|
||
|
||
Add the `SectionData` struct after the existing `BookInfo` struct (around line 71):
|
||
|
||
```go
|
||
// SectionData represents a dashboard section (carousel of books)
|
||
// Used by: Dashboard handler, Templates (SSR), API JSON responses
|
||
type SectionData struct {
|
||
ID string `json:"id"`
|
||
IsSystem bool `json:"is_system"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Icon string `json:"icon"`
|
||
Items []BookInfo `json:"items"`
|
||
ViewAllURL string `json:"view_all_url"`
|
||
Priority int `json:"priority"`
|
||
}
|
||
```
|
||
|
||
**Step 2: Create dashboard.go** (1-1.75 hours)
|
||
|
||
**File: `internal/handlers/dashboard.go`** (new file)
|
||
|
||
**COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins
|
||
|
||
**IMPORTANT**: This file uses shared types from `collections.go`:
|
||
- `SectionData` struct (defined in collections.go)
|
||
- `BookInfo` struct (defined in collections.go, uses `MediaItemID` field)
|
||
|
||
No duplicate type definitions - collections.go is the source of truth.
|
||
|
||
```go
|
||
package handlers
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"bookhoard/internal/database"
|
||
"bookhoard/internal/services"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/labstack/echo/v4"
|
||
)
|
||
|
||
type DashboardHandler struct {
|
||
db *database.Queries
|
||
dashboardService *services.DashboardService
|
||
}
|
||
|
||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
||
return &DashboardHandler{
|
||
db: db,
|
||
dashboardService: services.NewDashboardService(db),
|
||
}
|
||
}
|
||
|
||
// GetSections returns dashboard sections as JSON
|
||
// Used by: Mobile apps, web UI TypeScript, plugins
|
||
func (h *DashboardHandler) GetSections(c echo.Context) error {
|
||
user := c.Get("user").(database.Users)
|
||
userUUID := uuid.UUID(user.ID.Bytes)
|
||
|
||
libraryID := c.QueryParam("library_id")
|
||
if libraryID == "" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||
}
|
||
libUUID, err := uuid.Parse(libraryID)
|
||
if err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||
}
|
||
|
||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||
|
||
limit := 20
|
||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||
limit = l
|
||
}
|
||
}
|
||
|
||
// Get dashboard sections (service returns structured data)
|
||
sections, err := h.dashboardService.GetDashboardSections(
|
||
c.Request().Context(),
|
||
userUUID,
|
||
libUUID,
|
||
limit,
|
||
prefs.CollectionOrder,
|
||
prefs.HiddenCollections,
|
||
)
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
||
}
|
||
|
||
// Convert service types to handler types (for JSON serialization)
|
||
sectionData := BuildSections(sections)
|
||
|
||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||
}
|
||
|
||
// UpdatePreferences saves dashboard preferences
|
||
func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
|
||
user := c.Get("user").(database.Users)
|
||
userUUID := uuid.UUID(user.ID.Bytes)
|
||
|
||
var req struct {
|
||
LibraryID string `json:"library_id"`
|
||
HiddenCollections []string `json:"hidden_collections"`
|
||
CollectionOrder []string `json:"collection_order"`
|
||
ItemsPerSection int `json:"items_per_section"`
|
||
}
|
||
|
||
if err := c.Bind(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||
}
|
||
|
||
libUUID, err := uuid.Parse(req.LibraryID)
|
||
if err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||
}
|
||
|
||
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||
HiddenCollections: req.HiddenCollections,
|
||
CollectionOrder: req.CollectionOrder,
|
||
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
||
})
|
||
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
|
||
}
|
||
|
||
return c.JSON(http.StatusOK, prefs)
|
||
}
|
||
|
||
// RestoreSystemCollection resets a single system collection to defaults
|
||
func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
|
||
user := c.Get("user").(database.Users)
|
||
userUUID := uuid.UUID(user.ID.Bytes)
|
||
|
||
var req struct {
|
||
CollectionName string `json:"collection_name"`
|
||
}
|
||
|
||
if err := c.Bind(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||
}
|
||
|
||
if req.CollectionName == "" {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
|
||
}
|
||
|
||
// Validate it's a system collection name
|
||
validCollections := map[string]bool{
|
||
"continue-reading": true,
|
||
"recently-added": true,
|
||
"recently-read": true,
|
||
"not-started": true,
|
||
}
|
||
if !validCollections[req.CollectionName] {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
||
}
|
||
|
||
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
||
}
|
||
|
||
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
||
}
|
||
|
||
// BuildSections converts service DashboardSection to handler SectionData
|
||
// Note: SectionData and BookInfo are defined in collections.go
|
||
func BuildSections(sections []services.DashboardSection) []SectionData {
|
||
var result []SectionData
|
||
|
||
for _, ds := range sections {
|
||
// Convert database.MediaItems to handlers.BookInfo
|
||
bookCards := make([]BookInfo, len(ds.Items))
|
||
for i, item := range ds.Items {
|
||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||
bookCards[i] = BookInfo{
|
||
MediaItemID: itemUUID.String(),
|
||
Title: item.Title,
|
||
Author: textToString(item.Author),
|
||
CoverImagePath: textToString(item.CoverImagePath),
|
||
}
|
||
}
|
||
|
||
result = append(result, SectionData{
|
||
ID: ds.CollectionName,
|
||
IsSystem: ds.IsSystem,
|
||
Title: ds.Title,
|
||
Description: ds.Description,
|
||
Icon: ds.Icon,
|
||
Items: bookCards,
|
||
ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType),
|
||
Priority: ds.Priority,
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
func getViewAllURL(key, queryType string) string {
|
||
urls := map[string]string{
|
||
"continue-reading": "/section/continue-reading",
|
||
"recently-added": "/section/recently-added",
|
||
"recently-read": "/history",
|
||
"not-started": "/section/not-started",
|
||
}
|
||
if url, exists := urls[queryType]; exists {
|
||
return url
|
||
}
|
||
return "" // User collections don't have view-all URLs
|
||
}
|
||
|
||
func textToString(t pgtype.Text) string {
|
||
if t.Valid {
|
||
return t.String
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
**Key Points**:
|
||
- ✅ Uses shared types from collections.go (SectionData, BookInfo)
|
||
- ✅ `IsSystem bool` matches database field (no string conversion)
|
||
- ✅ Generic JSON API endpoint
|
||
- ✅ Updated field names (hidden_collections, collection_order)
|
||
- ✅ Restore system collections endpoint
|
||
- ✅ Reusable by mobile apps, web UI, plugins
|
||
- ✅ Single service method returns structured data (simpler, less bugs)
|
||
- ✅ Handler just converts types (no matching logic needed)
|
||
|
||
---
|
||
|
||
### **Phase 4.5: Collections Preview Endpoint** (30-45 min)
|
||
|
||
**IMPORTANT: Why this endpoint is necessary**
|
||
|
||
The preview endpoint is **required** for both the web UI custom section builder AND future mobile apps. It allows users to:
|
||
- See what books match their filter rules BEFORE saving
|
||
- Avoid creating incorrect collections
|
||
- Test different rule combinations quickly
|
||
|
||
**Why not client-side preview?**
|
||
- Client-side would require downloading entire library (10,000+ books) to browser
|
||
- Would duplicate 500+ lines of rule evaluation logic in TypeScript
|
||
- Would create maintenance nightmare (keeping Go and TypeScript logic in sync)
|
||
- Risk of client and server evaluating rules differently
|
||
|
||
**This endpoint reuses existing service logic** - the same `collectionService.EvaluateRules()` used by the actual collection creation.
|
||
|
||
**File: `internal/handlers/collections.go`** (MODIFY existing)
|
||
|
||
Add the preview endpoint method:
|
||
|
||
```go
|
||
// PreviewCollection evaluates filter rules and returns matching items without saving
|
||
// Used by: Custom section builder (web UI), future mobile apps
|
||
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
|
||
user := c.Get("user").(database.Users)
|
||
userUUID := uuid.UUID(user.ID.Bytes)
|
||
|
||
var req struct {
|
||
LibraryID string `json:"library_id"`
|
||
Rules []Rule `json:"rules"`
|
||
ManualBookIDs []string `json:"manual_book_ids"`
|
||
Limit int `json:"limit"`
|
||
}
|
||
|
||
if err := c.Bind(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||
}
|
||
|
||
libUUID, err := uuid.Parse(req.LibraryID)
|
||
if err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||
}
|
||
|
||
if req.Limit <= 0 || req.Limit > 100 {
|
||
req.Limit = 20
|
||
}
|
||
|
||
// Get all library items
|
||
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
||
}
|
||
|
||
// Evaluate rules for each item
|
||
var matchedItems []database.MediaItems
|
||
for _, item := range allItems {
|
||
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
|
||
for _, eval := range evaluations {
|
||
if eval.Matches {
|
||
matchedItems = append(matchedItems, item)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add manually selected books
|
||
for _, bookID := range req.ManualBookIDs {
|
||
bookUUID, err := uuid.Parse(bookID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
for _, item := range allItems {
|
||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||
if itemUUID == bookUUID {
|
||
// Check if already in matched items
|
||
alreadyAdded := false
|
||
for _, added := range matchedItems {
|
||
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
|
||
if addedUUID == bookUUID {
|
||
alreadyAdded = true
|
||
break
|
||
}
|
||
}
|
||
if !alreadyAdded {
|
||
matchedItems = append(matchedItems, item)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply limit
|
||
if len(matchedItems) > req.Limit {
|
||
matchedItems = matchedItems[:req.Limit]
|
||
}
|
||
|
||
// Convert to handler types
|
||
bookCards := make([]BookInfo, len(matchedItems))
|
||
for i, item := range matchedItems {
|
||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||
bookCards[i] = BookInfo{
|
||
MediaItemID: itemUUID.String(),
|
||
Title: item.Title,
|
||
Author: textToString(item.Author),
|
||
CoverImagePath: textToString(item.CoverImagePath),
|
||
}
|
||
}
|
||
|
||
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
||
}
|
||
```
|
||
|
||
**Register the route in internal/router/collections.go**:
|
||
|
||
```go
|
||
// Inside registerCollectionsRoutes function
|
||
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||
```
|
||
|
||
**Create Bruno test**:
|
||
|
||
File: `bruno/dashboard/preview-collection.bru`
|
||
|
||
```yaml
|
||
meta:
|
||
name: Preview Collection
|
||
group: Dashboard
|
||
priority: 5
|
||
|
||
post:
|
||
name: Preview collection with filter rules
|
||
description: Test preview endpoint for custom section builder
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Authorization: Bearer {{userToken}}
|
||
Content-Type: application/json
|
||
body: |-
|
||
{
|
||
"library_id": "{{libraryId}}",
|
||
"rules": [
|
||
{
|
||
"id": "rule1",
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Fiction",
|
||
"priority": 1
|
||
}
|
||
],
|
||
"manual_book_ids": [],
|
||
"limit": 20
|
||
}
|
||
tests:
|
||
- name: Status is 200
|
||
assert: response.status.should.equal(200)
|
||
- name: Returns items array
|
||
assert: response.body.data.items.should.be.array
|
||
- name: Items have required fields
|
||
assert: |
|
||
response.body.data.items.should.not.be.empty;
|
||
response.body.data.items[0].should.have.property("media_item_id");
|
||
response.body.data.items[0].should.have.property("title");
|
||
response.body.data.items[0].should.have.property("author");
|
||
response.body.data.items[0].should.have.property("cover_image_path");
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 4.6: Update CreateCollection Endpoint** (30-45 min)
|
||
|
||
**REQUIRED for Custom Section Builder**: The `CreateCollection` endpoint must support adding manual books when creating a collection.
|
||
|
||
**Why this is needed:**
|
||
- Custom Section Builder allows users to select books manually AND use filter rules
|
||
- Both features can be combined (rules + manual selection)
|
||
- Single API call is cleaner than separate create + add operations
|
||
|
||
**File: `internal/handlers/collections.go`** (MODIFY existing)
|
||
|
||
**Step 1: Add `ManualBookIDs` field to `CreateCollectionRequest`**
|
||
|
||
After line 40, add the new field:
|
||
|
||
```go
|
||
type CreateCollectionRequest struct {
|
||
Name string `json:"name" validate:"required"`
|
||
Description string `json:"description"`
|
||
Color string `json:"color"`
|
||
Icon string `json:"icon"`
|
||
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
||
ViewSettings map[string]interface{} `json:"view_settings"`
|
||
ManualBookIDs []string `json:"manual_book_ids" validate:"max=50"` // NEW
|
||
}
|
||
```
|
||
|
||
**Step 2: Update `CreateCollection` handler**
|
||
|
||
Modify the `CreateCollection` function (lines 73-112) to handle manual books:
|
||
|
||
```go
|
||
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
|
||
user := c.Get("user").(database.Users)
|
||
userUUID := uuid.UUID(user.ID.Bytes)
|
||
|
||
var req CreateCollectionRequest
|
||
if err := c.Bind(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
}
|
||
if err := c.Validate(&req); err != nil {
|
||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||
}
|
||
|
||
collection, err := h.collectionService.CreateCollection(
|
||
c.Request().Context(),
|
||
userUUID,
|
||
req.Name,
|
||
req.Description,
|
||
req.Color,
|
||
req.Icon,
|
||
req.AutoAssignRules,
|
||
req.ViewSettings,
|
||
)
|
||
if err != nil {
|
||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
}
|
||
|
||
// NEW: Add manual books if provided
|
||
if len(req.ManualBookIDs) > 0 {
|
||
collectionUUID := uuid.UUID(collection.ID.Bytes)
|
||
addedCount := 0
|
||
|
||
for _, bookIDStr := range req.ManualBookIDs {
|
||
bookID, err := uuid.Parse(bookIDStr)
|
||
if err != nil {
|
||
// Skip invalid book IDs, log error
|
||
c.Logger().Errorf("Invalid book ID %s: %v", bookIDStr, err)
|
||
continue
|
||
}
|
||
|
||
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionUUID, bookID, userUUID)
|
||
if err != nil {
|
||
// Log error but continue adding other books
|
||
c.Logger().Errorf("Failed to add book %s to collection: %v", bookIDStr, err)
|
||
} else {
|
||
addedCount++
|
||
}
|
||
}
|
||
|
||
c.Logger().Infof("Added %d/%d manual books to collection %s", addedCount, len(req.ManualBookIDs), collection.Name)
|
||
}
|
||
|
||
bookCount := int32(0)
|
||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||
"name": collection.Name,
|
||
"description": textToString(collection.Description),
|
||
"color": textToString(collection.Color),
|
||
"icon": textToString(collection.Icon),
|
||
"auto_assign_rules": collection.AutoAssignRules,
|
||
"view_settings": collection.ViewSettings,
|
||
"book_count": bookCount,
|
||
"created_at": collection.CreatedAt.Time.String(),
|
||
})
|
||
}
|
||
```
|
||
|
||
**Key Implementation Details:**
|
||
- ✅ Reuses existing `AddBookToCollection` service method
|
||
- ✅ Validates request (max 50 book IDs)
|
||
- ✅ Returns 400 if more than 50 book IDs provided
|
||
- ✅ Gracefully handles invalid book IDs (skips them, logs error)
|
||
- ✅ Continues adding remaining books if one fails
|
||
- ✅ Backward compatible (field is optional)
|
||
- ✅ No database schema changes needed
|
||
|
||
**Validation Rule:**
|
||
```go
|
||
// Add to validator in main.go (around line 130)
|
||
v.RegisterValidation("max", func(fl validator.FieldLevel) bool {
|
||
field := fl.Field()
|
||
if field.Kind() != reflect.Slice {
|
||
return true
|
||
}
|
||
return field.Len() <= 50
|
||
})
|
||
```
|
||
|
||
**Testing:**
|
||
```bash
|
||
# Verify compilation
|
||
go build ./internal/handlers/...
|
||
|
||
# Manual test with Bruno
|
||
cd bruno/collections
|
||
bru run --env local create-collection-with-manual-books.bru
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 5: Bruno API Tests** (1 hour)
|
||
|
||
**File: `bruno/dashboard/**`** (update existing tests)
|
||
|
||
**Update existing tests** to reflect new field names:
|
||
- ✅ `GET /api/dashboard/sections` - Response now includes unified collections
|
||
- ✅ `PUT /api/dashboard/preferences` - Updated request body:
|
||
```json
|
||
{
|
||
"library_id": "uuid",
|
||
"hidden_collections": ["not-started"],
|
||
"collection_order": ["recently-added", "continue-reading", "recently-read"],
|
||
"items_per_section": 20
|
||
}
|
||
```
|
||
|
||
**Create new test**:
|
||
- ✅ `POST /api/dashboard/restore-system-collection` - Restore specific system collection
|
||
- Request body: `{"collection_name": "continue-reading"}`
|
||
- Three contexts (no user → 401, user → success, admin → success)
|
||
- Verifies specific system collection is reset
|
||
- Test invalid collection_name returns 400
|
||
|
||
Run tests:
|
||
```bash
|
||
cd bruno/dashboard
|
||
bru run --env local
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 6: TypeScript Type Definitions** (30 min)
|
||
|
||
**File: `web/src/types/api.d.ts`** (ADD to existing file)
|
||
|
||
Add these interfaces to the existing `web/src/types/api.d.ts` file:
|
||
|
||
```typescript
|
||
// Dashboard type definitions
|
||
// CRITICAL: Must match Go handler return types EXACTLY
|
||
// Source: handlers.SectionData and handlers.BookInfo in collections.go
|
||
|
||
export interface SectionData {
|
||
id: string;
|
||
is_system: boolean; // Changed from "type" string to match database field
|
||
title: string;
|
||
description: string;
|
||
icon: string;
|
||
items: BookInfo[];
|
||
view_all_url: string;
|
||
priority: number;
|
||
}
|
||
|
||
export interface BookInfo {
|
||
media_item_id: string; // Changed from "id" to match Go struct field
|
||
title: string;
|
||
author: string;
|
||
cover_image_path: string;
|
||
}
|
||
|
||
export interface DashboardPreferences {
|
||
library_id: string;
|
||
hidden_collections: string[];
|
||
collection_order: string[];
|
||
items_per_section: number;
|
||
}
|
||
```
|
||
|
||
**Key Changes**:
|
||
- ✅ `is_system: boolean` matches database `is_system_collection` field (simpler, no conversion)
|
||
- ✅ `media_item_id` matches Go `BookInfo.MediaItemID` field (consistent with existing API)
|
||
- ✅ Uses existing `BookInfo` struct from collections.go
|
||
- ✅ No duplicate type definitions
|
||
- ✅ Added to existing `api.d.ts` file (follows established pattern)
|
||
|
||
---
|
||
|
||
### **Phase 7: Router Registration & Config Setup** (45 min)
|
||
|
||
**CRITICAL: Config struct updates needed in 3 files**
|
||
|
||
The Config struct is used throughout the application and must be updated consistently.
|
||
|
||
**Step 1: Update router.go Config struct** (5 min)
|
||
|
||
**File: `internal/router/router.go`** (MODIFY existing)
|
||
|
||
Add to Config struct (after line 56):
|
||
|
||
```go
|
||
type Config struct {
|
||
Echo *echo.Echo
|
||
Queries *database.Queries
|
||
Cfg *config.Config
|
||
DBPool interface{} // pgxpool.Pool interface
|
||
AuthHandler *handlers.AuthHandler
|
||
LibraryHandler *handlers.LibraryHandler
|
||
DeviceHandler *handlers.DeviceHandler
|
||
MediaHandler *handlers.MediaHandler
|
||
MatchingHandler *handlers.MatchingHandler
|
||
KOReaderHandler *handlers.KOReaderHandler
|
||
WSHandler *handlers.WSHandler
|
||
ConflictHandler *handlers.ConflictHandler
|
||
AnalyticsHandler *handlers.AnalyticsHandler
|
||
QueueHandler *handlers.QueueHandler
|
||
CollectionHandler *handlers.CollectionHandler
|
||
OPDSHandler *handlers.OPDSHandler
|
||
SystemSettingsHandler *handlers.SystemSettingsHandler
|
||
ConnManager *sync.ConnectionManager
|
||
QueueProcessor *sync.SyncQueueProcessor
|
||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||
LoginTracker *ratelimit.LoginAttemptTracker
|
||
ScannerHandler *handlers.Handler
|
||
DashboardService *services.DashboardService // NEW: For dashboard data fetching
|
||
DashboardHandler *handlers.DashboardHandler // NEW: For dashboard API endpoints
|
||
}
|
||
```
|
||
|
||
**Step 2: Update main.go initialization** (10 min)
|
||
|
||
**File: `cmd/server/main.go`** (MODIFY existing)
|
||
|
||
Add after line 123 (after collectionHandler initialization):
|
||
|
||
```go
|
||
// Dashboard service for unified collections architecture
|
||
dashboardService := services.NewDashboardService(queries)
|
||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||
```
|
||
|
||
Add to routerConfig struct (after line 172):
|
||
|
||
```go
|
||
routerConfig := &router.Config{
|
||
Echo: e,
|
||
Queries: queries,
|
||
Cfg: cfg,
|
||
DBPool: dbPool,
|
||
AuthHandler: authHandler,
|
||
LibraryHandler: libraryHandler,
|
||
DeviceHandler: deviceHandler,
|
||
MediaHandler: mediaHandler,
|
||
MatchingHandler: matchingHandler,
|
||
KOReaderHandler: koreaderHandler,
|
||
WSHandler: wsHandler,
|
||
ConflictHandler: conflictHandler,
|
||
AnalyticsHandler: analyticsHandler,
|
||
QueueHandler: queueHandler,
|
||
CollectionHandler: collectionHandler,
|
||
OPDSHandler: opdsHandler,
|
||
SystemSettingsHandler: systemSettingsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||
LoginTracker: loginAttemptTracker,
|
||
DashboardService: dashboardService, // NEW
|
||
DashboardHandler: dashboardHandler, // NEW
|
||
}
|
||
```
|
||
|
||
**Step 3: Update test_helpers.go** (10 min)
|
||
|
||
**File: `cmd/server/tests/test_helpers.go`** (MODIFY existing)
|
||
|
||
Add after line 419 (after opdsHandler initialization):
|
||
|
||
```go
|
||
// Dashboard service for testing
|
||
dashboardService := services.NewDashboardService(queries)
|
||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||
```
|
||
|
||
Add to routerConfig struct (after line 458):
|
||
|
||
```go
|
||
routerConfig := &router.Config{
|
||
Echo: e,
|
||
Queries: queries,
|
||
Cfg: cfg,
|
||
DBPool: dbPool,
|
||
AuthHandler: authHandler,
|
||
LibraryHandler: libraryHandler,
|
||
DeviceHandler: deviceHandler,
|
||
MediaHandler: mediaHandler,
|
||
MatchingHandler: matchingHandler,
|
||
KOReaderHandler: koreaderHandler,
|
||
WSHandler: wsHandler,
|
||
ConflictHandler: conflictHandler,
|
||
AnalyticsHandler: analyticsHandler,
|
||
QueueHandler: queueHandler,
|
||
SystemSettingsHandler: systemSettingsHandler,
|
||
CollectionHandler: collectionHandler,
|
||
OPDSHandler: opdsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||
LoginTracker: loginAttemptTracker,
|
||
DashboardService: dashboardService, // NEW
|
||
DashboardHandler: dashboardHandler, // NEW
|
||
}
|
||
```
|
||
|
||
**Step 4: Create dashboard router file** (20 min)
|
||
|
||
**File: `internal/router/dashboard.go`** (new file)
|
||
|
||
```go
|
||
package router
|
||
|
||
import (
|
||
"bookhoard/internal/handlers"
|
||
"github.com/labstack/echo/v4"
|
||
)
|
||
|
||
func registerDashboardRoutes(cfg *Config) {
|
||
e := cfg.Echo
|
||
|
||
apiGroup := e.Group("/api", cfg.jwtMiddleware)
|
||
|
||
dashboard := apiGroup.Group("/dashboard")
|
||
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
|
||
dashboard.PUT("/preferences", cfg.DashboardHandler.UpdatePreferences)
|
||
dashboard.POST("/restore-system-collection", cfg.DashboardHandler.RestoreSystemCollection)
|
||
}
|
||
```
|
||
|
||
**IMPORTANT: Why both DashboardService AND DashboardHandler in Config?**
|
||
|
||
- **DashboardService**: Used by SSR routes in `frontend.go` to fetch dashboard data (system collections, user collections, user preferences)
|
||
- **DashboardHandler**: Used by API routes in `dashboard.go` to serve JSON endpoints (`/api/dashboard/sections`, `/api/dashboard/preferences`, etc.)
|
||
- **Mobile apps**: Will use API endpoints via DashboardHandler
|
||
- **Web UI**: Uses SSR (DashboardService) for initial load + API (DashboardHandler) for interactions
|
||
|
||
Both are initialized in main.go and passed through Config to avoid creating multiple instances.
|
||
|
||
---
|
||
|
||
### **Phase 8: SSR Template Routes** (1-2 hours)
|
||
|
||
**File: `internal/router/frontend.go`** (MODIFY existing)
|
||
|
||
Update `/dashboard` route to use unified collections:
|
||
```go
|
||
frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
||
user, err := getTemplateUserWithTheme(c, cfg)
|
||
if err != nil {
|
||
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
||
}
|
||
|
||
libraryID := c.QueryParam("library_id")
|
||
if libraryID == "" {
|
||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||
if err == nil && len(libraries) > 0 {
|
||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||
libraryID = libUUID.String()
|
||
}
|
||
}
|
||
|
||
libUUID, _ := uuid.Parse(libraryID)
|
||
userUUID, _ := uuid.Parse(user.ID)
|
||
|
||
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||
|
||
sections, err := cfg.DashboardService.GetDashboardSections(
|
||
c.Request().Context(),
|
||
userUUID,
|
||
libUUID,
|
||
prefs.ItemsPerSection,
|
||
prefs.CollectionOrder,
|
||
prefs.HiddenCollections,
|
||
)
|
||
if err != nil {
|
||
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
|
||
}
|
||
|
||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||
if err != nil {
|
||
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
|
||
}
|
||
|
||
libData := make([]templates.LibraryData, len(libraries))
|
||
for i, lib := range libraries {
|
||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||
libData[i] = templates.LibraryData{
|
||
ID: libUUID.String(),
|
||
Name: lib.Name,
|
||
Description: lib.Description.String,
|
||
TypeName: lib.TypeName,
|
||
}
|
||
}
|
||
|
||
// Convert service types to handler types for template
|
||
sectionData := BuildSections(sections)
|
||
|
||
var buf bytes.Buffer
|
||
err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return c.HTML(http.StatusOK, buf.String())
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 9: Dashboard Template** (2 hours)
|
||
|
||
**File: `templates/dashboard.templ`** (REPLACE existing)
|
||
|
||
Update to use "collection" terminology instead of "section":
|
||
```templ
|
||
package templates
|
||
|
||
import (
|
||
"bookhoard/internal/handlers"
|
||
)
|
||
|
||
templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Dashboard - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<script src="/static/toast.js"></script>
|
||
<script src="/static/api.js"></script>
|
||
<script src="/static/events.js"></script>
|
||
<script src="/static/dashboard.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet">
|
||
</head>
|
||
<body class="theme-{ user.Theme }">
|
||
@Header(user, "/dashboard")
|
||
|
||
<!-- Sticky Library Selector -->
|
||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||
<select id="library-select" name="library_id"
|
||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||
data-action="switch-library">
|
||
for _, lib := range libData {
|
||
if lib.ID == currentLibraryID {
|
||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||
} else {
|
||
<option value={ lib.ID }>{ lib.Name }</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button data-action="open-dashboard-settings"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-secondary);"
|
||
title="Customize Dashboard">
|
||
⚙️
|
||
</button>
|
||
<button data-action="reload-page"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-secondary);"
|
||
title="Refresh">
|
||
🔄
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
||
style="background-color: var(--bg-primary);">
|
||
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Collections Container -->
|
||
<main id="collections-container" class="max-w-7xl mx-auto px-4 py-8">
|
||
for _, section := range sections {
|
||
@CollectionCarousel(section)
|
||
}
|
||
</main>
|
||
|
||
<!-- Dashboard Settings Modal -->
|
||
@DashboardSettingsModal(sections)
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ CollectionCarousel(section handlers.SectionData) {
|
||
<div class="dashboard-collection mb-8"
|
||
data-collection-id={ section.ID }
|
||
data-is-system={ section.IsSystem }>
|
||
<!-- Collection Header -->
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">{ section.Icon }</span>
|
||
<div>
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
|
||
if section.Description != "" {
|
||
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
if section.ViewAllURL != "" {
|
||
<a href={ section.ViewAllURL }
|
||
class="text-sm font-medium hover:underline transition-colors"
|
||
style="color: var(--accent);">
|
||
View All →
|
||
</a>
|
||
}
|
||
</div>
|
||
|
||
<!-- Carousel -->
|
||
<div class="carousel-container relative group">
|
||
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id={ section.ID }
|
||
data-direction="-1"
|
||
aria-label="Scroll left">
|
||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||
</button>
|
||
|
||
<div id="carousel-track-{ section.ID }"
|
||
class="carousel-track flex gap-4 overflow-x-auto
|
||
scroll-smooth snap-x snap-mandatory
|
||
px-12 pb-4"
|
||
style="scrollbar-width: none; -ms-overflow-style: none;">
|
||
for _, item := range section.Items {
|
||
@BookCard(item)
|
||
}
|
||
|
||
if len(section.Items) == 0 {
|
||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||
<p>No items in this collection</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id={ section.ID }
|
||
data-direction="1"
|
||
aria-label="Scroll right">
|
||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ BookCard(item handlers.BookInfo) {
|
||
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
|
||
transition-transform duration-200 hover:scale-105"
|
||
data-action="view-book"
|
||
data-book-id={ item.MediaItemID }
|
||
tabindex="0"
|
||
role="button"
|
||
aria-label={ "View " + item.Title }>
|
||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
|
||
bg-gradient-to-br from-gray-700 to-gray-900">
|
||
if item.CoverImagePath != "" {
|
||
<img src={ item.CoverImagePath }
|
||
alt={ item.Title }
|
||
class="w-full h-full object-cover"
|
||
loading="lazy"
|
||
onerror="this.src='/static/placeholder-book.svg'">
|
||
} else {
|
||
<img src="/static/placeholder-book.svg"
|
||
alt={ item.Title }
|
||
class="w-full h-full object-cover">
|
||
}
|
||
</div>
|
||
|
||
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
||
{ item.Title }
|
||
</h3>
|
||
|
||
if item.Author != "" {
|
||
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
|
||
{ item.Author }
|
||
</p>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
templ DashboardSettingsModal(sections []handlers.SectionData) {
|
||
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7);">
|
||
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
||
style="background-color: var(--bg-secondary);">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||
<button data-action="close-dashboard-settings"
|
||
class="p-2 hover:bg-gray-700 rounded transition-colors">
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||
Drag to reorder collections, toggle visibility with the switch.
|
||
</p>
|
||
|
||
<!-- Draggable Collection List -->
|
||
<div id="collection-list" class="space-y-2 mb-6">
|
||
for _, section := range sections {
|
||
<div class="collection-item flex items-center justify-between p-3 rounded border
|
||
cursor-move select-none"
|
||
data-collection-id={ section.ID }
|
||
data-is-system={ section.IsSystem ? "true" : "false" }
|
||
draggable="true"
|
||
style="background-color: var(--bg-primary); border-color: var(--border);">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||
<span class="text-xl">{ section.Icon }</span>
|
||
<div>
|
||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||
if section.IsSystem {
|
||
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
if section.IsSystem {
|
||
<button data-action="restore-system-collection"
|
||
data-collection-name={ section.ID }
|
||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||
style="border-color: var(--border); color: var(--text-secondary);"
|
||
title="Restore { section.Title } to defaults">
|
||
Restore
|
||
</button>
|
||
}
|
||
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox"
|
||
class="sr-only peer"
|
||
checked
|
||
data-action="toggle-collection-visibility"
|
||
data-collection-id={ section.ID }>
|
||
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
||
peer-checked:bg-blue-600"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Items Per Section Slider -->
|
||
<div class="mb-6">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||
Items per Collection: <span id="items-count-display" class="font-bold">20</span>
|
||
</label>
|
||
<input type="range" min="10" max="50" step="5" value="20"
|
||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||
data-action="update-items-count"
|
||
target="items-count-display">
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3">
|
||
<button data-action="close-dashboard-settings"
|
||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
||
style="border-color: var(--border); color: var(--text-primary);">
|
||
Cancel
|
||
</button>
|
||
<button data-action="save-dashboard-settings"
|
||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
||
style="background-color: var(--accent);">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
```
|
||
|
||
**Key Changes**:
|
||
- ✅ Updated variable names (section → collection)
|
||
- ✅ Uses `IsSystem` boolean instead of `Type` string
|
||
- ✅ Added "System" badge to system collections
|
||
- ✅ Added "Restore System Collections" button
|
||
- ✅ Updated data attributes (data-is-system)
|
||
|
||
---
|
||
|
||
### **Phase 10: TypeScript Implementation** (2-3 hours)
|
||
|
||
**File: `web/src/dashboard.ts`** (new file)
|
||
|
||
```typescript
|
||
// Dashboard functionality with unified collections architecture
|
||
// Procedural/imperative style (no OOP)
|
||
|
||
import type { SectionData, BookInfo, DashboardPreferences } from './types/api';
|
||
|
||
const SCROLL_AMOUNT = 300;
|
||
|
||
function scrollCarousel(collectionId: string, direction: number): void {
|
||
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
|
||
if (!track) return;
|
||
|
||
const scrollAmount = direction * SCROLL_AMOUNT;
|
||
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||
}
|
||
|
||
function openDashboardSettings(): void {
|
||
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
|
||
if (modal) {
|
||
modal.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
function closeDashboardSettings(): void {
|
||
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
|
||
if (modal) {
|
||
modal.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function toggleCollectionVisibility(collectionId: string): void {
|
||
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
|
||
if (checkbox) {
|
||
checkbox.checked = !checkbox.checked;
|
||
}
|
||
}
|
||
|
||
async function saveDashboardSettings(): Promise<void> {
|
||
const collectionList = document.getElementById('collection-list') as HTMLElement;
|
||
if (!collectionList) return;
|
||
|
||
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
|
||
const hiddenCollections: string[] = [];
|
||
const collectionOrder: string[] = [];
|
||
|
||
collectionItems.forEach((item, index) => {
|
||
const collectionId = item.dataset.collectionId;
|
||
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||
|
||
if (collectionId) {
|
||
collectionOrder.push(collectionId);
|
||
if (checkbox && !checkbox.checked) {
|
||
hiddenCollections.push(collectionId);
|
||
}
|
||
}
|
||
});
|
||
|
||
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
|
||
|
||
try {
|
||
const response = await (window as any).api.put('/dashboard/preferences', {
|
||
library_id: new URLSearchParams(window.location.search).get('library_id') || '',
|
||
hidden_collections: hiddenCollections,
|
||
collection_order: collectionOrder,
|
||
items_per_section: parseInt(itemsPerCollection),
|
||
});
|
||
|
||
if (response.ok) {
|
||
(window as any).showToast.success('Dashboard settings saved');
|
||
closeDashboardSettings();
|
||
window.location.reload();
|
||
}
|
||
} catch (error) {
|
||
(window as any).showToast.error('Failed to save settings');
|
||
console.error('Save dashboard settings error:', error);
|
||
}
|
||
}
|
||
|
||
async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise<void> {
|
||
if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await (window as any).api.post('/dashboard/restore-system-collection', {
|
||
collection_name: collectionName,
|
||
});
|
||
|
||
if (response.ok) {
|
||
(window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
|
||
setTimeout(() => window.location.reload(), 1000);
|
||
}
|
||
} catch (error) {
|
||
(window as any).showToast.error('Failed to restore system collection');
|
||
console.error('Restore system collection error:', error);
|
||
}
|
||
}
|
||
|
||
async function switchLibrary(libraryId: string): Promise<void> {
|
||
const container = document.getElementById('collections-container') as HTMLElement;
|
||
const loading = document.getElementById('loading-spinner') as HTMLElement;
|
||
|
||
if (!container || !loading) return;
|
||
|
||
loading.classList.remove('hidden');
|
||
|
||
try {
|
||
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load sections');
|
||
}
|
||
|
||
const data = await response.json();
|
||
renderCollections(data.sections);
|
||
} catch (error) {
|
||
(window as any).showToast.error('Failed to load library');
|
||
console.error('Switch library error:', error);
|
||
} finally {
|
||
loading.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function renderCollections(sections: SectionData[]): void {
|
||
const container = document.getElementById('collections-container') as HTMLElement;
|
||
if (!container) return;
|
||
|
||
container.innerHTML = sections.map(section => `
|
||
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">${section.icon}</span>
|
||
<div>
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
|
||
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ''}
|
||
</div>
|
||
</div>
|
||
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ''}
|
||
</div>
|
||
|
||
<div class="carousel-container relative group">
|
||
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id="${section.id}"
|
||
data-direction="-1"
|
||
aria-label="Scroll left">
|
||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||
</button>
|
||
|
||
<div id="carousel-track-${section.id}"
|
||
class="carousel-track flex gap-4 overflow-x-auto
|
||
scroll-smooth snap-x snap-mandatory
|
||
px-12 pb-4"
|
||
style="scrollbar-width: none; -ms-overflow-style: none;">
|
||
${section.items.length > 0
|
||
? section.items.map(item => renderBookCard(item)).join('')
|
||
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
|
||
}
|
||
</div>
|
||
|
||
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
||
transition-opacity duration-200"
|
||
data-action="scroll-carousel"
|
||
data-collection-id="${section.id}"
|
||
data-direction="1"
|
||
aria-label="Scroll right">
|
||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderBookCard(book: BookInfo): string {
|
||
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
|
||
|
||
return `
|
||
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
|
||
transition-transform duration-200 hover:scale-105"
|
||
data-action="view-book"
|
||
data-book-id="${book.media_item_id}"
|
||
tabindex="0"
|
||
role="button"
|
||
aria-label="View ${book.title}">
|
||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
|
||
bg-gradient-to-br from-gray-700 to-gray-900">
|
||
<img src="${coverUrl}"
|
||
alt="${book.title}"
|
||
class="w-full h-full object-cover"
|
||
loading="lazy"
|
||
onerror="this.src='/static/placeholder-book.svg'">
|
||
</div>
|
||
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
|
||
${book.title}
|
||
</h3>
|
||
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function viewBook(bookId: string): void {
|
||
// TODO: Implement book detail view
|
||
console.log('View book:', bookId);
|
||
}
|
||
|
||
function reloadPage(): void {
|
||
window.location.reload();
|
||
}
|
||
```
|
||
|
||
**Key Changes**:
|
||
- ✅ Updated function names (section → collection)
|
||
- ✅ Added restoreSystemCollection function (per-collection restore)
|
||
- ✅ Updated field names (hidden_collections, collection_order, media_item_id)
|
||
- ✅ Updated data attributes
|
||
- ✅ Uses `is_system` boolean instead of `type` string
|
||
- ✅ Uses `media_item_id` to match Go struct field
|
||
|
||
---
|
||
|
||
### **Phase 10.5: Custom Section Builder** (3-4 hours)
|
||
|
||
**FEATURE OVERVIEW**: Users can create custom dashboard sections by defining filter rules that automatically match books, or manually selecting specific books. This provides "exceeding flexibility" for personalized dashboards.
|
||
|
||
**KEY CAPABILITIES**:
|
||
- 13+ filter fields (title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators)
|
||
- Rule builder with AND/OR logic
|
||
- Live preview functionality
|
||
- Search + multi-select for manual book addition
|
||
- Auto-assign rules with exclusion capability
|
||
|
||
#### 10.5.1 Frontend Route
|
||
|
||
**File: `internal/router/frontend.go`** (MODIFY existing)
|
||
|
||
Add route after the `/dashboard` route:
|
||
|
||
```go
|
||
frontendProtected.GET("/custom-section", func(c echo.Context) error {
|
||
user, err := getTemplateUserWithTheme(c, cfg)
|
||
if err != nil {
|
||
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
||
}
|
||
|
||
userID, _ := uuid.Parse(user.ID)
|
||
|
||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||
if err != nil {
|
||
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
|
||
}
|
||
|
||
libData := make([]templates.LibraryData, len(libraries))
|
||
for i, lib := range libraries {
|
||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||
libData[i] = templates.LibraryData{
|
||
ID: libUUID.String(),
|
||
Name: lib.Name,
|
||
Description: lib.Description.String,
|
||
TypeName: lib.TypeName,
|
||
}
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return c.HTML(http.StatusOK, buf.String())
|
||
})
|
||
```
|
||
|
||
#### 10.5.2 Custom Section Builder Template
|
||
|
||
**File: `templates/custom_section.templ`** (new file)
|
||
|
||
```templ
|
||
package templates
|
||
|
||
import (
|
||
"bookhoard/internal/handlers"
|
||
)
|
||
|
||
templ CustomSectionBuilder(user User, libraries []LibraryData) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Create Custom Section - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<script src="/static/toast.js"></script>
|
||
<script src="/static/api.js"></script>
|
||
<script src="/static/custom-section-builder.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet">
|
||
</head>
|
||
<body class="theme-{ user.Theme }">
|
||
@Header(user, "/custom-section")
|
||
|
||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
|
||
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
||
|
||
<form id="custom-section-form" class="space-y-6">
|
||
<!-- Section Details -->
|
||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||
<input type="text" id="section-name" name="name" required
|
||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||
<input type="text" id="section-icon" name="icon" maxlength="4"
|
||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
placeholder="📚">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||
<textarea id="section-description" name="description" rows="2"
|
||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||
<select id="section-library" name="library_id" required
|
||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||
<option value="">Select a library...</option>
|
||
for _, lib := range libraries {
|
||
<option value={ lib.ID }>{ lib.Name }</option>
|
||
}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Rules -->
|
||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
|
||
<button type="button" id="add-rule-btn"
|
||
class="px-3 py-1 rounded-lg text-sm font-medium"
|
||
style="background-color: var(--accent);">
|
||
+ Add Rule
|
||
</button>
|
||
</div>
|
||
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
|
||
</p>
|
||
|
||
<div id="rules-container" class="space-y-3">
|
||
<!-- Rules will be added here dynamically -->
|
||
</div>
|
||
|
||
<div class="mt-4 flex items-center gap-2">
|
||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
|
||
<select id="match-type" name="match_type"
|
||
class="px-3 py-1 rounded border"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||
<option value="all">ALL rules (AND)</option>
|
||
<option value="any">ANY rule (OR)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manual Book Selection -->
|
||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||
Add specific books to this section. Use the search to find and select multiple books.
|
||
</p>
|
||
|
||
<div class="mb-4">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||
<div class="flex gap-2">
|
||
<input type="text" id="book-search" name="book_search"
|
||
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
placeholder="Search by title or author..."
|
||
autocomplete="off">
|
||
<button type="button" id="search-books-btn"
|
||
class="px-4 py-2 rounded-lg font-medium"
|
||
style="background-color: var(--accent);">
|
||
Search
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
|
||
style="background-color: var(--bg-primary);">
|
||
<!-- Search results will appear here -->
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||
<div id="selected-books" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
|
||
style="border-color: var(--border); background-color: var(--bg-primary);">
|
||
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live Preview -->
|
||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
|
||
<button type="button" id="preview-btn"
|
||
class="px-4 py-2 rounded-lg font-medium"
|
||
style="background-color: var(--accent);">
|
||
Refresh Preview
|
||
</button>
|
||
</div>
|
||
|
||
<div id="preview-container" class="p-4 rounded-lg"
|
||
style="background-color: var(--bg-primary); min-height: 200px;">
|
||
<p class="text-center" style="color: var(--text-secondary);">
|
||
Add filter rules or select books to see a preview of your custom section.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Form Actions -->
|
||
<div class="flex justify-end gap-3">
|
||
<button type="button" id="cancel-btn"
|
||
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
|
||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" id="save-section-btn"
|
||
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
|
||
style="background-color: var(--accent);">
|
||
Save Section
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</main>
|
||
</body>
|
||
</html>
|
||
}
|
||
```
|
||
|
||
#### 10.5.3 Custom Section Builder TypeScript
|
||
|
||
**File: `web/src/custom-section-builder.ts`** (new file)
|
||
|
||
```typescript
|
||
// Custom Section Builder - Procedural/imperative style (no OOP)
|
||
// Provides flexible filter-based and manual book selection for custom dashboard sections
|
||
|
||
import type { BookInfo } from './types/api';
|
||
|
||
// Filter field definitions with operators
|
||
interface FilterField {
|
||
id: string;
|
||
label: string;
|
||
operators: Operator[];
|
||
valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect';
|
||
options?: string[]; // For select/multiselect fields
|
||
}
|
||
|
||
interface Operator {
|
||
id: string;
|
||
label: string;
|
||
requiresValue: boolean;
|
||
}
|
||
|
||
// Filter rule structure
|
||
interface FilterRule {
|
||
id: string;
|
||
field: string;
|
||
operator: string;
|
||
value: string | string[];
|
||
priority: number;
|
||
}
|
||
|
||
// All available filter fields (13+ fields for exceeding flexibility)
|
||
const FILTER_FIELDS: FilterField[] = [
|
||
{
|
||
id: 'title',
|
||
label: 'Title',
|
||
operators: [
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'starts_with', label: 'Starts With', requiresValue: true },
|
||
{ id: 'ends_with', label: 'Ends With', requiresValue: true },
|
||
{ id: 'regex', label: 'Matches Regex', requiresValue: true },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
{
|
||
id: 'author',
|
||
label: 'Author',
|
||
operators: [
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
{
|
||
id: 'genre',
|
||
label: 'Genre',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
||
{ id: 'in', label: 'In', requiresValue: true },
|
||
{ id: 'not_in', label: 'Not In', requiresValue: true },
|
||
],
|
||
valueType: 'select',
|
||
options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'],
|
||
},
|
||
{
|
||
id: 'series',
|
||
label: 'Series',
|
||
operators: [
|
||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
{
|
||
id: 'progress',
|
||
label: 'Reading Progress',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
||
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
|
||
{ id: 'less_than', label: 'Less Than', requiresValue: true },
|
||
{ id: 'between', label: 'Between', requiresValue: true },
|
||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
||
],
|
||
valueType: 'number',
|
||
},
|
||
{
|
||
id: 'rating',
|
||
label: 'Rating',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
||
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
|
||
{ id: 'less_than', label: 'Less Than', requiresValue: true },
|
||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
||
],
|
||
valueType: 'number',
|
||
},
|
||
{
|
||
id: 'date_added',
|
||
label: 'Date Added',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
||
{ id: 'before', label: 'Before', requiresValue: true },
|
||
{ id: 'after', label: 'After', requiresValue: true },
|
||
{ id: 'between', label: 'Between', requiresValue: true },
|
||
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
|
||
],
|
||
valueType: 'date',
|
||
},
|
||
{
|
||
id: 'last_read',
|
||
label: 'Last Read Date',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'before', label: 'Before', requiresValue: true },
|
||
{ id: 'after', label: 'After', requiresValue: true },
|
||
{ id: 'between', label: 'Between', requiresValue: true },
|
||
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
|
||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
||
],
|
||
valueType: 'date',
|
||
},
|
||
{
|
||
id: 'publisher',
|
||
label: 'Publisher',
|
||
operators: [
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
{
|
||
id: 'language',
|
||
label: 'Language',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
||
{ id: 'in', label: 'In', requiresValue: true },
|
||
],
|
||
valueType: 'select',
|
||
options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'],
|
||
},
|
||
{
|
||
id: 'format',
|
||
label: 'Format',
|
||
operators: [
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'in', label: 'In', requiresValue: true },
|
||
],
|
||
valueType: 'select',
|
||
options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'],
|
||
},
|
||
{
|
||
id: 'tags',
|
||
label: 'Tags',
|
||
operators: [
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
{ id: 'not_contains', label: 'Does Not Contain', requiresValue: true },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
{
|
||
id: 'narrators',
|
||
label: 'Narrators (Audiobooks)',
|
||
operators: [
|
||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
||
],
|
||
valueType: 'text',
|
||
},
|
||
];
|
||
|
||
// State management
|
||
let ruleCounter = 0;
|
||
let selectedBooks: Map<string, BookInfo> = new Map();
|
||
let searchTimeout: number | null = null;
|
||
|
||
// Initialize the custom section builder
|
||
function initCustomSectionBuilder(): void {
|
||
const addRuleBtn = document.getElementById('add-rule-btn');
|
||
const previewBtn = document.getElementById('preview-btn');
|
||
const searchBtn = document.getElementById('search-books-btn');
|
||
const bookSearchInput = document.getElementById('book-search');
|
||
const cancelBtn = document.getElementById('cancel-btn');
|
||
const form = document.getElementById('custom-section-form');
|
||
|
||
if (addRuleBtn) {
|
||
addRuleBtn.addEventListener('click', addFilterRule);
|
||
}
|
||
|
||
if (previewBtn) {
|
||
previewBtn.addEventListener('click', loadPreview);
|
||
}
|
||
|
||
if (searchBtn) {
|
||
searchBtn.addEventListener('click', searchBooks);
|
||
}
|
||
|
||
if (bookSearchInput) {
|
||
bookSearchInput.addEventListener('input', onBookSearchInput);
|
||
bookSearchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
searchBooks();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener('click', () => {
|
||
window.location.href = '/dashboard';
|
||
});
|
||
}
|
||
|
||
if (form) {
|
||
form.addEventListener('submit', saveCustomSection);
|
||
}
|
||
}
|
||
|
||
// Add a new filter rule
|
||
function addFilterRule(): void {
|
||
const container = document.getElementById('rules-container');
|
||
if (!container) return;
|
||
|
||
ruleCounter++;
|
||
const ruleId = `rule-${ruleCounter}`;
|
||
|
||
const ruleElement = document.createElement('div');
|
||
ruleElement.className = 'rule-item p-3 rounded border';
|
||
ruleElement.dataset.ruleId = ruleId;
|
||
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
||
|
||
ruleElement.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<select class="field-select flex-1 px-3 py-1 rounded border"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
||
<option value="">Select field...</option>
|
||
${FILTER_FIELDS.map(field => `<option value="${field.id}">${field.label}</option>`).join('')}
|
||
</select>
|
||
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<select class="operator-select flex-1 px-3 py-1 rounded border"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||
disabled>
|
||
<option value="">Select field first...</option>
|
||
</select>
|
||
<input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
|
||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||
placeholder="Enter value...">
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(ruleElement);
|
||
|
||
// Add event listeners
|
||
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
|
||
const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
|
||
const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement;
|
||
|
||
fieldSelect.addEventListener('change', () => onFieldChange(ruleElement));
|
||
removeBtn.addEventListener('click', () => removeFilterRule(ruleId));
|
||
}
|
||
|
||
// Handle field selection change
|
||
function onFieldChange(ruleElement: HTMLElement): void {
|
||
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
|
||
const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
|
||
const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement;
|
||
|
||
const fieldId = fieldSelect.value;
|
||
const field = FILTER_FIELDS.find(f => f.id === fieldId);
|
||
|
||
// Update operators
|
||
operatorSelect.innerHTML = field
|
||
? field.operators.map(op => `<option value="${op.id}">${op.label}</option>`).join('')
|
||
: '<option value="">Select field first...</option>';
|
||
|
||
operatorSelect.disabled = !field;
|
||
|
||
// Handle value input visibility
|
||
if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) {
|
||
valueInput.classList.remove('hidden');
|
||
|
||
if (field.valueType === 'select' && field.options) {
|
||
valueInput.type = 'select'; // Will be replaced with actual select element
|
||
} else if (field.valueType === 'number') {
|
||
valueInput.type = 'number';
|
||
valueInput.step = '0.01';
|
||
} else if (field.valueType === 'date') {
|
||
valueInput.type = 'date';
|
||
} else {
|
||
valueInput.type = 'text';
|
||
}
|
||
} else {
|
||
valueInput.classList.add('hidden');
|
||
}
|
||
|
||
operatorSelect.addEventListener('change', () => {
|
||
const selectedOp = field?.operators.find(op => op.id === operatorSelect.value);
|
||
if (selectedOp?.requiresValue) {
|
||
valueInput.classList.remove('hidden');
|
||
} else {
|
||
valueInput.classList.add('hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Remove a filter rule
|
||
function removeFilterRule(ruleId: string): void {
|
||
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
|
||
if (ruleElement) {
|
||
ruleElement.remove();
|
||
}
|
||
}
|
||
|
||
// Search books with debounce
|
||
function onBookSearchInput(): void {
|
||
if (searchTimeout) {
|
||
clearTimeout(searchTimeout);
|
||
}
|
||
searchTimeout = window.setTimeout(() => {
|
||
searchBooks();
|
||
}, 300);
|
||
}
|
||
|
||
// Search for books
|
||
async function searchBooks(): Promise<void> {
|
||
const searchInput = document.getElementById('book-search') as HTMLInputElement;
|
||
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
|
||
const resultsContainer = document.getElementById('search-results') as HTMLElement;
|
||
|
||
const query = searchInput?.value.trim();
|
||
const libraryId = librarySelect?.value;
|
||
|
||
if (!query || !libraryId) {
|
||
if (resultsContainer) resultsContainer.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to search books');
|
||
}
|
||
|
||
const data = await response.json();
|
||
displaySearchResults(data.books || []);
|
||
} catch (error) {
|
||
console.error('Search books error:', error);
|
||
(window as any).showToast?.error('Failed to search books');
|
||
}
|
||
}
|
||
|
||
// Display search results
|
||
function displaySearchResults(books: BookInfo[]): void {
|
||
const resultsContainer = document.getElementById('search-results') as HTMLElement;
|
||
if (!resultsContainer) return;
|
||
|
||
if (books.length === 0) {
|
||
resultsContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
|
||
} else {
|
||
resultsContainer.innerHTML = books.map(book => `
|
||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||
data-book-id="${book.media_item_id}"
|
||
onclick="addBookToSelection('${book.media_item_id}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
|
||
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
|
||
alt="${escapeHtml(book.title)}"
|
||
class="w-10 h-15 object-cover rounded">
|
||
<div class="flex-1">
|
||
<p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</p>
|
||
<p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
|
||
</div>
|
||
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
resultsContainer.classList.remove('hidden');
|
||
}
|
||
|
||
// Add book to selection (global function for onclick)
|
||
(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void {
|
||
if (selectedBooks.has(bookId)) {
|
||
(window as any).showToast?.warning('Book already selected');
|
||
return;
|
||
}
|
||
|
||
selectedBooks.set(bookId, {
|
||
media_item_id: bookId,
|
||
title: title,
|
||
author: author,
|
||
cover_image_path: '',
|
||
});
|
||
|
||
updateSelectedBooksDisplay();
|
||
};
|
||
|
||
// Remove book from selection (global function for onclick)
|
||
(window as any).removeBookFromSelection = function(bookId: string): void {
|
||
selectedBooks.delete(bookId);
|
||
updateSelectedBooksDisplay();
|
||
};
|
||
|
||
// Update the selected books display
|
||
function updateSelectedBooksDisplay(): void {
|
||
const container = document.getElementById('selected-books') as HTMLElement;
|
||
if (!container) return;
|
||
|
||
if (selectedBooks.size === 0) {
|
||
container.innerHTML = '<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
|
||
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||
style="background-color: var(--accent);">
|
||
<span>${escapeHtml(book.title)}</span>
|
||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||
class="hover:opacity-70">×</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Load live preview of the custom section
|
||
async function loadPreview(): Promise<void> {
|
||
const previewContainer = document.getElementById('preview-container') as HTMLElement;
|
||
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
|
||
const libraryId = librarySelect?.value;
|
||
|
||
if (!libraryId) {
|
||
(window as any).showToast?.error('Please select a library first');
|
||
return;
|
||
}
|
||
|
||
const rules = gatherFilterRules();
|
||
const manualBookIds = Array.from(selectedBooks.keys());
|
||
|
||
previewContainer.innerHTML = '<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
|
||
|
||
try {
|
||
const response = await (window as any).api.post('/collections/preview', {
|
||
library_id: libraryId,
|
||
rules: rules,
|
||
manual_book_ids: manualBookIds,
|
||
limit: 20,
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
displayPreview(data.items || []);
|
||
} else {
|
||
throw new Error('Failed to load preview');
|
||
}
|
||
} catch (error) {
|
||
console.error('Preview error:', error);
|
||
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
|
||
}
|
||
}
|
||
|
||
// Gather all filter rules from the form
|
||
function gatherFilterRules(): FilterRule[] {
|
||
const container = document.getElementById('rules-container') as HTMLElement;
|
||
if (!container) return [];
|
||
|
||
const ruleElements = container.querySelectorAll('.rule-item');
|
||
const rules: FilterRule[] = [];
|
||
|
||
ruleElements.forEach((element, index) => {
|
||
const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement;
|
||
const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement;
|
||
const valueInput = element.querySelector('.value-input') as HTMLInputElement;
|
||
|
||
if (fieldSelect.value && operatorSelect.value) {
|
||
rules.push({
|
||
id: `rule-${index}`,
|
||
field: fieldSelect.value,
|
||
operator: operatorSelect.value,
|
||
value: valueInput.value,
|
||
priority: index,
|
||
});
|
||
}
|
||
});
|
||
|
||
return rules;
|
||
}
|
||
|
||
// Display preview results
|
||
function displayPreview(items: BookInfo[]): void {
|
||
const previewContainer = document.getElementById('preview-container') as HTMLElement;
|
||
if (!previewContainer) return;
|
||
|
||
if (items.length === 0) {
|
||
previewContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
|
||
return;
|
||
}
|
||
|
||
previewContainer.innerHTML = `
|
||
<div class="flex gap-4 overflow-x-auto pb-4">
|
||
${items.map(item => `
|
||
<div class="flex-shrink-0 w-32">
|
||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
||
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
|
||
alt="${escapeHtml(item.title)}"
|
||
class="w-full h-full object-cover">
|
||
</div>
|
||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||
${escapeHtml(item.title)}
|
||
</h3>
|
||
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(item.author)}</p>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
|
||
${items.length} item${items.length !== 1 ? 's' : ''} will be shown
|
||
</p>
|
||
`;
|
||
}
|
||
|
||
// Save the custom section
|
||
async function saveCustomSection(event: Event): Promise<void> {
|
||
event.preventDefault();
|
||
|
||
const formData = new FormData(event.target as HTMLFormElement);
|
||
const libraryId = formData.get('library_id') as string;
|
||
const name = formData.get('name') as string;
|
||
const icon = formData.get('icon') as string;
|
||
const description = formData.get('description') as string;
|
||
const matchType = (document.getElementById('match-type') as HTMLSelectElement).value;
|
||
|
||
if (!libraryId || !name) {
|
||
(window as any).showToast?.error('Please fill in required fields');
|
||
return;
|
||
}
|
||
|
||
const rules = gatherFilterRules();
|
||
const manualBookIds = Array.from(selectedBooks.keys());
|
||
|
||
if (rules.length === 0 && manualBookIds.length === 0) {
|
||
(window as any).showToast?.error('Please add filter rules or select books');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await (window as any).api.post('/collections', {
|
||
library_id: libraryId,
|
||
name: name,
|
||
icon: icon,
|
||
description: description,
|
||
show_on_dashboard: true,
|
||
auto_assign_rules: JSON.stringify(rules),
|
||
manual_book_ids: manualBookIds,
|
||
match_type: matchType,
|
||
});
|
||
|
||
if (response.ok) {
|
||
(window as any).showToast?.success('Custom section created successfully');
|
||
setTimeout(() => {
|
||
window.location.href = '/dashboard';
|
||
}, 1000);
|
||
} else {
|
||
throw new Error('Failed to save custom section');
|
||
}
|
||
} catch (error) {
|
||
console.error('Save custom section error:', error);
|
||
(window as any).showToast?.error('Failed to save custom section');
|
||
}
|
||
}
|
||
|
||
// Utility function to escape HTML
|
||
function escapeHtml(text: string): string {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Initialize on DOM ready
|
||
document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);
|
||
```
|
||
|
||
#### 10.5.4 Collections Preview Endpoint
|
||
|
||
**IMPORTANT: This endpoint is REQUIRED for both web UI and mobile apps**
|
||
|
||
The preview endpoint allows users to:
|
||
- **Web UI**: Test filter rules before saving custom sections
|
||
- **Mobile apps**: Preview collections before creation (future feature)
|
||
- **API consumers**: Validate rules without creating collections
|
||
|
||
**Reuses existing service logic** - no code duplication, single source of truth.
|
||
|
||
**Route already registered**: POST `/api/collections/preview` (added in Phase 4.5)
|
||
|
||
**Handler method already implemented**: `PreviewCollection` in `internal/handlers/collections.go` (added in Phase 4.5)
|
||
|
||
**Bruno test already created**: `bruno/dashboard/preview-collection.bru` (added in Phase 4.5)
|
||
|
||
No additional work needed - this section references the preview endpoint added earlier in the plan.
|
||
|
||
---
|
||
|
||
### **Phase 10.6: Final Integration & Testing** (1 hour)
|
||
|
||
**CRITICAL**: Before proceeding to Phase 11 (Unit Tests), verify all components integrate correctly.
|
||
|
||
#### Verification Checklist
|
||
|
||
**Build Verification:**
|
||
- [ ] TypeScript modules compile: `npm run build:ts`
|
||
- Verify: `web/static/dashboard.js` exists
|
||
- Verify: `web/static/custom-section-builder.js` exists
|
||
- Check for no compilation errors
|
||
- [ ] Templates generate successfully: `templ generate --path templates`
|
||
- Verify: `templates/dashboard_templ.go` exists
|
||
- Verify: `templates/custom_section_templ.go` exists
|
||
- [ ] Go build succeeds: `go build ./cmd/server`
|
||
- Verify: No compilation errors
|
||
- Check all imports resolve correctly
|
||
|
||
**Bruno API Tests:**
|
||
- [ ] Dashboard endpoints pass: `cd bruno/dashboard && bru run --env local`
|
||
- get-sections-success.bru
|
||
- get-sections-missing-library-id.bru
|
||
- get-sections-unauthorized.bru
|
||
- put-preferences-success.bru
|
||
- restore-system-collection-success.bru
|
||
- restore-system-collection-invalid-name.bru
|
||
- [ ] Collections preview tests pass:
|
||
- preview-collection-success.bru
|
||
- preview-collection-manual-selection.bru
|
||
- preview-collection-combined.bru
|
||
- preview-collection-invalid-library.bru
|
||
|
||
**Manual Integration Testing:**
|
||
- [ ] Dashboard loads successfully
|
||
- Navigate to `/dashboard?library_id=<valid_uuid>`
|
||
- Verify 4 system collections appear
|
||
- Verify collections show books correctly
|
||
- [ ] Library switching works
|
||
- Select different library from dropdown
|
||
- Verify page updates without full reload
|
||
- Verify loading spinner appears/disappears
|
||
- [ ] Dashboard settings modal functions
|
||
- Open settings modal
|
||
- Toggle collection visibility
|
||
- Drag to reorder collections
|
||
- Save preferences
|
||
- Verify changes persist on page reload
|
||
- [ ] System collection restore works
|
||
- Customize a system collection (hide it)
|
||
- Click "Restore" button
|
||
- Confirm restoration
|
||
- Verify collection reappears with defaults
|
||
- [ ] Custom section builder works end-to-end
|
||
- Navigate to `/custom-section`
|
||
- Add filter rules
|
||
- Search and select books manually
|
||
- Click "Refresh Preview"
|
||
- Verify preview shows matching books
|
||
- Save custom section
|
||
- Verify section appears on dashboard
|
||
|
||
**Type Safety Verification:**
|
||
- [ ] API responses match TypeScript types
|
||
- Check `is_system` is boolean (not string)
|
||
- Check `media_item_id` field exists (not `id`)
|
||
- Verify field names match (`hidden_collections`, `collection_order`)
|
||
- [ ] No TypeScript type errors
|
||
- Check browser console for type errors
|
||
- Verify all API calls use correct field names
|
||
|
||
**Database Verification:**
|
||
- [ ] System collections exist
|
||
```sql
|
||
SELECT name, query_type, priority, is_system_collection
|
||
FROM collections
|
||
WHERE user_id IS NULL;
|
||
```
|
||
Should return 4 rows
|
||
- [ ] User preferences table exists
|
||
```sql
|
||
\d user_dashboard_preferences
|
||
```
|
||
Verify all columns present
|
||
|
||
**Performance Smoke Test:**
|
||
- [ ] Dashboard loads within 2 seconds
|
||
- Test with library containing 100+ items
|
||
- Verify carousel scrolling is smooth
|
||
- Check no memory leaks in browser console
|
||
|
||
**Error Handling Verification:**
|
||
- [ ] Invalid library_id shows error
|
||
- [ ] Unauthorized requests return 401
|
||
- [ ] Network errors show toast notifications
|
||
- [ ] Empty collections display "No items" message
|
||
|
||
#### Troubleshooting Common Issues
|
||
|
||
**Issue: Collections not appearing**
|
||
- Check `show_on_dashboard = true` in database
|
||
- Verify user hasn't hidden collection in preferences
|
||
- Check browser console for JavaScript errors
|
||
|
||
**Issue: TypeScript compilation fails**
|
||
- Verify all type definitions in `web/src/types/api.d.ts`
|
||
- Check import statements use correct paths
|
||
- Ensure no missing dependencies in `package.json`
|
||
|
||
**Issue: Templates don't generate**
|
||
- Verify template syntax is correct
|
||
- Check for unclosed tags
|
||
- Run `go install github.com/a-h/templ/cmd/templ@latest` to update templ
|
||
|
||
**Issue: Bruno tests fail**
|
||
- Verify server is running
|
||
- Check environment variables in `bruno/.env`
|
||
- Ensure test database has seed data
|
||
|
||
#### Success Criteria
|
||
|
||
Phase 10.6 is complete when:
|
||
- ✅ All builds succeed (Go, TypeScript, Templates)
|
||
- ✅ All Bruno tests pass
|
||
- ✅ Manual testing confirms features work
|
||
- ✅ No console errors in browser
|
||
- ✅ Dashboard loads within 2 seconds
|
||
- ✅ Custom section builder creates sections successfully
|
||
- ✅ System collection restore works
|
||
|
||
**IMPORTANT**: Do not proceed to Phase 11 until all verification items pass. Integration issues discovered here are easier to fix before writing comprehensive unit tests.
|
||
|
||
---
|
||
|
||
### **Phase 11: Unit and Integration Tests** (3-4 hours)
|
||
|
||
#### 11.1 Unit Tests for Dashboard Service
|
||
|
||
**File: `internal/services/dashboard_service_test.go`** (new file)
|
||
|
||
**ARCHITECTURE NOTE**: Tests verify service returns database types correctly
|
||
|
||
```go
|
||
package services_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"bookhoard/internal/services"
|
||
"bookhoard/internal/database"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
)
|
||
|
||
func TestDashboardService_GetSystemCollectionsForDashboard(t *testing.T) {
|
||
// Setup test database and service
|
||
db := setupTestDB(t)
|
||
defer db.Close()
|
||
service := services.NewDashboardService(db)
|
||
|
||
userID := uuid.New()
|
||
libraryID := uuid.New()
|
||
|
||
// Create test media items
|
||
item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
|
||
item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")
|
||
|
||
// Create reading progress for item1 (continue-reading)
|
||
createTestReadingProgress(t, db, userID, item1.ID, 0.5)
|
||
|
||
// Execute
|
||
collections, items, err := service.GetSystemCollectionsForDashboard(
|
||
context.Background(),
|
||
userID,
|
||
libraryID,
|
||
20,
|
||
)
|
||
|
||
// Verify
|
||
assert.NoError(t, err)
|
||
assert.NotNil(t, collections)
|
||
assert.NotNil(t, items)
|
||
|
||
// Should have system collections
|
||
assert.Greater(t, len(collections), 0, "Should return system collections")
|
||
|
||
// Verify collections are database types
|
||
for _, coll := range collections {
|
||
assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
|
||
assert.False(t, coll.UserID.Valid, "System collections should have NULL user_id")
|
||
}
|
||
|
||
// Verify items are database types
|
||
for _, item := range items {
|
||
assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
|
||
}
|
||
}
|
||
|
||
func TestDashboardService_GetUserCollectionsForDashboard(t *testing.T) {
|
||
// Setup test database and service
|
||
db := setupTestDB(t)
|
||
defer db.Close()
|
||
service := services.NewDashboardService(db)
|
||
|
||
userID := uuid.New()
|
||
libraryID := uuid.New()
|
||
|
||
// Create test collection
|
||
collectionID := createTestCollection(t, db, userID, "My Collection", true)
|
||
|
||
// Add items to collection
|
||
item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
|
||
item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")
|
||
addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})
|
||
|
||
// Execute
|
||
collections, items, err := service.GetUserCollectionsForDashboard(
|
||
context.Background(),
|
||
userID,
|
||
libraryID,
|
||
20,
|
||
)
|
||
|
||
// Verify
|
||
assert.NoError(t, err)
|
||
assert.NotNil(t, collections)
|
||
assert.NotNil(t, items)
|
||
|
||
// Should have user collections
|
||
assert.Greater(t, len(collections), 0, "Should return user collections")
|
||
|
||
// Verify collections are database types
|
||
for _, coll := range collections {
|
||
assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
|
||
assert.True(t, coll.UserID.Valid, "User collections should have user_id set")
|
||
assert.Equal(t, userID, uuid.UUID(coll.UserID.Bytes), "Should belong to user")
|
||
}
|
||
|
||
// Verify items are database types
|
||
for _, item := range items {
|
||
assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
|
||
}
|
||
}
|
||
|
||
func TestDashboardService_AutoAssignRules(t *testing.T) {
|
||
// Setup
|
||
db := setupTestDB(t)
|
||
defer db.Close()
|
||
service := services.NewDashboardService(db)
|
||
|
||
userID := uuid.New()
|
||
libraryID := uuid.New()
|
||
|
||
// Create collection with auto-assign rules (Sci-Fi genre)
|
||
collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
|
||
{
|
||
ID: "rule1",
|
||
Field: "genre",
|
||
Operator: "equals",
|
||
Value: "Sci-Fi",
|
||
Priority: 5,
|
||
},
|
||
})
|
||
|
||
// Create test items (one Sci-Fi, one Fiction)
|
||
item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
|
||
item2 := createTestMediaItemWithGenre(t, db, libraryID, "Pride and Prejudice", "Jane Austen", "Fiction")
|
||
|
||
// Execute
|
||
collections, items, err := service.GetUserCollectionsForDashboard(
|
||
context.Background(),
|
||
userID,
|
||
libraryID,
|
||
20,
|
||
)
|
||
|
||
// Verify
|
||
assert.NoError(t, err)
|
||
assert.Greater(t, len(items), 0, "Should have matched items")
|
||
|
||
// Should have Dune (Sci-Fi) but not Pride and Prejudice (Fiction)
|
||
itemIDs := make([]uuid.UUID, len(items))
|
||
for i, item := range items {
|
||
itemIDs[i] = uuid.UUID(item.ID.Bytes)
|
||
}
|
||
|
||
assert.Contains(t, itemIDs, item1.ID, "Should include Sci-Fi book")
|
||
assert.NotContains(t, itemIDs, item2.ID, "Should not include Fiction book")
|
||
}
|
||
|
||
func TestDashboardService_ExcludedItems(t *testing.T) {
|
||
// Setup
|
||
db := setupTestDB(t)
|
||
defer db.Close()
|
||
service := services.NewDashboardService(db)
|
||
|
||
userID := uuid.New()
|
||
libraryID := uuid.New()
|
||
|
||
// Create collection with auto-assign rules
|
||
collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
|
||
{Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5},
|
||
})
|
||
|
||
// Create Sci-Fi books
|
||
item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
|
||
item2 := createTestMediaItemWithGenre(t, db, libraryID, "Foundation", "Isaac Asimov", "Sci-Fi")
|
||
|
||
// Manually add both to collection
|
||
addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})
|
||
|
||
// Exclude item1 from auto-assign
|
||
excludeItemFromCollection(t, db, collectionID, item1.ID)
|
||
|
||
// Execute
|
||
collections, items, err := service.GetUserCollectionsForDashboard(
|
||
context.Background(),
|
||
userID,
|
||
libraryID,
|
||
20,
|
||
)
|
||
|
||
// Verify
|
||
assert.NoError(t, err)
|
||
|
||
// Should have item2 but not item1 (excluded)
|
||
itemIDs := make([]uuid.UUID, len(items))
|
||
for i, item := range items {
|
||
itemIDs[i] = uuid.UUID(item.ID.Bytes)
|
||
}
|
||
|
||
assert.NotContains(t, itemIDs, item1.ID, "Should not include excluded item")
|
||
assert.Contains(t, itemIDs, item2.ID, "Should include non-excluded item")
|
||
}
|
||
```
|
||
|
||
#### 11.2 Unit Tests for Dashboard Handler
|
||
|
||
**File: `internal/handlers/dashboard_test.go`** (new file)
|
||
|
||
**ARCHITECTURE NOTE**: Tests verify handler converts database types to API types correctly
|
||
|
||
```go
|
||
package handlers_test
|
||
|
||
import (
|
||
"testing"
|
||
"bookhoard/internal/handlers"
|
||
"bookhoard/internal/database"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
)
|
||
|
||
func TestBuildSectionsFromDB_ConvertsDatabaseTypes(t *testing.T) {
|
||
// Create test database collections (system and user)
|
||
systemCollections := []database.Collections{
|
||
{
|
||
Name: "continue-reading",
|
||
IsSystemCollection: true,
|
||
Priority: pgtype.Int4{Int32: 1, Valid: true},
|
||
QueryType: pgtype.Text{String: "continue-reading", Valid: true},
|
||
Description: pgtype.Text{String: "Books you're reading", Valid: true},
|
||
Icon: pgtype.Text{String: "📖", Valid: true},
|
||
},
|
||
}
|
||
|
||
userCollections := []database.Collections{
|
||
{
|
||
Name: "My Favorites",
|
||
UserID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
|
||
Priority: pgtype.Int4{Int32: 10, Valid: true},
|
||
Description: pgtype.Text{String: "My favorite books", Valid: true},
|
||
Icon: pgtype.Text{String: "⭐", Valid: true},
|
||
},
|
||
}
|
||
|
||
// Create test media items
|
||
mediaItems := []database.MediaItems{
|
||
{
|
||
ID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
|
||
Title: "Test Book",
|
||
Author: pgtype.Text{String: "Test Author", Valid: true},
|
||
CoverImagePath: pgtype.Text{String: "/path/to/cover.jpg", Valid: true},
|
||
},
|
||
}
|
||
|
||
// Create test preferences
|
||
prefs := database.UserDashboardPreferences{
|
||
HiddenCollections: []string{},
|
||
CollectionOrder: []string{},
|
||
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
||
}
|
||
|
||
// Execute conversion
|
||
sections := handlers.BuildSectionsFromDB(
|
||
systemCollections,
|
||
userCollections,
|
||
mediaItems,
|
||
mediaItems,
|
||
prefs,
|
||
)
|
||
|
||
// Verify conversion to handler types
|
||
assert.NotNil(t, sections)
|
||
assert.Greater(t, len(sections), 0, "Should have sections")
|
||
|
||
// Verify SectionData type (handler type, not database type)
|
||
for _, section := range sections {
|
||
assert.IsType(t, handlers.SectionData{}, section, "Should return handler.SectionData type")
|
||
|
||
// Verify string conversion (pgtype.Text → string)
|
||
assert.IsType(t, "", section.Title, "Title should be string, not pgtype.Text")
|
||
assert.IsType(t, "", section.Description, "Description should be string, not pgtype.Text")
|
||
assert.IsType(t, "", section.Icon, "Icon should be string, not pgtype.Text")
|
||
|
||
// Verify boolean conversion (database field → JSON field)
|
||
assert.IsType(t, false, section.IsSystem, "IsSystem should be boolean")
|
||
|
||
// Verify items are BookInfo (handler type)
|
||
for _, item := range section.Items {
|
||
assert.IsType(t, handlers.BookInfo{}, item, "Items should be handler.BookInfo type")
|
||
|
||
// Verify MediaItemID field (not "id")
|
||
assert.IsType(t, "", item.MediaItemID, "Should have MediaItemID field")
|
||
|
||
// Verify string conversion
|
||
assert.IsType(t, "", item.Title, "Title should be string")
|
||
assert.IsType(t, "", item.Author, "Author should be string")
|
||
assert.IsType(t, "", item.CoverImagePath, "CoverImagePath should be string")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBuildSectionsFromDB_FilterHiddenCollections(t *testing.T) {
|
||
// Create test data
|
||
collections := createTestCollections()
|
||
items := createTestMediaItems()
|
||
prefs := database.UserDashboardPreferences{
|
||
HiddenCollections: []string{"not-started"},
|
||
CollectionOrder: []string{},
|
||
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
||
}
|
||
|
||
// Execute
|
||
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
|
||
|
||
// Verify filtering
|
||
for _, section := range sections {
|
||
assert.NotEqual(t, "not-started", section.ID, "Should filter out hidden collection")
|
||
}
|
||
}
|
||
|
||
func TestBuildSectionsFromDB_ReorderCollections(t *testing.T) {
|
||
// Create test data
|
||
collections := createTestCollections()
|
||
items := createTestMediaItems()
|
||
prefs := database.UserDashboardPreferences{
|
||
HiddenCollections: []string{},
|
||
CollectionOrder: []string{"not-started", "recently-added", "continue-reading"},
|
||
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
||
}
|
||
|
||
// Execute
|
||
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
|
||
|
||
// Verify order
|
||
assert.Equal(t, "not-started", sections[0].ID, "Should reorder to match custom order")
|
||
assert.Equal(t, "recently-added", sections[1].ID)
|
||
assert.Equal(t, "continue-reading", sections[2].ID)
|
||
}
|
||
|
||
func TestBuildSectionsFromDB_SortByPriority(t *testing.T) {
|
||
// Create test data with different priorities
|
||
collections := createTestCollectionsWithPriorities()
|
||
items := createTestMediaItems()
|
||
prefs := database.UserDashboardPreferences{
|
||
HiddenCollections: []string{},
|
||
CollectionOrder: []string{}, // Empty = use priority sort
|
||
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
||
}
|
||
|
||
// Execute
|
||
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
|
||
|
||
// Verify priority sort
|
||
for i := 0; i < len(sections)-1; i++ {
|
||
assert.LessOrEqual(t, sections[i].Priority, sections[i+1].Priority, "Should sort by priority ascending")
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 11.2 Integration Tests
|
||
|
||
**File: `internal/handlers/dashboard_integration_test.go`** (new file)
|
||
|
||
**ARCHITECTURE NOTE**: Integration tests verify end-to-end flow from service → handler → JSON
|
||
|
||
```go
|
||
package handlers_test
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"bytes"
|
||
|
||
"bookhoard/internal/handlers"
|
||
"bookhoard/internal/database"
|
||
"bookhoard/internal/test_helpers"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"github.com/stretchr/testify/suite"
|
||
)
|
||
|
||
type DashboardIntegrationTestSuite struct {
|
||
suite.Suite
|
||
test_helpers.TestSuite
|
||
handler *handlers.DashboardHandler
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) SetupSuite() {
|
||
s.TestSuite.SetupSuite()
|
||
s.handler = handlers.NewDashboardHandler(s.Queries)
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) TearDownSuite() {
|
||
s.TestSuite.TearDownSuite()
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||
// Setup: Create user, library, and media items
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
|
||
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
|
||
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction")
|
||
|
||
// Create reading progress
|
||
s.CreateReadingProgress(user.ID, item1.ID, 0.5) // Continue Reading
|
||
s.CreateReadingProgress(user.ID, item2.ID, 1.0) // Recently Read
|
||
// item3 has no progress → Not Started
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Execute API call
|
||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.GetSections(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
// Verify HTTP response
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
// Parse JSON response
|
||
var response map[string]interface{}
|
||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||
require.NoError(s.T(), err)
|
||
|
||
sections := response["sections"].([]interface{})
|
||
assert.Len(s.T(), sections, 4, "Should have 4 system collections")
|
||
|
||
// Verify response structure matches handler types
|
||
sectionMap := make(map[string]map[string]interface{})
|
||
for _, sec := range sections {
|
||
section := sec.(map[string]interface{})
|
||
sectionMap[section["id"].(string)] = section
|
||
|
||
// Verify field types (JSON serialization of handler types)
|
||
assert.IsType(s.T(), false, section["is_system"], "is_system should be boolean")
|
||
assert.IsType(s.T(), "", section["title"], "title should be string")
|
||
assert.IsType(s.T(), "", section["description"], "description should be string")
|
||
assert.IsType(s.T(), "", section["icon"], "icon should be string")
|
||
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
|
||
}
|
||
|
||
// Verify system collections
|
||
continueReading := sectionMap["continue-reading"]
|
||
require.NotNil(s.T(), continueReading)
|
||
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
|
||
|
||
items := continueReading["items"].([]interface{})
|
||
assert.Len(s.T(), items, 1, "Continue Reading should have 1 item")
|
||
|
||
// Verify book item structure (BookInfo handler type)
|
||
firstBook := items[0].(map[string]interface{})
|
||
assert.Contains(s.T(), firstBook, "media_item_id", "Should have media_item_id field")
|
||
assert.NotContains(s.T(), firstBook, "id", "Should NOT have 'id' field")
|
||
assert.IsType(s.T(), "", firstBook["media_item_id"], "media_item_id should be string")
|
||
assert.IsType(s.T(), "", firstBook["title"], "title should be string")
|
||
assert.IsType(s.T(), "", firstBook["author"], "author should be string")
|
||
|
||
// Verify other collections
|
||
recentlyRead := sectionMap["recently-read"]
|
||
require.NotNil(s.T(), recentlyRead)
|
||
items = recentlyRead["items"].([]interface{})
|
||
assert.Len(s.T(), items, 1, "Recently Read should have 1 item")
|
||
|
||
notStarted := sectionMap["not-started"]
|
||
require.NotNil(s.T(), notStarted)
|
||
items = notStarted["items"].([]interface{})
|
||
assert.Len(s.T(), items, 1, "Not Started should have 1 item")
|
||
|
||
recentlyAdded := sectionMap["recently-added"]
|
||
require.NotNil(s.T(), recentlyAdded)
|
||
items = recentlyAdded["items"].([]interface{})
|
||
assert.Len(s.T(), items, 3, "Recently Added should have 3 items")
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) TestGetSections_WithUserCollections() {
|
||
// Setup: Create user with custom collection
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
// Create user collection with auto-assign rules
|
||
collectionID := s.CreateCollectionWithRules(user.ID, []map[string]interface{}{
|
||
{
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Fiction",
|
||
"priority": 5,
|
||
},
|
||
})
|
||
|
||
// Create test items
|
||
item1 := s.CreateTestMediaItem(library.ID, "Fiction Book", "Author 1", "Fiction")
|
||
item2 := s.CreateTestMediaItem(library.ID, "Sci-Fi Book", "Author 2", "Sci-Fi")
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Execute
|
||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.GetSections(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
sections := response["sections"].([]interface{})
|
||
|
||
// Should have system collections + user collection
|
||
assert.Greater(s.T(), len(sections), 4, "Should have system + user collections")
|
||
|
||
// Find user collection
|
||
var userCollection map[string]interface{}
|
||
for _, sec := range sections {
|
||
section := sec.(map[string]interface{})
|
||
if section["id"].(string) == "My Collection" {
|
||
userCollection = section
|
||
break
|
||
}
|
||
}
|
||
|
||
require.NotNil(s.T(), userCollection, "Should find user collection")
|
||
assert.False(s.T(), userCollection["is_system"].(bool), "User collection should not be system")
|
||
|
||
items := userCollection["items"].([]interface{})
|
||
assert.Greater(s.T(), len(items), 0, "User collection should have items from auto-assign")
|
||
|
||
// Verify Fiction Book is included, Sci-Fi Book is not
|
||
itemTitles := make([]string, len(items))
|
||
for i, item := range items {
|
||
item := item.(map[string]interface{})
|
||
itemTitles[i] = item["title"].(string)
|
||
}
|
||
|
||
assert.Contains(s.T(), itemTitles, "Fiction Book", "Should include Fiction book")
|
||
assert.NotContains(s.T(), itemTitles, "Sci-Fi Book", "Should not include Sci-Fi book")
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection() {
|
||
user := s.CreateTestUser()
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Create a user-owned copy of a system collection
|
||
collName := "continue-reading"
|
||
_, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{
|
||
UserID: pgtype.UUID{Bytes: user.ID, Valid: true},
|
||
Name: collName,
|
||
Description: pgtype.Text{String: "User modified version", Valid: true},
|
||
IsSystemCollection: true,
|
||
})
|
||
require.NoError(s.T(), err)
|
||
|
||
// Test restore
|
||
reqBody := map[string]interface{}{
|
||
"collection_name": collName,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err = s.handler.RestoreSystemCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
// Verify user-owned system collection was deleted
|
||
collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true})
|
||
for _, coll := range collections {
|
||
if coll.Name == collName && coll.IsSystemCollection {
|
||
s.T().Fatalf("User-owned system collection should have been deleted")
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() {
|
||
user := s.CreateTestUser()
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Test invalid collection name
|
||
reqBody := map[string]interface{}{
|
||
"collection_name": "invalid-collection-name",
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.RestoreSystemCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
|
||
}
|
||
|
||
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
||
suite.Run(t, new(DashboardIntegrationTestSuite))
|
||
}
|
||
```
|
||
|
||
#### 11.3 Custom Section Builder Tests
|
||
|
||
**File: `internal/handlers/collections_preview_test.go`** (new file)
|
||
|
||
**ARCHITECTURE NOTE**: Tests verify preview endpoint evaluates filter rules correctly
|
||
|
||
```go
|
||
package handlers_test
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"bytes"
|
||
|
||
"bookhoard/internal/handlers"
|
||
"bookhoard/internal/database"
|
||
"bookhoard/internal/test_helpers"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"github.com/stretchr/testify/suite"
|
||
)
|
||
|
||
type CollectionPreviewTestSuite struct {
|
||
suite.Suite
|
||
test_helpers.TestSuite
|
||
handler *handlers.CollectionHandler
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) SetupSuite() {
|
||
s.TestSuite.SetupSuite()
|
||
s.handler = handlers.NewCollectionHandler(s.Queries, s.CollectionService)
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TearDownSuite() {
|
||
s.TestSuite.TearDownSuite()
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TestPreviewCollection_FilterRules() {
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
// Create test items with different genres
|
||
item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
|
||
item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
|
||
item3 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Test preview with Sci-Fi filter
|
||
reqBody := map[string]interface{}{
|
||
"library_id": library.ID.String(),
|
||
"rules": []map[string]interface{}{
|
||
{
|
||
"id": "rule1",
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Sci-Fi",
|
||
"priority": 1,
|
||
},
|
||
},
|
||
"manual_book_ids": []string{},
|
||
"limit": 20,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.PreviewCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
items := response["items"].([]interface{})
|
||
assert.Greater(s.T(), len(items), 0, "Should have matched items")
|
||
|
||
// Verify Sci-Fi books are included, Fiction is not
|
||
itemTitles := make([]string, len(items))
|
||
for i, item := range items {
|
||
itemMap := item.(map[string]interface{})
|
||
itemTitles[i] = itemMap["title"].(string)
|
||
}
|
||
|
||
assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book")
|
||
assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book")
|
||
assert.NotContains(s.T(), itemTitles, "Pride and Prejudice", "Should not include Fiction book")
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TestPreviewCollection_ManualBookSelection() {
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
// Create test items
|
||
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
|
||
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
|
||
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Mystery")
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Test preview with manual book selection (no filter rules)
|
||
reqBody := map[string]interface{}{
|
||
"library_id": library.ID.String(),
|
||
"rules": []map[string]interface{}{},
|
||
"manual_book_ids": []string{
|
||
item1.ID.String(),
|
||
item3.ID.String(),
|
||
},
|
||
"limit": 20,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.PreviewCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
items := response["items"].([]interface{})
|
||
assert.Len(s.T(), items, 2, "Should have exactly 2 manually selected books")
|
||
|
||
// Verify correct books are included
|
||
itemIDs := make([]string, len(items))
|
||
for i, item := range items {
|
||
itemMap := item.(map[string]interface{})
|
||
itemIDs[i] = itemMap["media_item_id"].(string)
|
||
}
|
||
|
||
assert.Contains(s.T(), itemIDs, item1.ID.String(), "Should include Book 1")
|
||
assert.Contains(s.T(), itemIDs, item3.ID.String(), "Should include Book 3")
|
||
assert.NotContains(s.T(), itemIDs, item2.ID.String(), "Should not include Book 2 (not selected)")
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TestPreviewCollection_CombinedFiltersAndManual() {
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
// Create test items
|
||
item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
|
||
item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
|
||
item3 := s.CreateTestMediaItem(library.ID, "Neuromancer", "William Gibson", "Sci-Fi")
|
||
item4 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Test preview with Sci-Fi filter + manual selection of Fiction book
|
||
reqBody := map[string]interface{}{
|
||
"library_id": library.ID.String(),
|
||
"rules": []map[string]interface{}{
|
||
{
|
||
"id": "rule1",
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Sci-Fi",
|
||
"priority": 1,
|
||
},
|
||
},
|
||
"manual_book_ids": []string{
|
||
item4.ID.String(), // Manually add Fiction book
|
||
},
|
||
"limit": 20,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.PreviewCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
items := response["items"].([]interface{})
|
||
assert.Greater(s.T(), len(items), 0, "Should have matched items")
|
||
|
||
// Should include all Sci-Fi books + manually selected Fiction book
|
||
itemTitles := make([]string, len(items))
|
||
for i, item := range items {
|
||
itemMap := item.(map[string]interface{})
|
||
itemTitles[i] = itemMap["title"].(string)
|
||
}
|
||
|
||
assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book from filter")
|
||
assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book from filter")
|
||
assert.Contains(s.T(), itemTitles, "Pride and Prejudice", "Should include manually selected Fiction book")
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TestPreviewCollection_LimitRespected() {
|
||
user := s.CreateTestUser()
|
||
library := s.CreateTestLibrary(user.ID)
|
||
|
||
// Create 30 test items
|
||
for i := 1; i <= 30; i++ {
|
||
s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction")
|
||
}
|
||
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
// Test preview with limit of 10
|
||
reqBody := map[string]interface{}{
|
||
"library_id": library.ID.String(),
|
||
"rules": []map[string]interface{}{
|
||
{
|
||
"id": "rule1",
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Fiction",
|
||
"priority": 1,
|
||
},
|
||
},
|
||
"manual_book_ids": []string{},
|
||
"limit": 10,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.PreviewCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
items := response["items"].([]interface{})
|
||
assert.Len(s.T(), items, 10, "Should respect limit of 10 items")
|
||
}
|
||
|
||
func (s *CollectionPreviewTestSuite) TestPreviewCollection_InvalidLibraryID() {
|
||
user := s.CreateTestUser()
|
||
token := s.GenerateJWTToken(user.ID)
|
||
|
||
reqBody := map[string]interface{}{
|
||
"library_id": "invalid-uuid",
|
||
"rules": []map[string]interface{}{},
|
||
"manual_book_ids": []string{},
|
||
"limit": 20,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
rec := httptest.NewRecorder()
|
||
|
||
c := s.Echo.NewContext(req, rec)
|
||
c.Set("user", user)
|
||
|
||
err := s.handler.PreviewCollection(c)
|
||
require.NoError(s.T(), err)
|
||
|
||
assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
|
||
}
|
||
|
||
func TestCollectionPreviewTestSuite(t *testing.T) {
|
||
suite.Run(t, new(CollectionPreviewTestSuite))
|
||
}
|
||
```
|
||
|
||
**Run tests**:
|
||
```bash
|
||
# Run all dashboard tests
|
||
go test ./internal/services/dashboard_service_test.go -v
|
||
go test ./internal/handlers/dashboard_test.go -v
|
||
go test ./internal/handlers/dashboard_integration_test.go -v
|
||
go test ./internal/handlers/collections_preview_test.go -v
|
||
|
||
# Run with coverage
|
||
go test ./internal/services/... ./internal/handlers/... -coverprofile=coverage.out
|
||
go tool cover -html=coverage.out
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 12: Bruno API Tests** (1 hour)
|
||
|
||
**CRITICAL**: Bruno tests must be created to verify API functionality. These tests serve three purposes:
|
||
1. **API Verification**: Ensure endpoints work as documented
|
||
2. **Documentation**: Examples show developers how to use the API
|
||
3. **Regression Testing**: Catch breaking changes early
|
||
|
||
#### 12.1 Create Bruno Test Directory
|
||
|
||
**Directory structure**:
|
||
```
|
||
bruno/
|
||
└── dashboard/
|
||
├── get-sections-success.bru
|
||
├── get-sections-missing-library-id.bru
|
||
├── get-sections-invalid-library-id.bru
|
||
├── get-sections-unauthorized.bru
|
||
├── put-preferences-success.bru
|
||
├── put-preferences-unauthorized.bru
|
||
├── restore-system-collection-success.bru
|
||
├── restore-system-collection-invalid-name.bru
|
||
└── restore-system-collection-unauthorized.bru
|
||
```
|
||
|
||
#### 12.2 Create Get Sections Tests
|
||
|
||
**File: `bruno/dashboard/get-sections-success.bru`**
|
||
|
||
```yaml
|
||
name: Get Dashboard Sections - Success
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: GET
|
||
url: {{baseUrl}}/api/dashboard/sections
|
||
query:
|
||
library_id: {{defaultLibraryId}}
|
||
limit: 20
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.sections"
|
||
exists: true
|
||
- jsonpath: "$.sections[0].is_system"
|
||
type: boolean
|
||
- jsonpath: "$.sections[0].items[0].media_item_id"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/get-sections-missing-library-id.bru`**
|
||
|
||
```yaml
|
||
name: Get Dashboard Sections - Missing library_id
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: GET
|
||
url: {{baseUrl}}/api/dashboard/sections
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
|
||
assertions:
|
||
- status: 400
|
||
- jsonpath: "$.error"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/get-sections-unauthorized.bru`**
|
||
|
||
```yaml
|
||
name: Get Dashboard Sections - Unauthorized
|
||
meta:
|
||
group: Dashboard API
|
||
|
||
req:
|
||
method: GET
|
||
url: {{baseUrl}}/api/dashboard/sections
|
||
query:
|
||
library_id: {{defaultLibraryId}}
|
||
|
||
assertions:
|
||
- status: 401
|
||
```
|
||
|
||
#### 12.3 Create Update Preferences Tests
|
||
|
||
**File: `bruno/dashboard/put-preferences-success.bru`**
|
||
|
||
```yaml
|
||
name: Update Dashboard Preferences - Success
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: PUT
|
||
url: {{baseUrl}}/api/dashboard/preferences
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: {{defaultLibraryId}}
|
||
hidden_collections:
|
||
- not-started
|
||
collection_order:
|
||
- recently-added
|
||
- continue-reading
|
||
- recently-read
|
||
items_per_section: 20
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.hidden_collections"
|
||
exists: true
|
||
- jsonpath: "$.collection_order"
|
||
exists: true
|
||
```
|
||
|
||
#### 12.4 Create Restore System Collection Tests
|
||
|
||
**File: `bruno/dashboard/restore-system-collection-success.bru`**
|
||
|
||
```yaml
|
||
name: Restore System Collection - Success
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/dashboard/restore-system-collection
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
collection_name: continue-reading
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.message"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/restore-system-collection-invalid-name.bru`**
|
||
|
||
```yaml
|
||
name: Restore System Collection - Invalid Name
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/dashboard/restore-system-collection
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
collection_name: invalid-collection-name
|
||
|
||
assertions:
|
||
- status: 400
|
||
- jsonpath: "$.error"
|
||
exists: true
|
||
```
|
||
|
||
**Run Bruno tests**:
|
||
```bash
|
||
cd bruno/dashboard
|
||
bru run --env local
|
||
```
|
||
|
||
**Verify**:
|
||
- ✅ All tests pass in three contexts (no user, user, admin)
|
||
- ✅ Response fields match Go handler JSON tags
|
||
- ✅ `is_system` is boolean, not string
|
||
- ✅ `media_item_id` field present (not `id`)
|
||
- ✅ Error cases handled correctly
|
||
|
||
#### 12.5 Create Collections Endpoint Tests
|
||
|
||
**IMPORTANT**: Tests for CreateCollection with manual_book_ids support
|
||
|
||
**File: `bruno/collections/create-collection-with-manual-books.bru`** (NEW)
|
||
|
||
```yaml
|
||
name: Create Collection with Manual Books
|
||
meta:
|
||
group: Collections API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
name: "Sci-Fi Favorites"
|
||
description: "My favorite sci-fi books"
|
||
icon: "🚀"
|
||
color: "#9333ea"
|
||
auto_assign_rules:
|
||
- id: rule1
|
||
field: genre
|
||
operator: equals
|
||
value: Sci-Fi
|
||
priority: 1
|
||
manual_book_ids:
|
||
- {{bookId1}}
|
||
- {{bookId2}}
|
||
view_settings: {}
|
||
|
||
assertions:
|
||
- status: 201
|
||
- jsonpath: "$.id"
|
||
exists: true
|
||
- jsonpath: "$.name"
|
||
equals: "Sci-Fi Favorites"
|
||
```
|
||
|
||
**File: `bruno/collections/create-collection-too-many-books.bru`** (NEW)
|
||
|
||
```yaml
|
||
name: Create Collection - Too Many Manual Books (Validation Test)
|
||
meta:
|
||
group: Collections API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
name: "Test Collection"
|
||
manual_book_ids:
|
||
# Generate 51 book IDs to exceed max limit
|
||
- {{bookId1}}
|
||
- {{bookId2}}
|
||
- {{bookId3}}
|
||
- {{bookId4}}
|
||
- {{bookId5}}
|
||
# ... (total of 51 IDs)
|
||
|
||
assertions:
|
||
- status: 400
|
||
- jsonpath: "$.error"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/collections/create-collection-invalid-book-id.bru`** (NEW)
|
||
|
||
```yaml
|
||
name: Create Collection - Invalid Book IDs
|
||
meta:
|
||
group: Collections API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
name: "Test Collection"
|
||
manual_book_ids:
|
||
- invalid-uuid-format
|
||
- {{bookId1}}
|
||
- another-invalid-uuid
|
||
|
||
assertions:
|
||
- status: 201
|
||
- jsonpath: "$.id"
|
||
exists: true
|
||
# Collection should be created, valid books added, invalid IDs skipped
|
||
```
|
||
|
||
**File: `bruno/collections/create-collection-rules-only.bru`** (NEW)
|
||
|
||
```yaml
|
||
name: Create Collection - Auto-Assign Rules Only
|
||
meta:
|
||
group: Collections API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
name: "High Rated Books"
|
||
description: "Books with rating > 4"
|
||
icon: "⭐"
|
||
color: "#FFD700"
|
||
auto_assign_rules:
|
||
- id: rule1
|
||
field: rating
|
||
operator: greater_than
|
||
value: "4"
|
||
priority: 1
|
||
# manual_book_ids not provided (optional field)
|
||
|
||
assertions:
|
||
- status: 201
|
||
- jsonpath: "$.auto_assign_rules"
|
||
exists: true
|
||
```
|
||
|
||
**Update Bruno test directory structure**:
|
||
```
|
||
bruno/
|
||
├── dashboard/
|
||
│ ├── get-sections-success.bru
|
||
│ ├── get-sections-missing-library-id.bru
|
||
│ ├── get-sections-invalid-library-id.bru
|
||
│ ├── get-sections-unauthorized.bru
|
||
│ ├── put-preferences-success.bru
|
||
│ ├── put-preferences-unauthorized.bru
|
||
│ ├── restore-system-collection-success.bru
|
||
│ ├── restore-system-collection-invalid-name.bru
|
||
│ ├── restore-system-collection-unauthorized.bru
|
||
│ ├── preview-collection-success.bru
|
||
│ ├── preview-collection-manual-selection.bru
|
||
│ ├── preview-collection-combined.bru
|
||
│ ├── preview-collection-invalid-library.bru
|
||
│ └── preview-collection-unauthorized.bru
|
||
└── collections/ # NEW DIRECTORY
|
||
├── create-collection-with-manual-books.bru
|
||
├── create-collection-too-many-books.bru
|
||
├── create-collection-invalid-book-id.bru
|
||
├── create-collection-rules-only.bru
|
||
├── create-collection-unauthorized.bru
|
||
└── get-collections.bru
|
||
```
|
||
|
||
**Run Bruno tests**:
|
||
```bash
|
||
# Test dashboard endpoints
|
||
cd bruno/dashboard
|
||
bru run --env local
|
||
|
||
# Test collections endpoints
|
||
cd bruno/collections
|
||
bru run --env local
|
||
```
|
||
|
||
#### 12.5 Create Collections Preview Tests
|
||
|
||
**File: `bruno/dashboard/preview-collection-success.bru`**
|
||
|
||
```yaml
|
||
name: Preview Collection - Success with Filter Rules
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: {{defaultLibraryId}}
|
||
rules:
|
||
- id: rule1
|
||
field: genre
|
||
operator: equals
|
||
value: Sci-Fi
|
||
priority: 1
|
||
manual_book_ids: []
|
||
limit: 20
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.items"
|
||
exists: true
|
||
- jsonpath: "$.items[0].media_item_id"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/preview-collection-manual-selection.bru`**
|
||
|
||
```yaml
|
||
name: Preview Collection - Manual Book Selection
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: {{defaultLibraryId}}
|
||
rules: []
|
||
manual_book_ids:
|
||
- {{bookId1}}
|
||
- {{bookId2}}
|
||
limit: 20
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.items"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/preview-collection-combined.bru`**
|
||
|
||
```yaml
|
||
name: Preview Collection - Combined Filters + Manual Selection
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: {{defaultLibraryId}}
|
||
rules:
|
||
- id: rule1
|
||
field: genre
|
||
operator: equals
|
||
value: Fiction
|
||
priority: 1
|
||
manual_book_ids:
|
||
- {{bookId1}}
|
||
limit: 20
|
||
|
||
assertions:
|
||
- status: 200
|
||
- jsonpath: "$.items"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/preview-collection-invalid-library.bru`**
|
||
|
||
```yaml
|
||
name: Preview Collection - Invalid Library ID
|
||
meta:
|
||
group: Dashboard API
|
||
pre_request: Login as regular user
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: invalid-uuid
|
||
rules: []
|
||
manual_book_ids: []
|
||
limit: 20
|
||
|
||
assertions:
|
||
- status: 400
|
||
- jsonpath: "$.error"
|
||
exists: true
|
||
```
|
||
|
||
**File: `bruno/dashboard/preview-collection-unauthorized.bru`**
|
||
|
||
```yaml
|
||
name: Preview Collection - Unauthorized
|
||
meta:
|
||
group: Dashboard API
|
||
|
||
req:
|
||
method: POST
|
||
url: {{baseUrl}}/api/collections/preview
|
||
headers:
|
||
Content-Type: application/json
|
||
body:
|
||
library_id: {{defaultLibraryId}}
|
||
rules: []
|
||
manual_book_ids: []
|
||
limit: 20
|
||
|
||
assertions:
|
||
- status: 401
|
||
```
|
||
|
||
**Update Bruno test directory structure**:
|
||
```
|
||
bruno/
|
||
└── dashboard/
|
||
├── get-sections-success.bru
|
||
├── get-sections-missing-library-id.bru
|
||
├── get-sections-invalid-library-id.bru
|
||
├── get-sections-unauthorized.bru
|
||
├── put-preferences-success.bru
|
||
├── put-preferences-unauthorized.bru
|
||
├── restore-system-collection-success.bru
|
||
├── restore-system-collection-invalid-name.bru
|
||
├── restore-system-collection-unauthorized.bru
|
||
├── preview-collection-success.bru
|
||
├── preview-collection-manual-selection.bru
|
||
├── preview-collection-combined.bru
|
||
├── preview-collection-invalid-library.bru
|
||
└── preview-collection-unauthorized.bru
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 13: Documentation Updates** (2-3 hours)
|
||
|
||
#### 13.1 Developer API Documentation
|
||
|
||
**File: `docs/developer/api/dashboard.md`** (REPLACE existing)
|
||
|
||
Update to reflect new API structure:
|
||
- Change `type: "smart"` → `is_system: true`
|
||
- Change `type: "collection"` → `is_system: false`
|
||
- Change `"id"` → `"media_item_id"` for books
|
||
- Remove "In Progress" section (only 4 system collections now)
|
||
- Update field names: `hidden_collections`, `collection_order`
|
||
- Add Restore System Collection endpoint documentation
|
||
|
||
**Add architecture note:**
|
||
```markdown
|
||
## Architecture
|
||
|
||
The dashboard follows a layered type system:
|
||
|
||
1. **Service Layer** (`internal/services/dashboard_service.go`)
|
||
- Returns database types: `[]database.MediaItems`, `[]database.Collections`
|
||
- Provides type safety at the database layer
|
||
- No HTTP concerns
|
||
|
||
2. **Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`)
|
||
- Converts database types to API types: `SectionData`, `BookInfo`
|
||
- Single source of truth for API contracts
|
||
- Handles JSON serialization
|
||
|
||
3. **Template Layer** (`templates/dashboard.templ`)
|
||
- Uses handler types directly: `[]handlers.SectionData`
|
||
- No type duplication in templates package
|
||
- SSR pre-populates data
|
||
|
||
This pattern ensures:
|
||
- ✅ Type safety at database layer (compiler catches schema changes)
|
||
- ✅ Clean JSON contracts (no pgtype in API responses)
|
||
- ✅ Single source of truth (no duplicate type definitions)
|
||
- ✅ Reusable by SSR, API, mobile apps
|
||
```
|
||
|
||
**Example request/response:**
|
||
```markdown
|
||
### Get Dashboard Sections
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"sections": [
|
||
{
|
||
"id": "continue-reading",
|
||
"is_system": true,
|
||
"title": "Continue Reading",
|
||
"description": "Books you're currently reading (0 < progress < 1)",
|
||
"icon": "📖",
|
||
"items": [
|
||
{
|
||
"media_item_id": "uuid-here",
|
||
"title": "Book Title",
|
||
"author": "Author Name",
|
||
"cover_image_path": "/path/to/cover.jpg"
|
||
}
|
||
],
|
||
"view_all_url": "/section/continue-reading",
|
||
"priority": 1
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Note:** `media_item_id` is used (not `id`) to match Go struct field names.
|
||
```
|
||
|
||
#### 13.2 Custom Section Builder API Documentation
|
||
|
||
**File: `docs/developer/api/custom-section-builder.md`** (new file)
|
||
|
||
**Add complete documentation for Custom Section Builder**:
|
||
|
||
```markdown
|
||
# Custom Section Builder API
|
||
|
||
The Custom Section Builder allows users to create personalized dashboard sections by defining filter rules or manually selecting books.
|
||
|
||
## Preview Collection
|
||
|
||
Evaluates filter rules and returns matching items without saving the collection.
|
||
|
||
**Endpoint:** `POST /api/collections/preview`
|
||
|
||
**Request Body:**
|
||
```json
|
||
{
|
||
"library_id": "uuid",
|
||
"rules": [
|
||
{
|
||
"id": "rule1",
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Sci-Fi",
|
||
"priority": 1
|
||
}
|
||
],
|
||
"manual_book_ids": ["uuid1", "uuid2"],
|
||
"limit": 20
|
||
}
|
||
```
|
||
|
||
**Available Filter Fields:**
|
||
|
||
| Field | Type | Operators |
|
||
|-------|------|-----------|
|
||
| `title` | text | contains, equals, starts_with, ends_with, regex |
|
||
| `author` | text | contains, equals |
|
||
| `genre` | select | equals, not_equals, in, not_in |
|
||
| `series` | text | is_set, is_not_set, equals, contains |
|
||
| `progress` | number | equals, not_equals, greater_than, less_than, between, is_set, is_not_set |
|
||
| `rating` | number | equals, not_equals, greater_than, less_than, is_set, is_not_set |
|
||
| `date_added` | date | equals, not_equals, before, after, between, last_x_days |
|
||
| `last_read` | date | equals, before, after, between, last_x_days, is_set, is_not_set |
|
||
| `publisher` | text | contains, equals |
|
||
| `language` | select | equals, not_equals, in |
|
||
| `format` | select | equals, in |
|
||
| `tags` | text | contains, not_contains, equals |
|
||
| `narrators` | text | contains, equals, is_set, is_not_set |
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"items": [
|
||
{
|
||
"media_item_id": "uuid",
|
||
"title": "Book Title",
|
||
"author": "Author Name",
|
||
"cover_image_path": "/path/to/cover.jpg"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
## Create Custom Section
|
||
|
||
Creates a new custom collection with filter rules and/or manual book selection.
|
||
|
||
**Endpoint:** `POST /api/collections`
|
||
|
||
**Request Body:**
|
||
```json
|
||
{
|
||
"library_id": "uuid",
|
||
"name": "My Custom Section",
|
||
"icon": "📚",
|
||
"description": "My favorite Sci-Fi books",
|
||
"show_on_dashboard": true,
|
||
"auto_assign_rules": "[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]",
|
||
"manual_book_ids": ["uuid1", "uuid2"],
|
||
"match_type": "all"
|
||
}
|
||
```
|
||
|
||
**Response:** Returns the created collection object.
|
||
|
||
## Frontend Implementation
|
||
|
||
**Route:** `/custom-section`
|
||
|
||
**Template:** `templates/custom_section.templ`
|
||
|
||
**TypeScript:** `web/src/custom-section-builder.ts`
|
||
|
||
Key features:
|
||
- 13+ filter fields with various operators
|
||
- Live preview functionality
|
||
- Search + multi-select for manual book addition
|
||
- AND/OR logic support for combining rules
|
||
```
|
||
|
||
#### 13.3 Collections API Documentation Update
|
||
|
||
**File: `docs/developer/api/collections/create_collection.md`** (UPDATE existing)
|
||
|
||
**Add `manual_book_ids` field to request body table:**
|
||
|
||
```markdown
|
||
## Request Body
|
||
|
||
| Field | Type | Required | Description |
|
||
|--------|------|-----------|-------------|
|
||
| name | string | Yes | Collection name (max 255 chars) |
|
||
| description | string | No | Collection description |
|
||
| color | string | No | Hex color code (e.g., "#FF5733") |
|
||
| icon | string | No | Emoji icon (e.g., "🚀", "📖") |
|
||
| auto_assign_rules | array | No | Array of rule objects |
|
||
| manual_book_ids | array | No | Array of book UUIDs to manually add (max 50) |
|
||
| view_settings | object | No | Per-device display preferences |
|
||
```
|
||
|
||
**Add validation section:**
|
||
|
||
```markdown
|
||
## Validation
|
||
|
||
- `manual_book_ids` array is limited to 50 items
|
||
- Returns `400 Bad Request` if more than 50 book IDs provided
|
||
- Invalid book UUIDs are skipped (don't prevent collection creation)
|
||
- Duplicate book IDs are automatically ignored (database constraint)
|
||
```
|
||
|
||
**Add example with manual books:**
|
||
|
||
```markdown
|
||
### Example Request (Auto-Assign Rules + Manual Books)
|
||
|
||
```json
|
||
{
|
||
"name": "Sci-Fi Favorites",
|
||
"description": "My favorite sci-fi books plus manual picks",
|
||
"icon": "🚀",
|
||
"color": "#9333ea",
|
||
"auto_assign_rules": [
|
||
{
|
||
"field": "genre",
|
||
"operator": "equals",
|
||
"value": "Sci-Fi",
|
||
"priority": 1
|
||
}
|
||
],
|
||
"manual_book_ids": [
|
||
"550e8400-e29b-41d4-a716-446655440000",
|
||
"550e8400-e29b-41d4-a716-446655440001"
|
||
],
|
||
"view_settings": {
|
||
"kobo": {
|
||
"view_mode": "grid"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Notes:**
|
||
- You can combine `auto_assign_rules` AND `manual_book_ids`
|
||
- Manual books are added regardless of whether they match the auto-assign rules
|
||
- Invalid book IDs are skipped with errors logged
|
||
- Maximum 50 manual books per collection (UI constraint)
|
||
```
|
||
|
||
**Add error response example:**
|
||
|
||
```markdown
|
||
### Error Responses
|
||
|
||
| Code | Description |
|
||
|------|-------------|
|
||
| 400 | Invalid request (validation failed, > 50 manual books) |
|
||
| 400 | Invalid request (validation failed) |
|
||
| 401 | Authentication required |
|
||
| 500 | Internal server error |
|
||
|
||
**Example: Too Many Manual Books**
|
||
|
||
Request:
|
||
```json
|
||
{
|
||
"name": "Test",
|
||
"manual_book_ids": [ ... 51 book IDs ... ]
|
||
}
|
||
```
|
||
|
||
Response (400):
|
||
```json
|
||
{
|
||
"error": "Validation failed"
|
||
}
|
||
```
|
||
```
|
||
|
||
#### 13.4 User Documentation
|
||
|
||
**File: `docs/user/dashboard.md`** (UPDATE existing)
|
||
|
||
Update sections:
|
||
- **Smart Sections**: List only 4 sections (remove "In Progress")
|
||
- Continue Reading
|
||
- Recently Added
|
||
- Recently Read
|
||
- Not Started
|
||
- **Customizing Dashboard**: Update instructions to match new UI
|
||
- **System Collections**: Explain that system collections can be restored to defaults
|
||
- Add note about "System" badge in settings modal
|
||
|
||
**Add section:**
|
||
```markdown
|
||
## System Collections
|
||
|
||
System collections are pre-configured sections that appear on your dashboard:
|
||
- **Continue Reading**: Books you're currently reading
|
||
- **Recently Added**: Newly added items to this library
|
||
- **Recently Read**: Books you've finished
|
||
- **Not Started**: Books you haven't read yet
|
||
|
||
### Customizing System Collections
|
||
|
||
You can customize system collections by:
|
||
1. Opening dashboard settings (⚙️)
|
||
2. Finding the system collection (marked with "System" badge)
|
||
3. Toggling visibility or changing order
|
||
|
||
### Restoring Defaults
|
||
|
||
If you've customized a system collection and want to restore it to defaults:
|
||
1. Open dashboard settings
|
||
2. Find the system collection
|
||
3. Click "Restore" button
|
||
4. Confirm the restore
|
||
|
||
This will reset the collection to its original state.
|
||
```
|
||
|
||
**Add Custom Section Builder section:**
|
||
```markdown
|
||
## Custom Sections
|
||
|
||
Create personalized dashboard sections by defining filter rules or manually selecting books.
|
||
|
||
### Creating a Custom Section
|
||
|
||
1. Click "Create Custom Section" from the dashboard
|
||
2. Fill in section details:
|
||
- **Name**: Section name (required)
|
||
- **Icon**: Emoji icon (optional)
|
||
- **Description**: Section description (optional)
|
||
- **Library**: Select which library to use (required)
|
||
|
||
3. Add filter rules (optional):
|
||
- Click "+ Add Rule" to create filter conditions
|
||
- Select a field (genre, author, progress, rating, etc.)
|
||
- Choose an operator (equals, contains, greater than, etc.)
|
||
- Enter a value
|
||
- Choose match type: ALL rules (AND) or ANY rule (OR)
|
||
|
||
4. Add manual book selection (optional):
|
||
- Search for books by title or author
|
||
- Click "+" to add books to your selection
|
||
- Selected books appear in the "Selected Books" area
|
||
|
||
5. Preview your section:
|
||
- Click "Refresh Preview" to see matching books
|
||
- Adjust rules or book selection as needed
|
||
|
||
6. Save your section:
|
||
- Click "Save Section" to create the section
|
||
- The section will appear on your dashboard
|
||
|
||
### Available Filter Fields
|
||
|
||
- **Title**: Book title
|
||
- **Author**: Book author
|
||
- **Genre**: Fiction, Non-Fiction, Sci-Fi, Fantasy, etc.
|
||
- **Series**: Series name
|
||
- **Progress**: Reading progress percentage
|
||
- **Rating**: Your rating
|
||
- **Date Added**: When the book was added
|
||
- **Last Read**: When you last read the book
|
||
- **Publisher**: Book publisher
|
||
- **Language**: Book language
|
||
- **Format**: Ebook, Audiobook, Comic, etc.
|
||
- **Tags**: Book tags
|
||
- **Narrators**: Audiobook narrators
|
||
|
||
### Example Custom Sections
|
||
|
||
**Sci-Fi Favorites:**
|
||
- Rule: Genre equals "Sci-Fi"
|
||
- Rule: Rating greater than "4"
|
||
|
||
**Long Books:**
|
||
- Rule: Progress equals "0"
|
||
- Manual: Add books with 500+ pages
|
||
|
||
**Recently Finished Audiobooks:**
|
||
- Rule: Format equals "Audiobook"
|
||
- Rule: Last read after "30 days ago"
|
||
```
|
||
|
||
**File: `docs/user/user-guide.md`** (UPDATE existing)
|
||
|
||
Add dashboard section if not present, or update existing section to reference new Carousel-style interface.
|
||
|
||
#### 13.4 Contributing Documentation
|
||
|
||
**File: `docs/contributing/development.md`** (UPDATE existing)
|
||
|
||
Add to handler list:
|
||
```markdown
|
||
**Handlers** (`internal/handlers/`):
|
||
- ...
|
||
- `dashboard.go` - Dashboard sections and preferences API
|
||
- `collections.go` - Shared handler types (SectionData, BookInfo)
|
||
```
|
||
|
||
Add to services list:
|
||
```markdown
|
||
**Services** (`internal/services/`):
|
||
- ...
|
||
- `dashboard_service.go` - Dashboard business logic
|
||
```
|
||
|
||
**Add architecture pattern:**
|
||
```markdown
|
||
## Type Conversion Pattern
|
||
|
||
Follow this pattern for type safety and clean APIs:
|
||
|
||
1. **Services return database types**
|
||
```go
|
||
func (s *Service) GetData() ([]database.MediaItems, error) {
|
||
return s.db.QueryMediaItems(ctx)
|
||
}
|
||
```
|
||
|
||
2. **Handlers convert to API types**
|
||
```go
|
||
func BuildResponse(items []database.MediaItems) []APIType {
|
||
response := make([]APIType, len(items))
|
||
for i, item := range items {
|
||
response[i] = APIType{
|
||
Field: textToString(item.Field), // pgtype.Text → string
|
||
ID: uuid.UUID(item.ID.Bytes).String(), // pgtype.UUID → string
|
||
}
|
||
}
|
||
return response
|
||
}
|
||
```
|
||
|
||
3. **Templates use handler types**
|
||
```templ
|
||
templ Page(data []handlers.APIType) {
|
||
for _, item := range data {
|
||
// Use handler type directly - no conversion
|
||
}
|
||
}
|
||
```
|
||
|
||
**Benefits:**
|
||
- ✅ Compiler catches database schema changes
|
||
- ✅ Clean JSON contracts for API
|
||
- ✅ No duplicate type definitions
|
||
- ✅ Single source of truth
|
||
```
|
||
|
||
#### 13.5 Operations Documentation
|
||
|
||
**File: `docs/operations/operations.md`** (UPDATE if needed)
|
||
|
||
- Update any troubleshooting guides that reference old dashboard
|
||
- Add notes about database recreation for schema changes
|
||
- Document system collection restoration process
|
||
|
||
**Add section:**
|
||
```markdown
|
||
## Dashboard Troubleshooting
|
||
|
||
### Collections Not Appearing
|
||
|
||
If collections don't appear on dashboard:
|
||
|
||
1. Check collection has `show_on_dashboard = true`
|
||
2. Check user hasn't hidden collection in preferences
|
||
3. Verify library_id is correct
|
||
|
||
### System Collections Missing
|
||
|
||
If system collections are missing:
|
||
|
||
```sql
|
||
-- Check system collections exist
|
||
SELECT name, query_type, priority, is_system_collection
|
||
FROM collections
|
||
WHERE user_id IS NULL;
|
||
```
|
||
|
||
Should return 4 rows (continue-reading, recently-added, recently-read, not-started).
|
||
|
||
If missing, re-insert:
|
||
```sql
|
||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
|
||
VALUES
|
||
(NULL, 'continue-reading', 'Books you''re currently reading', '📖', '#7aa2f7', true, 'continue-reading', 1, true),
|
||
(NULL, 'recently-added', 'Newly added items', '🆕', '#9ece6a', true, 'recently-added', 2, true),
|
||
(NULL, 'recently-read', 'Books you''ve finished', '✅', '#e0af68', true, 'recently-read', 3, true),
|
||
(NULL, 'not-started', 'Books you haven''t read', '📕', '#f7768e', true, 'not-started', 4, true);
|
||
```
|
||
```
|
||
|
||
#### 13.6 API Reference
|
||
|
||
**File: `docs/developer/api/api-reference.md`** (UPDATE existing)
|
||
|
||
Add dashboard endpoints to the API reference index:
|
||
```markdown
|
||
## Dashboard
|
||
|
||
- [Get Dashboard Sections](./dashboard.md#get-dashboard-sections)
|
||
- [Update Dashboard Preferences](./dashboard.md#update-dashboard-preferences)
|
||
- [Restore System Collection](./dashboard.md#restore-system-collection)
|
||
|
||
## Collections
|
||
|
||
- [Preview Collection](./custom-section-builder.md#preview-collection)
|
||
- [Create Custom Section](./custom-section-builder.md#create-custom-section)
|
||
```
|
||
|
||
#### 13.7 Type System Documentation
|
||
|
||
**File: `docs/developer/architecture/types.md`** (CREATE new)
|
||
|
||
Create new documentation file explaining the type system:
|
||
```markdown
|
||
# Type System Architecture
|
||
|
||
## Overview
|
||
|
||
Bookhoard uses a layered type system to ensure type safety while providing clean APIs.
|
||
|
||
## Layers
|
||
|
||
### 1. Database Layer (sqlc generated)
|
||
- **Location**: `internal/database/models.go`
|
||
- **Types**: `database.MediaItems`, `database.Collections`, etc.
|
||
- **Fields**: Use `pgtype.UUID`, `pgtype.Text`, `pgtype.Int4`, etc.
|
||
- **Purpose**: Match database schema exactly
|
||
- **Benefits**: Compiler catches schema changes
|
||
|
||
### 2. Service Layer
|
||
- **Location**: `internal/services/*.go`
|
||
- **Returns**: Database types (`[]database.MediaItems`)
|
||
- **Purpose**: Business logic with type safety
|
||
- **Benefits**: Reusable by SSR, API, mobile
|
||
|
||
### 3. Handler Layer
|
||
- **Location**: `internal/handlers/*.go`
|
||
- **Types**: `SectionData`, `BookInfo`, etc.
|
||
- **Fields**: Use `string`, `bool`, `int`, etc.
|
||
- **Purpose**: Clean JSON contracts for API
|
||
- **Benefits**: Predictable API responses
|
||
|
||
### 4. Template Layer
|
||
- **Location**: `templates/*.templ`
|
||
- **Uses**: Handler types (`[]handlers.SectionData`)
|
||
- **Purpose**: SSR data pre-population
|
||
- **Benefits**: No type duplication
|
||
|
||
## Type Conversion Example
|
||
|
||
```go
|
||
// Service returns database types
|
||
func (s *DashboardService) GetSystemCollections(...) (
|
||
[]database.Collections,
|
||
[]database.MediaItems,
|
||
error,
|
||
)
|
||
|
||
// Handler converts to API types
|
||
func BuildSections(
|
||
collections []database.Collections,
|
||
items []database.MediaItems,
|
||
) []SectionData {
|
||
sections := make([]SectionData, len(collections))
|
||
for i, coll := range collections {
|
||
sections[i] = SectionData{
|
||
ID: coll.Name,
|
||
Icon: textToString(coll.Icon), // pgtype.Text → string
|
||
Items: convertToBookInfo(items), // pgtype conversion
|
||
}
|
||
}
|
||
return sections
|
||
}
|
||
|
||
// Template uses handler types
|
||
templ Dashboard(sections []handlers.SectionData) {
|
||
for _, section := range sections {
|
||
// Direct use - no conversion needed
|
||
}
|
||
}
|
||
```
|
||
|
||
## Field Mapping
|
||
|
||
| Database Type | Handler Type | JSON Type | Example |
|
||
|--------------|--------------|-----------|---------|
|
||
| `pgtype.UUID` | `string` | string | `"uuid-here"` |
|
||
| `pgtype.Text` | `string` | string | `"value"` |
|
||
| `pgtype.Int4` | `int` | number | `42` |
|
||
| `pgtype.Bool` | `bool` | boolean | `true` |
|
||
|
||
## Benefits
|
||
|
||
1. **Type Safety**: Compiler validates all database operations
|
||
2. **Clean APIs**: No `pgtype` in JSON responses
|
||
3. **Single Source**: Handler types define API contracts
|
||
4. **Reusable**: Services work with SSR, API, mobile
|
||
5. **Testable**: Each layer can be tested independently
|
||
```
|
||
|
||
**Documentation verification**:
|
||
- ✅ All field names match API (is_system, media_item_id, hidden_collections, collection_order)
|
||
- ✅ Examples use correct JSON structure
|
||
- ✅ Code snippets are accurate
|
||
- ✅ No references to old "smart sections" concept
|
||
- ✅ No references to removed "In Progress" section
|
||
- ✅ Unified collections terminology used consistently
|
||
- ✅ Architecture pattern documented
|
||
- ✅ Type conversion pattern explained
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
### Backend (Phases 1-3):
|
||
- ✅ Database schema updated with unified collections table
|
||
- ✅ System collections pre-seeded (user_id = NULL)
|
||
- ✅ Service layer returns database types (type safety)
|
||
- ✅ Queries generated and tested
|
||
|
||
### Architecture Pattern:
|
||
- ✅ Service returns `[]database.MediaItems` (not custom types)
|
||
- ✅ Handler converts to `handlers.SectionData` (following collections.go pattern)
|
||
- ✅ Single `SectionData` type in handlers (no duplication)
|
||
- ✅ Templates use `handlers.SectionData` directly (no template types)
|
||
|
||
### API (Phases 4-6):
|
||
- ✅ `/api/dashboard/sections` returns unified collections (system + user)
|
||
- ✅ `/api/dashboard/restore-system-collection` resets specific system collection
|
||
- ✅ Bruno tests pass with updated field names
|
||
- ✅ JSON uses `is_system: boolean` and `media_item_id: string`
|
||
- ✅ SSR `/dashboard` route pre-populates data
|
||
|
||
### Frontend (Phases 7-10):
|
||
- ✅ Dashboard uses "collection" terminology consistently
|
||
- ✅ System collections marked with badge
|
||
- ✅ Per-collection "Restore" buttons functional
|
||
- ✅ TypeScript uses correct field names
|
||
- ✅ Type definitions match Go handler types
|
||
|
||
### Tests (Phase 11):
|
||
- ✅ Unit tests for service layer (database types)
|
||
- ✅ Unit tests for handler layer (conversion logic)
|
||
- ✅ Integration tests for end-to-end flow
|
||
- ✅ Test coverage > 80%
|
||
|
||
### Documentation (Phase 13):
|
||
- ✅ API documentation updated with new architecture
|
||
- ✅ Type system pattern documented
|
||
- ✅ Architecture diagram included
|
||
- ✅ Developer guide explains type conversion
|
||
|
||
### Compliance:
|
||
- ✅ Unified collections architecture (no smart_section_types table)
|
||
- ✅ System collections are editable
|
||
- ✅ Per-collection restore functionality
|
||
- ✅ SSR for initial page load
|
||
- ✅ TypeScript for interactive updates
|
||
- ✅ Procedural/imperative style (no OOP)
|
||
- ✅ Event delegation via data-action attributes
|
||
- ✅ Handler types used directly in templates
|
||
- ✅ Single source of truth for types
|
||
- ✅ No duplicate type definitions
|
||
|
||
---
|
||
|
||
## Architecture Pattern
|
||
|
||
This plan follows the **established architecture pattern** from `collections.go`:
|
||
|
||
```
|
||
Database → Service → Handler → Template/API
|
||
↓ ↓ ↓ ↓
|
||
schema.sql database handlers.go dashboard.templ
|
||
↓ types types types
|
||
↓ ↓ ↓ ↓
|
||
pgtype.UUID → []database.MediaItems → []BookInfo → JSON
|
||
```
|
||
|
||
### Key Principles
|
||
|
||
1. **Single Source of Truth**
|
||
- Handler types define API contracts (`SectionData`, `BookInfo` in `collections.go`)
|
||
- No duplicate types in templates package
|
||
- TypeScript recreates handler types for frontend
|
||
|
||
2. **Type Safety at Database Layer**
|
||
- Services return `database.MediaItems` (with `pgtype.UUID`, `pgtype.Text`)
|
||
- Compiler catches schema changes immediately
|
||
- No accidental type mismatches
|
||
|
||
3. **Clean API Contracts**
|
||
- Handlers convert `pgtype` → `string`/`bool`/`int`
|
||
- JSON responses are predictable and clean
|
||
- Frontend receives simple types
|
||
|
||
4. **No Duplication**
|
||
- No `templates.SectionData` type
|
||
- No `api.SectionData` type
|
||
- Only `handlers.SectionData` (single source of truth)
|
||
|
||
### Why This Pattern?
|
||
|
||
Following the existing `collections.go` pattern ensures:
|
||
- ✅ **Consistency**: All handlers work the same way
|
||
- ✅ **Maintainability**: One pattern to learn and follow
|
||
- ✅ **Testability**: Each layer tested independently
|
||
- ✅ **Type Safety**: Database changes caught at compile time
|
||
- ✅ **API Stability**: Frontend unaffected by database changes
|
||
|
||
---
|
||
|
||
## Migration Notes
|
||
|
||
### Breaking Changes from Original Plan
|
||
|
||
1. **Schema**:
|
||
- Removed: `smart_section_types` table
|
||
- Added: `user_id`, `query_type`, `priority`, `is_system_collection` to collections table
|
||
- Updated: `hidden_sections` → `hidden_collections`, `section_order` → `collection_order`
|
||
|
||
2. **API**:
|
||
- Response field: `is_system: boolean` (not `type: string`)
|
||
- Response field: `media_item_id` (not `id`) for books
|
||
- Request body: Updated field names to use "collections" terminology
|
||
- Restore endpoint: Per-collection restore with `collection_name` parameter
|
||
|
||
3. **Architecture**:
|
||
- Service returns database types (not custom `SectionItems` type)
|
||
- Handler converts database types to API types
|
||
- Single `SectionData` type in handlers (following `collections.go` pattern)
|
||
- Templates use `handlers.SectionData` directly (no template types)
|
||
|
||
4. **Frontend**:
|
||
- Terminology changed from "section" to "collection"
|
||
- Added "System" badge for system collections
|
||
- Added restore defaults functionality
|
||
- TypeScript uses `is_system: boolean` and `media_item_id: string`
|
||
|
||
### Backward Compatibility
|
||
|
||
- ✅ Mobile apps will receive `is_system: true/false` instead of `type: "smart"/"collection"` - minor update needed
|
||
- ✅ API endpoint paths remain unchanged
|
||
- ✅ Response structure mostly unchanged (field types and names updated)
|
||
- ✅ TypeScript types match Go handler types exactly
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
This updated plan implements a **unified collections architecture** that:
|
||
|
||
1. **Eliminates Duplication** - Single table for all dashboard sections (no smart_section_types)
|
||
2. **Follows Established Pattern** - Uses existing `collections.go` architecture
|
||
3. **Maintains Type Safety** - Database types → Handler types → JSON
|
||
4. **Single Source of Truth** - Handler types define API contracts
|
||
5. **User Customization** - Editable system collections with restore functionality
|
||
|
||
### Architecture Highlights
|
||
|
||
**Service Layer** (`internal/services/dashboard_service.go`):
|
||
- Returns `[]database.MediaItems` (database types)
|
||
- Business logic reusable by SSR, API, mobile
|
||
- Type safety at database layer
|
||
|
||
**Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`):
|
||
- Converts `database.MediaItems` → `handlers.SectionData`
|
||
- Single `SectionData` type (no duplication)
|
||
- Clean JSON contracts
|
||
|
||
**Template Layer** (`templates/dashboard.templ`):
|
||
- Uses `handlers.SectionData` directly
|
||
- No template types (follows guidelines)
|
||
- SSR pre-populates data
|
||
|
||
**Frontend** (`web/src/dashboard.ts`, `web/src/types/dashboard.d.ts`):
|
||
- TypeScript recreates handler types (necessary due to pgtype)
|
||
- Matches Go struct field names exactly
|
||
- Single source of truth for API contracts
|
||
|
||
The plan maintains all compliance requirements while providing a more maintainable and extensible architecture that follows established patterns in the codebase.
|
||
|
||
---
|
||
|
||
## Implementation Checklist
|
||
|
||
Use this checklist to track implementation progress. Each item includes file path and verification step.
|
||
|
||
### Database Changes
|
||
- [ ] **database/schema/schema.sql**
|
||
- Add `user_dashboard_preferences` table
|
||
- Modify `collections` table (add columns, update constraints)
|
||
- Add 4 system collections (INSERT statements)
|
||
- Verification: `psql -f database/schema/schema.sql --dry-run`
|
||
|
||
- [ ] **Regenerate database code**
|
||
- Run: `cd internal/database && sqlc generate`
|
||
- Verification: `ls -la internal/database/models.go internal/database/queries.go`
|
||
|
||
### Service Layer
|
||
- [ ] **internal/services/dashboard_service.go** (CREATE)
|
||
- Implement all methods (GetDashboardSections, filterHiddenCollections, etc.)
|
||
- Verification: `go build ./internal/services/...`
|
||
|
||
### Database Queries
|
||
- [ ] **internal/database/queries/queries.sql** (MODIFY)
|
||
- Add dashboard queries (GetDashboardPreferences, GetSystemCollectionsForDashboard, etc.)
|
||
- Verification: `cd internal/database && sqlc generate`
|
||
|
||
### Handler Layer
|
||
- [ ] **internal/handlers/collections.go** (MODIFY)
|
||
- Add `SectionData` struct after `BookInfo`
|
||
- Add `PreviewCollection` method
|
||
- Verification: `rg "type SectionData struct" internal/handlers/collections.go`
|
||
|
||
- [ ] **internal/handlers/dashboard.go** (CREATE)
|
||
- Implement GetSections, UpdatePreferences, RestoreSystemCollection
|
||
- Implement BuildSections helper
|
||
- Verification: `go build ./internal/handlers/...`
|
||
|
||
### Router & Config (3 FILES - CRITICAL)
|
||
- [ ] **internal/router/router.go** (MODIFY)
|
||
- Add `DashboardService *services.DashboardService` to Config struct (line 58)
|
||
- Add `DashboardHandler *handlers.DashboardHandler` to Config struct (line 59)
|
||
- Verification: `rg "DashboardService|DashboardHandler" internal/router/router.go`
|
||
|
||
- [ ] **cmd/server/main.go** (MODIFY)
|
||
- Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 123)
|
||
- Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 124)
|
||
- Add to routerConfig: `DashboardService: dashboardService,` (after line 172)
|
||
- Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 173)
|
||
- Verification: `rg "DashboardService|DashboardHandler" cmd/server/main.go`
|
||
|
||
- [ ] **cmd/server/tests/test_helpers.go** (MODIFY)
|
||
- Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 419)
|
||
- Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 420)
|
||
- Add to routerConfig: `DashboardService: dashboardService,` (after line 458)
|
||
- Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 459)
|
||
- Verification: `rg "DashboardService|DashboardHandler" cmd/server/tests/test_helpers.go`
|
||
|
||
- [ ] **internal/router/dashboard.go** (CREATE)
|
||
- Register API routes (GET /api/dashboard/sections, PUT /api/dashboard/preferences, POST /api/dashboard/restore-system-collection)
|
||
- Verification: `rg "registerDashboardRoutes" internal/router/router.go`
|
||
|
||
- [ ] **internal/router/collections.go** (MODIFY)
|
||
- Register preview route: `collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)`
|
||
- Verification: `rg 'POST.*"/preview"' internal/router/collections.go`
|
||
|
||
- [ ] **internal/router/frontend.go** (MODIFY)
|
||
- Update /dashboard route to use DashboardService
|
||
- Add /custom-section route
|
||
- Verification: `rg "DashboardService" internal/router/frontend.go`
|
||
|
||
### Templates
|
||
- [ ] **templates/dashboard.templ** (MODIFY)
|
||
- Use handlers.SectionData, handlers.BookInfo
|
||
- Add library selector, settings modal, collections container
|
||
- Verification: `templ generate --path templates`
|
||
|
||
- [ ] **templates/custom_section.templ** (CREATE)
|
||
- Form for custom section builder
|
||
- Filter rules, manual book selection, live preview
|
||
- Verification: `templ generate --path templates`
|
||
|
||
### TypeScript
|
||
- [ ] **web/src/types/api.d.ts** (MODIFY)
|
||
- Add SectionData, BookInfo, DashboardPreferences interfaces
|
||
- Match Go handler types exactly
|
||
- Verification: `npm run build:ts`
|
||
|
||
- [ ] **web/src/dashboard.ts** (CREATE)
|
||
- Implement dashboard functions (scrollCarousel, switchLibrary, renderCollections, etc.)
|
||
- Use event delegation pattern
|
||
- Verification: `npm run build:ts && ls -la web/static/dashboard.js`
|
||
|
||
- [ ] **web/src/custom-section-builder.ts** (CREATE)
|
||
- Implement custom section builder (13+ filter fields, preview, search)
|
||
- Verification: `npm run build:ts && ls -la web/static/custom-section-builder.js`
|
||
|
||
### Bruno Tests
|
||
- [ ] **bruno/dashboard/get-dashboard-sections.bru** (UPDATE)
|
||
- Update response validation (is_system: boolean, media_item_id: string)
|
||
- Verification: `cd bruno/dashboard && bru run --env local`
|
||
|
||
- [ ] **bruno/dashboard/update-preferences.bru** (UPDATE)
|
||
- Update request body (hidden_collections, collection_order)
|
||
- Verification: `cd bruno/dashboard && bru run --env local`
|
||
|
||
- [ ] **bruno/dashboard/preview-collection.bru** (CREATE)
|
||
- Test preview endpoint with filter rules
|
||
- Verification: `cd bruno/dashboard && bru run --env local`
|
||
|
||
### Documentation
|
||
- [ ] **docs/user/dashboard.md** (UPDATE)
|
||
- Document new dashboard features
|
||
- Document custom section builder
|
||
- Document system collection restore functionality
|
||
|
||
- [ ] **docs/developer/api/dashboard/** (CREATE)
|
||
- Document GET /api/dashboard/sections
|
||
- Document PUT /api/dashboard/preferences
|
||
- Document POST /api/dashboard/restore-system-collection
|
||
|
||
- [ ] **docs/developer/api/collections/preview.md** (CREATE)
|
||
- Document POST /api/collections/preview
|
||
- Include request/response examples
|
||
- Document all 13+ filter fields and operators
|
||
|
||
### Testing
|
||
- [ ] **Integration tests** (CREATE)
|
||
- Test dashboard sections API
|
||
- Test preferences API
|
||
- Test custom section creation
|
||
- Test system collection restore
|
||
- Verification: `go test ./cmd/server/tests/... -v -run Dashboard`
|
||
|
||
### Build & Verification
|
||
- [ ] **Full build test**
|
||
- `go build ./cmd/server`
|
||
- `templ generate --path templates`
|
||
- `npm run build:ts`
|
||
- Verification: All commands succeed with exit code 0
|
||
|
||
- [ ] **Database migration**
|
||
- Backup: `cp database/schema/schema.sql database/schema/schema.sql.backup`
|
||
- Stop app: `podman compose down -v`
|
||
- Start app: `podman compose up -d`
|
||
- Verification: Check tables created: `psql bookhoard -c "\dt"`
|
||
|
||
- [ ] **Manual testing**
|
||
- Login as user
|
||
- Navigate to /dashboard
|
||
- Test library switching
|
||
- Test custom section builder
|
||
- Test dashboard settings modal
|
||
- Verification: All features work without errors
|
||
|
||
---
|
||
|
||
## Breaking Changes & Migration Guide
|
||
|
||
### For Mobile App Developers
|
||
|
||
1. **API Response Changes**:
|
||
- Field `is_system: boolean` replaces `type: string`
|
||
- Field `media_item_id: string` replaces `id: string` for books
|
||
- Request body uses `hidden_collections`, `collection_order` instead of `hidden_sections`, `section_order`
|
||
|
||
2. **New Endpoints**:
|
||
- `POST /api/dashboard/restore-system-collection` - Restore system collections to defaults
|
||
- `POST /api/collections/preview` - Preview custom collections before saving
|
||
|
||
3. **Action Required**:
|
||
- Update type definitions to match new API responses
|
||
- Update field names in API calls
|
||
- Consider adding support for custom section builder (optional)
|
||
|
||
### For Database Administrators
|
||
|
||
**This is a pre-production app. Database will be recreated.**
|
||
|
||
```bash
|
||
# Backup current schema (for reference)
|
||
cp database/schema/schema.sql database/schema/schema.sql.backup
|
||
|
||
# Stop application and delete volumes
|
||
podman compose down -v
|
||
|
||
# Start with new schema
|
||
podman compose up -d
|
||
```
|
||
|
||
**Warning**: All data will be lost. This is acceptable for pre-production deployment.
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
Implementation is complete when:
|
||
|
||
1. ✅ Database schema updated with unified collections architecture
|
||
2. ✅ All 4 system collections pre-seeded and visible on dashboard
|
||
3. ✅ Custom section builder functional with 13+ filter fields
|
||
4. ✅ Preview endpoint working (tested with Bruno)
|
||
5. ✅ Dashboard settings modal functional (reorder, hide/show, restore)
|
||
6. ✅ Library switching works via TypeScript
|
||
7. ✅ All Bruno tests passing
|
||
8. ✅ Documentation updated (user guide, API docs)
|
||
9. ✅ No Go compilation errors
|
||
10. ✅ No TypeScript compilation errors
|
||
11. ✅ Templates compile successfully
|
||
12. ✅ Integration tests passing
|
||
|
||
---
|
||
|
||
## Timeline Estimate
|
||
|
||
- Phase 1 (Database): 2-3 hours
|
||
- Phase 2 (Service): 3-4 hours
|
||
- Phase 3 (Queries): 1-2 hours
|
||
- Phase 4 (Handler): 2-3 hours
|
||
- Phase 4.5 (Preview): 30-45 min
|
||
- Phase 5 (Bruno): 1 hour
|
||
- Phase 6 (Types): 30 min
|
||
- Phase 7 (Router): 45 min
|
||
- Phase 8 (Frontend routes): 1-2 hours
|
||
- Phase 9 (Templates): 2 hours
|
||
- Phase 10 (TypeScript): 2-3 hours
|
||
- Phase 10.5 (Custom builder): 3-4 hours
|
||
- Phase 10.6 (Testing): 1 hour
|
||
|
||
**Total**: 20-26 hours (3-4 days for focused developer)
|
||
|
||
---
|
||
|
||
## Post-Implementation Tasks
|
||
|
||
1. **Performance Testing**
|
||
- Load test dashboard with 10,000+ items
|
||
- Test preview endpoint with complex filter rules
|
||
- Optimize queries if needed
|
||
|
||
2. **User Acceptance Testing**
|
||
- Test custom section builder with real users
|
||
- Gather feedback on UI/UX
|
||
- Iterate based on feedback
|
||
|
||
3. **Mobile App Coordination**
|
||
- Share updated API documentation
|
||
- Provide example requests/responses
|
||
- Coordinate release timeline
|
||
|
||
4. **Documentation**
|
||
- Update user guide with screenshots
|
||
- Record demo video of custom section builder
|
||
- Update API documentation
|
||
|
||
---
|
||
|
||
**End of Carousel Dashboard Plan**
|