From 810316694aca62eab0d8b72a7b4510c050414526 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 19 Feb 2026 20:59:19 -0500 Subject: [PATCH] feat(dashboard): implement Phase 4 API handlers for Carousel-style dashboard Add dashboard API endpoints with handler layer: Step 1: Add SectionData to collections.go - SectionData struct represents dashboard section (carousel of books) - Used by: Dashboard handler, Templates (SSR), API JSON responses - Shared type from collections.go (no duplicate definitions) - Fields: ID, IsSystem, Title, Description, Icon, Items, ViewAllURL, Priority Step 2: Create dashboard.go handler - DashboardHandler struct with injected database and dashboard service - GetSections: Returns dashboard sections as JSON (mobile apps, web UI TypeScript, plugins) * Validates library_id parameter * Fetches user dashboard preferences * Configurable limit (default 20, max 100) * Calls service layer for business logic * Converts service types to handler types for JSON serialization - UpdatePreferences: Saves dashboard preferences * Validates library_id * Upserts user dashboard preferences - RestoreSystemCollection: Resets system collection to defaults * Validates collection_name against allowed system collections * Deletes user's copy (system collection reappears automatically) - BuildSections: Converts service DashboardSection to handler SectionData * Converts database.MediaItems to handlers.BookInfo * Uses shared types from collections.go - getViewAllURL: Maps system collections to their view-all URLs - Reuses existing textToString helper from collections.go Architecture Compliance: - Generic API handler for reuse by SSR, mobile, plugins - Uses shared types from collections.go (SectionData, BookInfo) - IsSystem bool matches database field (no string conversion) - Single service method returns structured data (simpler, less bugs) - Handler just converts types (no matching logic needed) - Reusable by mobile apps, web UI, plugins --- internal/handlers/collections.go | 11 ++ internal/handlers/dashboard.go | 175 +++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 internal/handlers/dashboard.go diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index fef9fb6..7dd5e6c 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -70,6 +70,17 @@ type BookInfo struct { CoverImagePath string `json:"cover_image_path"` } +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"` +} + func (h *CollectionHandler) CreateCollection(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..d11ef3d --- /dev/null +++ b/internal/handlers/dashboard.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/services" + "net/http" + "strconv" + + "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), + } +} + +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 + } + } + + 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"}) + } + + sectionData := BuildSections(sections) + + return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) +} + +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) +} + +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"}) + } + + 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"}) +} + +func BuildSections(sections []services.DashboardSection) []SectionData { + var result []SectionData + + for _, ds := range sections { + 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 "" +}