diff --git a/API_CONSOLIDATION_PLAN.md b/API_CONSOLIDATION_PLAN.md
deleted file mode 100644
index 391a8bd..0000000
--- a/API_CONSOLIDATION_PLAN.md
+++ /dev/null
@@ -1,3093 +0,0 @@
-# API Consolidation Implementation Plan (REVISED - UPDATED)
-
-**Goal:** Consolidate fragmented user profile endpoints using simple handler composition (copy/paste existing logic)
-**Principle:** Surgical, line-by-line changes with verification at each step
-**Rule #1:** NO OVER-ENGINEERING - Keep It Simple, Copy/Paste Existing Logic
-**Strategy:** Combine handlers using URL param pattern - same handler for self-edit and admin modes
-
----
-
-## π― FINAL API DESIGN (Reference)
-
-```
-Self-Service:
- GET /api/auth/profile - Get own profile (UNCHANGED)
- PUT /api/auth/profile - Update own profile {username, email, first_name, last_name, theme}
- PUT /api/auth/password - Change own password {current_password, new_password, confirm_password}
- PUT /api/auth/theme - Quick theme toggle {theme} (UNCHANGED)
- DELETE /api/auth/profile - Delete your own account
-
-Admin:
- GET /api/auth/users - List all users (UNCHANGED)
- PUT /api/auth/profile/:id - Update user {username, email, first_name, last_name, theme, role?}
- PUT /api/auth/password/:id - Reset user password {new_password, confirm_password}
- PUT /api/auth/users/:id/max-devices - Update device limit (UNCHANGED)
- DELETE /api/auth/profile/:id - Delete user (with last-admin check)
-
-Remove (routes only, handlers kept for reuse):
- PUT /api/auth/username - Route removed, handler kept for UpdateUser to call
- PUT /api/auth/email - Route removed, handler kept for UpdateUser to call
-```
-
----
-
-## π§ͺ Available Test Helpers (From cmd/server/tests/test_helpers.go)
-
-**Setup Helpers:**
-- `setupTestServer(t)` - Creates complete test server with automatic cleanup via `t.Cleanup()`
-- `setupDeviceTest(t)` - Creates test environment for device tests (includes server + user + token)
-
-**User Creation:**
-- `createTestUserOnce(t, db)` - Creates test user with deterministic credentials (returns `UserTestData`)
- - Email: "testuser@example.com"
- - Username: "testuser"
- - Password: "Test@Pass123!"
- - Role: "admin"
-- `createRegularUserOnce(t, db)` - Creates a regular (non-admin) test user with unique credentials (returns `UserTestData`)
- - Email: unique (e.g., "regularuser-{uuid}@example.com")
- - Username: unique (e.g., "regularuser-{uuid}")
- - Password: "Test@Pass123!"
- - Role: "user"
- - **Use this for testing admin operations on regular users**
-- `getTestUserID(t, db)` - Gets or creates test user UUID (admin role)
-- `getRegularUserID(t, db)` - Gets or creates regular user UUID ("user" role)
-
-**Type Conversion:**
-- `uuidToPGType(u uuid.UUID) pgtype.UUID` - Converts `uuid.UUID` to `pgtype.UUID` for database operations
-
-**Authentication:**
-- `loginTestUser(t, ts, db)` - Logs in test user, returns JWT token
-- `loginUserWithCredentials(t, ts, email, password)` - Login with custom credentials
-- `loginRegularUser(t, ts, db)` - Login as regular user (role="user")
-
-**Database Verification:**
-- `verifyUserField(t, db, userID, field, expected)` - Verify user fields in DB (supports: email, first_name, last_name, username, theme)
-- `verifyDeviceCreated(t, db, deviceID, name, type, identifier)` - Verify device exists
-- `verifyDeviceDeleted(t, db, deviceID)` - Verify device deleted
-- `verifyMediaItemInDB(t, db, mediaID)` - Verify media item exists
-- `verifyMediaItemDeleted(t, db, mediaID)` - Verify media item deleted
-
-**Library Creation:**
-- `createTestLibraryWithFolder(t, ts, token, name, withFolder)` - Creates library with optional folder
-
-**Media Item Creation:**
-- `createTestMediaItemID(t, ts)` - Creates test media item and returns its ID
-
-**Other:**
-- `runConcurrent(t, maxConcurrent, fns)` - Runs functions concurrently for testing
-
----
-
-## π PHASE OVERVIEW
-
-1. **Phase 1:** Database Query Addition
-2. **Phase 2:** Extend UpdateProfile, Create Admin Handlers (copy/paste logic)
-3. **Phase 3:** Rename DeleteAccount to DeleteUser (use URL param)
-4. **Phase 4:** Router Updates (remove/add routes)
-5. **Phase 5:** Frontend (Profile page, Header updates, Login updates, Profile modal, Admin users list - 14 steps)
-6. **Phase 6:** Test Overhaul (add helpers, remove obsolete, add new - 7 steps)
-7. **Phase 7:** Bruno YAML Tests
-8. **Phase 8:** Documentation
-
----
-
-## PHASE 1: Database Query Addition
-
-**Goal:** Add UpdateUserRole query
-**Files:** 1 file, 5 lines added
-**Risk:** Lowest (no schema changes)
-
-### Step 1.1: Add UpdateUserRole Query
-
-**File:** `internal/database/queries/queries.sql`
-
-**Location:** After line 334 (after UpdatePassword query)
-
-**Action:** Add the following lines:
-
-```sql
--- name: UpdateUserRole :one
-UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
-RETURNING id, email, username, role;
-```
-
-**Verification:**
-```bash
-# Read back the added lines
-sed -n '335,340p' internal/database/queries/queries.sql
-```
-
-**Expected output:**
-```
--- name: UpdateUserRole :one
-UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
-RETURNING id, email, username, role;
-```
-
-### Step 1.2: Regenerate Database Code
-
-**Command:**
-```bash
-make sqlc
-```
-
-**Verification:**
-```bash
-# Check that UpdateUserRole was added to queries.sql.go
-grep -n "UpdateUserRole" internal/database/queries.sql.go
-
-# Check that UpdateUserRole was added to querier.go interface
-grep -n "UpdateUserRole" internal/database/querier.go
-
-# Verify the query function signature
-grep -A 5 "func (q \*Queries) UpdateUserRole" internal/database/queries.sql.go | head -1
-```
-
-**Expected:**
-- Line in `queries.sql.go`: `func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) {`
-- Line in `querier.go`: `UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)`
-
-**Commit:**
-```bash
-git add internal/database/
-git commit -m "feat(db): add UpdateUserRole query for admin user management"
-```
-
----
-
-## PHASE 2: Extend UpdateProfile and Create Admin Handlers
-
-**Goal:** Extend UpdateProfile for all fields, create UpdateUser and ResetUserPassword handlers
-**Files:** 1 file, ~230 lines modified
-**Risk:** Medium (modifying existing UpdateProfile handler, adding new handlers)
-**Approach:** Copy/paste conflict checking logic (NO function calls, keep it simple)
-
-### Step 2.1: Read Existing Handlers to Copy/Paste Logic
-
-**Read UpdateUsername to understand conflict checking pattern:**
-```bash
-sed -n '538,565p' internal/handlers/auth.go
-```
-
-**Read UpdateEmail to understand conflict checking pattern:**
-```bash
-sed -n '572,599p' internal/handlers/auth.go
-```
-
-**Read UpdatePassword to understand password hashing logic:**
-```bash
-sed -n '608,654p' internal/handlers/auth.go
-```
-
-**NOTE:** We will copy/paste the logic from these handlers, NOT call them as functions. This keeps handlers simple and avoids over-engineering.
-
-### Step 2.2: Extend UpdateProfileRequest Struct
-
-**Location:** Lines 76-79 in `internal/handlers/auth.go`
-
-**Current code:**
-```go
-type UpdateProfileRequest struct {
- FirstName string `json:"first_name,omitempty"`
- LastName string `json:"last_name,omitempty"`
-}
-```
-
-**Replace with:**
-```go
-type UpdateProfileRequest struct {
- Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
- Email string `json:"email,omitempty" validate:"omitempty,email"`
- FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
- LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
- Theme string `json:"theme,omitempty" validate:"omitempty"`
-}
-```
-
-### Step 2.3: Create AdminUpdateUserRequest Struct
-
-**Location:** After UpdateProfileRequest struct in `internal/handlers/auth.go`
-
-**Add after line 79:**
-```go
-type AdminUpdateUserRequest struct {
- Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
- Email string `json:"email,omitempty" validate:"omitempty,email"`
- FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
- LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
- Theme string `json:"theme,omitempty" validate:"omitempty"`
- Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
-}
-```
-
-### Step 2.4: Replace UpdateProfile Handler (Combined Self + Admin)
-
-**Location:** Lines 452-470 in `internal/handlers/auth.go`
-
-**Read current implementation:**
-```bash
-sed -n '452,470p' internal/handlers/auth.go
-```
-
-**Current code:**
-```go
-// UpdateProfile handles PUT /api/auth/profile
-func (h *AuthHandler) UpdateProfile(c echo.Context) error {
- user := MustGetAuthenticatedUser(c)
-
- var req UpdateProfileRequest
- if err := c.Bind(&req); err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
- }
-
- err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
- ID: user.ID,
- FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
- LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-
- return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
-}
-```
-
-**Replace with:**
-
-```go
-// UpdateProfile handles PUT /api/auth/profile (self-edit) and PUT /api/auth/profile/:id (admin edit)
-// Combined handler for both self-service and admin profile updates
-func (h *AuthHandler) UpdateProfile(c echo.Context) error {
- currentUser := MustGetAuthenticatedUser(c)
-
- // Determine target user: URL param (admin mode) or current user (self-edit)
- targetUserID := c.Param("id")
- var targetUserUUID pgtype.UUID
- isAdminMode := targetUserID != ""
-
- if isAdminMode {
- // Admin editing another user - validate admin role
- if currentUser.Role != "admin" {
- return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
- }
- parsedUUID, err := uuid.Parse(targetUserID)
- if err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
- }
- targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
- } else {
- // Self-edit mode
- targetUserUUID = currentUser.ID
- }
-
- var req AdminUpdateUserRequest
- 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()})
- }
-
- // Handle role change (admin mode only)
- if req.Role != "" && isAdminMode {
- if req.Role != "admin" && req.Role != "user" {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
- }
-
- // Check if this is the last admin (preventing demotion)
- users, err := h.db.ListUsers(c.Request().Context())
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
- }
-
- adminCount := 0
- currentTargetUserRole := ""
- for _, user := range users {
- if user.Role == "admin" {
- adminCount++
- }
- if user.ID.Bytes == targetUserUUID.Bytes {
- currentTargetUserRole = user.Role
- }
- }
-
- // Prevent demoting the last admin
- if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
- }
-
- // Update role
- _, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
- ID: targetUserUUID,
- Role: req.Role,
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
- }
-
- // Update username (if provided)
- if req.Username != "" {
- existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
- if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
- return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
- }
-
- err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
- ID: targetUserUUID,
- Username: req.Username,
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
- }
-
- // Update email (if provided)
- if req.Email != "" {
- existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
- if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
- return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
- }
-
- err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
- ID: targetUserUUID,
- Email: req.Email,
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
- }
-
- // Update first/last name (if provided)
- if req.FirstName != "" || req.LastName != "" {
- err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
- ID: targetUserUUID,
- FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
- LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
- }
-
- // Update theme (if provided)
- if req.Theme != "" {
- err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
- ID: targetUserUUID,
- Theme: pgtype.Text{String: req.Theme, Valid: true},
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
- }
-
- return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
-}
-```
-
-### Step 2.5: Extend UpdatePassword Handler (Combined Self + Admin)
-
-**Location:** Find the existing UpdatePassword handler in `internal/handlers/auth.go`
-
-**Find existing handler:**
-```bash
-grep -n "func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go
-```
-
-**Modify to handle both self-change (requires current password) and admin reset (no current password):**
-
-```go
-// UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset)
-// Combined handler for both self-service password change and admin password reset
-func (h *AuthHandler) UpdatePassword(c echo.Context) error {
- currentUser := MustGetAuthenticatedUser(c)
-
- // Determine target user: URL param (admin mode) or current user (self-change)
- targetUserID := c.Param("id")
- var targetUserUUID pgtype.UUID
- isAdminMode := targetUserID != ""
-
- if isAdminMode {
- // Admin resetting another user's password - validate admin role
- if currentUser.Role != "admin" {
- return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
- }
- parsedUUID, err := uuid.Parse(targetUserID)
- if err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
- }
- targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
- } else {
- // Self-change mode
- targetUserUUID = currentUser.ID
- }
-
- type PasswordRequest struct {
- CurrentPassword string `json:"current_password,omitempty"`
- NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
- ConfirmPassword string `json:"confirm_password" validate:"required"`
- }
-
- var req PasswordRequest
- 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()})
- }
-
- // Self-change mode: require current password
- if !isAdminMode {
- if req.CurrentPassword == "" {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
- }
-
- // Get current password hash
- passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
- }
-
- // Verify current password
- err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword))
- if err != nil {
- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
- }
- }
-
- // Validate new password matches confirmation
- if req.NewPassword != req.ConfirmPassword {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
- }
-
- // Hash new password
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
- }
-
- // Update password
- err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
- ID: targetUserUUID,
- PasswordHash: string(hashedPassword),
- })
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-
- return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
-}
-```
-
-**Verification:**
-```bash
-# Compile check
-go build ./internal/handlers
-
-# Verify handlers exist
-grep -n "func (h \*AuthHandler) UpdateProfile\|func (h \*AuthHandler) UpdatePassword" internal/handlers/auth.go
-```
-
-**Commit:**
-```bash
-git add internal/handlers/auth.go
-git commit -m "feat(auth): combine self-edit and admin handlers
-
-- UpdateProfile now handles both self-edit and admin edit (via URL param)
-- UpdatePassword now handles both self-change and admin reset (via URL param)
-- Single source of truth for profile and password update logic
-- Role field only allowed in admin mode (URL param present)
-- Admin mode validates admin role before allowing operations
-- Last-admin protection for role demotion
-- Removes code duplication between self-service and admin handlers
-"
-```
-
----
-
-## PHASE 3: Rename DeleteAccount to DeleteUser
-
-**Goal:** Rename handler, modify to use URL param instead of query param (already combined pattern)
-**Files:** 1 file, ~10 lines modified
-**Risk:** Low (simple rename and param change)
-
-**Note:** This handler already follows the combined pattern - no URL param = self-delete, with URL param = admin delete.
-
-### Step 3.1: Rename DeleteAccount to DeleteUser
-
-**Location:** Lines 658-731 in `internal/handlers/auth.go`
-
-**Read current implementation:**
-```bash
-sed -n '658,731p' internal/handlers/auth.go
-```
-
-**Step 3.1.1: Change function signature (line 658)**
-
-**From:**
-```go
-func (h *AuthHandler) DeleteAccount(c echo.Context) error {
-```
-
-**To:**
-```go
-func (h *AuthHandler) DeleteUser(c echo.Context) error {
-```
-
-### Step 3.2: Modify Query Param to URL Param (lines 661-678)
-
-**Location:** Lines 658-731 in `internal/handlers/auth.go`
-
-**Read current implementation:**
-```bash
-sed -n '658,731p' internal/handlers/auth.go
-```
-
-**Current code:**
-```go
- // Get target user ID from query parameter (for admin override) or use current user
- targetUserID := c.QueryParam("user_id")
- var targetUserUUID pgtype.UUID
-
- // If admin override is used, validate admin and use target
- if targetUserID != "" {
- // Admin override mode - check if current user is admin
- if currentUser.Role != "admin" {
- return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
- }
- parsedUUID, err := uuid.Parse(targetUserID)
- if err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
- }
- targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
- } else {
- targetUserUUID = currentUser.ID
- }
-```
-
-**Replace with:**
-```go
- // Get target user ID from URL param (admin mode) or use current user (self-deletion)
- targetUserID := c.Param("id")
- var targetUserUUID pgtype.UUID
-
- if targetUserID != "" {
- // Admin deletion mode
- if currentUser.Role != "admin" {
- return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
- }
- parsedUUID, err := uuid.Parse(targetUserID)
- if err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
- }
- targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
- } else {
- // Self-deletion mode
- targetUserUUID = currentUser.ID
- }
-```
-
-**Rest of function (lines 680-730) remains unchanged**
-
-**Verification:**
-```bash
-# Compile check
-go build ./internal/handlers
-
-# Verify function was renamed
-grep -n "func (h \*AuthHandler) DeleteUser" internal/handlers/auth.go
-```
-
-**Commit:**
-```bash
-git add internal/handlers/auth.go
-git commit -m "refactor(auth): rename DeleteAccount to DeleteUser
-
-- Rename DeleteAccount handler to DeleteUser
-- Modify DeleteUser to use URL param (:id) instead of query param (?user_id=)
-- Keep all last-admin protection logic
-- Self-deletion: DELETE /api/auth/profile (no ID)
-- Admin deletion: DELETE /api/auth/profile/:id (with user ID)
-"
-```
-
----
-
-## PHASE 4: Router Updates
-
-**Goal:** Register new routes, remove obsolete routes
-**Files:** 1 file, ~8 lines removed, ~8 lines added
-**Risk:** Low (route registration only)
-
-### Step 4.1: Read Current Router
-
-```bash
-cat internal/router/auth.go
-```
-
-### Step 4.2: Remove Obsolete Routes
-
-**Delete line 24:**
-```go
-protected.PUT("/auth/profile", cfg.AuthHandler.UpdateProfile)
-```
-
-**Delete lines 34-35:**
-```go
-authGroup.PUT("/email", cfg.AuthHandler.UpdateEmail)
-authGroup.PUT("/username", cfg.AuthHandler.UpdateUsername)
-```
-
-### Step 4.3: Add Consolidated Routes
-
-**Add after line 37 (after theme route):**
-
-```go
- // Profile management (combined handlers - self-edit)
- authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
- authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
-```
-
-**Add after line 42 (after max-devices route):**
-
-```go
- // Admin routes (same handlers, with URL param for target user)
- admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
- admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
- admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
-```
-
-### Step 4.4: Verify Router File
-
-**Complete file should look like:**
-
-```go
-package router
-
-import (
- "bookhoard/internal/handlers"
-
- "github.com/labstack/echo/v4"
-)
-
-func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
- e := cfg.Echo
-
- // Auth routes (no auth required, but rate limited)
- e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
- e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))
-
- // JWT middleware for protected routes
- jwtMiddleware := createJWTMiddleware(cfg)
-
- // Create protected route group
- protected := e.Group("/api", jwtMiddleware)
-
- // Protected auth routes
- protected.GET("/auth/profile", cfg.AuthHandler.GetProfile)
-
- // Refresh token endpoint (no authentication required - uses refresh token from body)
- e.POST("/api/auth/refresh", cfg.AuthHandler.RefreshAccessToken)
-
- // Logout endpoint (optional authentication - can revoke tokens if provided)
- e.POST("/api/auth/logout", cfg.AuthHandler.Logout)
-
- // Auth update routes
- authGroup := e.Group("/api/auth", createJWTMiddleware(cfg))
- authGroup.PUT("/password", cfg.AuthHandler.UpdatePassword)
- authGroup.PUT("/theme", cfg.AuthHandler.UpdateTheme)
-
- // Profile management (combined handlers - self-edit)
- authGroup.PUT("/profile", cfg.AuthHandler.UpdateProfile)
- authGroup.DELETE("/profile", cfg.AuthHandler.DeleteUser)
-
- // Admin-only routes (same handlers with URL param)
- admin := protected.Group("/auth", handlers.AdminMiddleware)
- admin.GET("/users", cfg.AuthHandler.ListUsers)
- admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
- admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
- admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
- admin.DELETE("/profile/:id", cfg.AuthHandler.DeleteUser)
-}
-```
-
-**Verification:**
-```bash
-# Compile check
-go build ./internal/router
-
-# Count routes (should be 10)
-grep -E "\.(GET|POST|PUT|DELETE)\(" internal/router/auth.go | wc -l
-
-# Verify handlers are reused (same handler, different routes)
-grep "UpdateProfile\|UpdatePassword\|DeleteUser" internal/router/auth.go
-```
-
-**Expected:** UpdateProfile, UpdatePassword, DeleteUser each appear twice (self + admin routes)
-
-**Commit:**
-```bash
-git add internal/router/auth.go
-git commit -m "refactor(router): consolidate auth routes with combined handlers
-
-- Remove PUT /api/auth/email (merged into /profile)
-- Remove PUT /api/auth/username (merged into /profile)
-- Add PUT /api/auth/profile (self-update)
-- Add DELETE /api/auth/profile (self-deletion)
-- Add PUT /api/auth/profile/:id (admin update - same handler)
-- Add PUT /api/auth/password/:id (admin reset - same handler)
-- Add DELETE /api/auth/profile/:id (admin delete - same handler)
-- Keep PUT /api/auth/theme unchanged (header quick-toggle)
-- Combined handlers reduce code duplication
-"
-```
-
----
-
-## PHASE 5: Frontend Routes & Templates
-
-**Goal:** Create /profile page, create /admin/users page with modal editing, update header links
-**Files:** 7 files (3 new: profile.templ, profile_modal.templ, admin_users.templ; 4 modified)
-**Risk:** Medium (template changes, HTMX modal pattern)
-
-### Step 5.1: Extend templates.User Struct
-
-**File:** `templates/types.go`
-
-**Current User struct:**
-```go
-type User struct {
- ID string
- Username string
- Email string
- Role string
- Theme string
-}
-```
-
-**Replace with:**
-```go
-type User struct {
- ID string
- Username string
- Email string
- Role string
- Theme string
- FirstName string
- LastName string
- CreatedAt time.Time
-}
-```
-
-**Add import at top of file (if not present):**
-```go
-import "time"
-```
-
-### Step 5.2: Create Profile Template
-
-**File:** `templates/profile.templ` (NEW FILE)
-
-**Create with following content:**
-
-```templ
-package templates
-
-templ Profile(user User) {
-
-
-
-
- Profile Settings - Bookhoard
-
-
-
-
-
-
- @Header(user, "/profile")
-
-
-
-
Profile Settings
-
Manage your account information and preferences
-
-
-
-
-
- @ProfileForm(user, "/api/auth/profile", true, false, false)
-
-
-
-
-
Danger Zone
-
Once you delete your account, there is no going back. Please be certain.
-
-
-
-
-
-
-
-
-
-}
-```
-
-### Step 5.3: Update Header Template
-
-**File:** `templates/header.templ`
-
-**Find the settings link (around line 117-118):**
-
-**Current:**
-```html
-Settings
-```
-
-**Change to:**
-```html
-Profile
-```
-
-**Regenerate template:**
-```bash
-templ generate
-```
-
-### Step 5.4: Update Admin Template
-
-**File:** `templates/admin.templ`
-
-**Find the profile link (around line 39-40):**
-
-**Current:**
-```html
-Profile Settings
-```
-
-**Change to:**
-```html
-Profile Settings
-```
-
-**Regenerate template:**
-```bash
-templ generate
-```
-
-### Step 5.5: Delete Admin Profile Template
-
-**File:** `templates/admin_profile.templ`
-
-**Delete the entire file:**
-```bash
-rm templates/admin_profile.templ
-```
-
-**Regenerate templates:**
-```bash
-templ generate
-```
-
-### Step 5.6: Add Frontend Route for Profile Page
-
-**File:** `internal/router/frontend.go`
-
-**Add after line 385 (in the frontendProtected routes section):**
-
-```go
-// Profile page (all users)
-frontendProtected.GET("/profile", func(c echo.Context) error {
- user, err := getTemplateUserWithTheme(c, cfg)
- if err != nil {
- return renderErrorPage(c, "Error loading user", "user_load_error")
- }
-
- var buf bytes.Buffer
- err = templates.Profile(user).Render(c.Request().Context(), &buf)
- if err != nil {
- return err
- }
- return c.HTML(http.StatusOK, buf.String())
-})
-```
-
-### Step 5.7: Update Login Template
-
-**File:** `templates/login.templ`
-
-**Line 3: Change signature from:**
-```templ
-templ Login(sessionExpired bool) {
-```
-
-**To:**
-```templ
-templ Login(sessionExpired bool, deleted bool) {
-```
-
-**After the sessionExpired block (around line 40), add:**
-
-```templ
-if deleted {
-
-
- Your account has been deleted successfully.
-
-
-}
-```
-
-**Regenerate template:**
-```bash
-templ generate
-```
-
-### Step 5.8: Update Login Frontend Route
-
-**File:** `internal/router/frontend.go`
-
-**Find the login route (around line 53):**
-```bash
-grep -n "GET.*login" internal/router/frontend.go
-```
-
-**Modify to read deleted query parameter:**
-
-**Current:**
-```go
-e.GET("/login", func(c echo.Context) error {
- var buf bytes.Buffer
- sessionExpired := c.QueryParam("session") == "expired"
- err := templates.Login(sessionExpired).Render(c.Request().Context(), &buf)
-```
-
-**Change to:**
-```go
-e.GET("/login", func(c echo.Context) error {
- var buf bytes.Buffer
- sessionExpired := c.QueryParam("session") == "expired"
- deleted := c.QueryParam("deleted") == "true"
- err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
-```
-
-**Verification:**
-```bash
-# Compile check
-go build ./internal/router
-```
-
-**Commit (for Steps 5.1-5.7):**
-```bash
-git add templates/ internal/router/frontend.go
-git commit -m "feat(frontend): add universal /profile page, remove admin-only profile
-
-- Create templates/profile.templ for all users
-- Update header link: /settings β /profile
-- Update admin template: /admin/profile β /profile
-- Remove templates/admin_profile.templ (no longer needed)
-- Add GET /profile route in frontend.go
-- Add account deletion with confirmation
-- Update Login template to show account deleted message
-- Update login route to pass deleted parameter
-"
-```
-
-### Step 5.9: Create Reusable Profile Form Component
-
-**Goal:** Create a reusable profile form component that can be used both for self-editing (Profile page) and admin editing other users (ProfileModal)
-
-**File:** `templates/profile_form.templ` (NEW FILE)
-
-**Create with following content:**
-
-```templ
-package templates
-
-// ProfileForm is a reusable form component for editing user profiles
-// - actionURL: The endpoint to submit the form to (e.g., "/api/auth/profile" or "/api/auth/profile/{id}")
-// - requireCurrentPassword: If true, show current password field (self-edit); if false, admin reset mode
-// - showRoleField: If true, show role dropdown (admin editing other users)
-// - showCancelButton: If true, show cancel button that closes modal (modal context); if false, no cancel button (page context)
-templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
-
-}
-```
-
-### Step 5.10: Create Profile Modal Component
-
-**Goal:** Modal wrapper for admin editing other users, using the reusable ProfileForm
-
-**File:** `templates/profile_modal.templ` (NEW FILE)
-
-**Create with following content:**
-
-```templ
-package templates
-
-// ProfileModal is used by admins to edit other users
-// Always uses: actionURL = "/api/auth/profile/{user.ID}", requireCurrentPassword = false, showRoleField = true, showCancelButton = true
-templ ProfileModal(user User) {
-
-
-
-
-
-
Edit User Profile
-
- { user.Username } ({ user.Email })
-
-
-
-
-
-
-
- @ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
-
-
-
-
-
-}
-```
-
-**Generate template:**
-```bash
-templ generate
-```
-
-### Step 5.11: Add Profile Modal Route
-
-**File:** `internal/router/frontend.go`
-
-**Add after the profile route (around line 400):**
-
-```go
-// Admin: Get profile modal for editing user
-frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c echo.Context) error {
- user, err := getTemplateUserWithTheme(c, cfg)
- if err != nil {
- return renderErrorPage(c, "Error loading user", "user_load_error")
- }
-
- // Get target user ID from URL
- targetUserID := c.Param("id")
- parsedUUID, err := uuid.Parse(targetUserID)
- if err != nil {
- return c.HTML(http.StatusBadRequest, "Invalid user ID
")
- }
-
- // Fetch target user
- targetUser, err := cfg.Queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true})
- if err != nil {
- return c.HTML(http.StatusNotFound, "User not found
")
- }
-
- // Convert to template user
- templateUser := toTemplateUser(targetUser)
-
- // Render modal (admin editing other user)
- var buf bytes.Buffer
- err = templates.ProfileModal(templateUser).Render(c.Request().Context(), &buf)
- if err != nil {
- return err
- }
- return c.HTML(http.StatusOK, buf.String())
-}))
-```
-
-**Note:** This adds a new admin-only endpoint that returns the profile modal HTML for any user. Server validates admin before returning modal.
-
-### Step 5.12: Create Admin Users List Page
-
-**Goal:** Admin page to view and manage all users with role toggles, delete actions, and modal profile editing
-
-**File:** `templates/admin_users.templ` (NEW FILE)
-
-**Create with following content:**
-
-```templ
-package templates
-
-templ AdminUsers(users []User, adminCount int, currentUserID string) {
-
-
-
-
- Users - Bookhoard Admin
-
-
-
-
-
-
- @Header(User{Username: "Admin", Role: "admin"}, "/admin/users")
-
-
-
-
-
-
-
User Management
-
Manage user accounts and permissions
-
-
-
-
-
-
-
- | Username |
- Email |
- Role |
- Created |
- Actions |
-
-
-
- @for _, user := range users {
-
-
-
-
-
- {user.Username}
- if user.ID == currentUserID {
- You
- }
-
-
- |
-
-
-
- {user.Email}
- |
-
-
-
- @if user.Role == "admin" && adminCount == 1 {
-
-
-
- β οΈ
-
- } else {
-
-
- }
- |
-
-
-
- {user.CreatedAt.Format("2006-01-02")}
- |
-
-
-
-
-
-
-
-
- @if user.Role == "admin" && adminCount == 1 {
-
-
- } else {
-
-
- }
-
- |
-
- }
-
-
-
-
-
-
-
- β οΈ Note: The system must always have at least one admin user. The last admin cannot be demoted or deleted.
-
-
-
-
-
-
-
-}
-```
-
-**Generate template:**
-```bash
-templ generate
-```
-
-
-### Step 5.13: Add Admin Users Route
-
-**File:** `internal/router/frontend.go`
-
-**Add after the /profile route (around line 395):**
-
-```go
-// Admin users page
-frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c echo.Context) error {
- user, err := getTemplateUserWithTheme(c, cfg)
- if err != nil {
- return renderErrorPage(c, "Error loading user", "user_load_error")
- }
-
- // Fetch all users
- users, err := cfg.Queries.ListUsers(c.Request().Context())
- if err != nil {
- return renderErrorPage(c, "Error loading users", "users_load_error")
- }
-
- // Count admins for UI protection
- adminCount := 0
- for _, u := range users {
- if u.Role == "admin" {
- adminCount++
- }
- }
-
- // Get current user ID for "You" badge
- currentUserID := user.ID
-
- var buf bytes.Buffer
- err = templates.AdminUsers(toTemplateUsers(users), adminCount, currentUserID).Render(c.Request().Context(), &buf)
- if err != nil {
- return err
- }
- return c.HTML(http.StatusOK, buf.String())
-}))
-```
-
-**Note:** You'll need helper functions to convert database users to template users. Add these in `internal/router/frontend.go`:
-
-```go
-import "github.com/google/uuid"
-
-// Convert single database user (from GetUser) to template user
-func toTemplateUser(dbUser database.GetUserRow) templates.User {
- return templates.User{
- ID: uuid.UUID(dbUser.ID.Bytes).String(),
- Username: dbUser.Username,
- Email: dbUser.Email,
- Role: dbUser.Role,
- FirstName: dbUser.FirstName.String,
- LastName: dbUser.LastName.String,
- Theme: dbUser.Theme.String,
- CreatedAt: dbUser.CreatedAt.Time,
- }
-}
-
-// Convert single database user (from ListUsers) to template user
-func toTemplateUserFromList(dbUser database.ListUsersRow) templates.User {
- return templates.User{
- ID: uuid.UUID(dbUser.ID.Bytes).String(),
- Username: dbUser.Username,
- Email: dbUser.Email,
- Role: dbUser.Role,
- FirstName: dbUser.FirstName.String,
- LastName: dbUser.LastName.String,
- Theme: dbUser.Theme.String,
- CreatedAt: dbUser.CreatedAt.Time,
- }
-}
-
-// Convert slice of database users (from ListUsers) to template users
-func toTemplateUsers(dbUsers []database.ListUsersRow) []templates.User {
- users := make([]templates.User, len(dbUsers))
- for i, u := range dbUsers {
- users[i] = toTemplateUserFromList(u)
- }
- return users
-}
-```
-
-### Step 5.14: Update Admin Navigation Link
-
-**File:** `templates/admin.templ`
-
-**Add link to users page (around line 40):**
-
-**Find this section:**
-```templ
-
-```
-
-**Add after it:**
-```templ
-
-```
-
-**Regenerate template:**
-```bash
-templ generate
-```
-
-**Verification:**
-```bash
-# Compile check
-go build ./internal/router
-
-# Verify template exists
-ls templates/admin_users.templ
-
-# Verify route exists
-grep "admin.GET.*users" internal/router/frontend.go
-```
-
-**Commit:**
-```bash
-git add templates/ internal/router/frontend.go
-git commit -m "feat(frontend): add admin users management with modal profile editing
-
-- Create templates/profile_form.templ (reusable form component)
-- Create templates/profile_modal.templ (admin-only modal using ProfileForm)
-- Add GET /admin/users/:id/profile-modal route (admin only, server validates)
-- Create templates/admin_users.templ with user management table
-- Add GET /admin/users route with SSR initial data fetch
-- HTMX Edit button loads modal (no page navigation, secure)
-- HTMX role toggles with page reload after success
-- HTMX delete buttons with page reload after success
-- Last-admin protection in UI (disabled states for role/delete)
-- ProfileForm supports both self-edit and admin-edit modes via parameters
-- Progressive enhancement - page reloads after successful actions
-- Server-side security - admin-only access validated before modal sent
-"
-```
-
----
-
-## PHASE 6: Test Overhaul
-
-**Goal:** Remove obsolete tests, add comprehensive tests
-**Files:** 1 file, ~50 tests removed, ~40 tests added
-**Risk:** Highest (test changes must be thorough)
-
-### Step 6.1: Backup Current Tests
-
-```bash
-git stash push -m "Backup tests before API consolidation overhaul"
-```
-
-### Step 6.2: Read Current Test Structure
-
-```bash
-# Count tests in user_test.go
-grep -c "t.Run(" cmd/server/tests/user_test.go
-
-# List all test functions
-grep "^func Test" cmd/server/tests/user_test.go
-```
-
-### Step 6.3: Add New Test Helper Functions
-
-**File:** `cmd/server/tests/test_helpers.go`
-
-**Add these helper functions to the file:**
-
-```go
-// uuidToPGType converts uuid.UUID to pgtype.UUID for database operations
-func uuidToPGType(u uuid.UUID) pgtype.UUID {
- return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
-}
-
-// createRegularUserOnce creates a regular (non-admin) test user with unique credentials
-// Use this for testing admin operations on regular users
-func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
- ctx := context.Background()
- uniqueID := uuid.New().String()[:8]
-
- email := "regular-" + uniqueID + "@example.com"
- username := "regular-" + uniqueID
- password := "Test@Pass123!"
-
- // Hash the password
- passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- require.NoError(t, err, "Failed to hash password")
-
- // Create user with regular role
- pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
- newUser, err := db.CreateUser(ctx, database.CreateUserParams{
- ID: pgUserID,
- Email: email,
- Username: username,
- PasswordHash: string(passwordHash),
- Role: "user",
- })
- require.NoError(t, err, "Failed to create regular user")
-
- userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
- require.NoError(t, err, "Failed to convert user ID to UUID")
-
- return UserTestData{
- ID: userUUID,
- Email: email,
- Username: username,
- Password: password,
- }
-}
-```
-
-### Step 6.4: Remove Obsolete Tests
-
-**File:** `cmd/server/tests/user_test.go`
-
-**Find and delete these test blocks:**
-
-1. **PUT /api/auth/email tests** - Find lines:
-```bash
-grep -n "PUT /api/auth/email" cmd/server/tests/user_test.go
-```
-
-Delete all tests under this category (approximately lines 82-166)
-
-2. **PUT /api/auth/username tests** - Find lines:
-```bash
-grep -n "PUT /api/auth/username" cmd/server/tests/user_test.go
-```
-
-Delete all tests under this category (approximately lines 167-215)
-
-**Keep:** GET profile, PUT profile (will modify), PUT password, PUT theme, DELETE account (will modify)
-
-### Step 6.5: Modify Existing Tests
-
-**Modify PUT /api/auth/profile test:**
-
-**Find the test:**
-```bash
-grep -n "PUT /api/auth/profile - Update profile" cmd/server/tests/user_test.go
-```
-
-**Update the test body to include new fields:**
-
-```go
- t.Run("PUT /api/auth/profile - Update profile with all fields", func(t *testing.T) {
- username := "updateduser"
- email := "updated@example.com"
- firstName := "Updated"
- lastName := "User"
- theme := "dracula"
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": username,
- "email": email,
- "first_name": firstName,
- "last_name": lastName,
- "theme": theme,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Mock handler logic
- w.Write([]byte(`{"message":"profile updated successfully"}`))
- })
-
- handler.ServeHTTP(rec, req)
- assert.Equal(t, http.StatusOK, rec.Code)
- })
-```
-
-**Modify DELETE /api/auth/account tests to use new endpoints:**
-
-**Find all occurrences:**
-```bash
-grep -n "DELETE /api/auth/account" cmd/server/tests/user_test.go
-```
-
-**Replace all:**
-```go
-req := httptest.NewRequest("DELETE", "/api/auth/account", nil)
-```
-
-**With:**
-```go
-req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
-```
-
-**Replace all:**
-```go
-req := httptest.NewRequest("DELETE", "/api/auth/account?user_id="+userID.String(), nil)
-```
-
-**With:**
-```go
-req := httptest.NewRequest("DELETE", "/api/auth/profile/"+userID.String(), nil)
-```
-
-### Step 6.6: Add New Complete Tests
-
-**NOTE:** `createTestUserOnce()` creates an admin user (role="admin"). For admin tests:
-- Use `createTestUserOnce(t, setup.DB)` for the admin
-- Use `createRegularUserOnce(t, setup.DB)` for a regular user (role="user") to test admin operations on regular users
-- Use `uuidToPGType(user.ID)` to convert `uuid.UUID` to `pgtype.UUID` for database operations
-
-**Add at end of file before final closing brace:**
-
-```go
-// TestUpdateProfileConsolidated tests the consolidated profile update endpoint
-func TestUpdateProfileConsolidated(t *testing.T) {
- t.Run("PUT /api/auth/profile - Update username only", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create test user using helper
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- username := "newusername"
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": username,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify username was updated
- updatedUser, err := setup.DB.GetUserByUsername(context.Background(), username)
- assert.NoError(t, err)
- assert.Equal(t, username, updatedUser.Username)
- })
-
- t.Run("PUT /api/auth/profile - Update email only", func(t *testing.T) {
- setup := setupTestServer(t)
-
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- email := "newemail@example.com"
- jsonData, _ := json.Marshal(map[string]interface{}{
- "email": email,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify email was updated
- updatedUser, err := setup.DB.GetUserByEmail(context.Background(), email)
- assert.NoError(t, err)
- assert.Equal(t, email, updatedUser.Email)
- })
-
- t.Run("PUT /api/auth/profile - Update theme only", func(t *testing.T) {
- setup := setupTestServer(t)
-
- user := createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- theme := "dracula"
- jsonData, _ := json.Marshal(map[string]interface{}{
- "theme": theme,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify theme was updated
- updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
- assert.NoError(t, err)
- assert.Equal(t, theme, updatedUser.Theme.String)
- })
-
- t.Run("PUT /api/auth/profile - Update all fields", func(t *testing.T) {
- setup := setupTestServer(t)
-
- user := createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": "newalluser",
- "email": "newall@example.com",
- "first_name": "NewAll",
- "last_name": "User",
- "theme": "nord",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify all fields updated
- updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
- assert.NoError(t, err)
- assert.Equal(t, "newalluser", updatedUser.Username)
- assert.Equal(t, "newall@example.com", updatedUser.Email)
- })
-
- t.Run("PUT /api/auth/profile - Username conflict", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create first user using helper
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create second user manually with different username
- passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
- pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
- _, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
- ID: pgUserID,
- Email: "user2@example.com",
- Username: "user2",
- PasswordHash: passwordHash,
- Role: "user",
- })
- require.NoError(t, err)
-
- // Try to update user1 to user2's username
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": "user2",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusConflict, rec.Code)
- })
-
- t.Run("PUT /api/auth/profile - Email conflict", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create first user using helper
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create second user manually with different email
- passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
- pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
- _, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
- ID: pgUserID,
- Email: "email2@example.com",
- Username: "email2user",
- PasswordHash: passwordHash,
- Role: "user",
- })
- require.NoError(t, err)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "email": "email2@example.com",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusConflict, rec.Code)
- })
-
- t.Run("PUT /api/auth/profile - No auth", func(t *testing.T) {
- setup := setupTestServer(t)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": "test",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
- // No Authorization header
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusUnauthorized, rec.Code)
- })
-}
-
-// TestUpdateProfileAdminMode tests admin updating another user (same handler, with URL param)
-func TestUpdateProfileAdminMode(t *testing.T) {
- t.Run("PUT /api/auth/profile/:id - Admin update username", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user to update
- passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
- pgUserID := pgtype.UUID{Bytes: [16]byte(uuid.New()), Valid: true}
- user, err := setup.DB.CreateUser(context.Background(), database.CreateUserParams{
- ID: pgUserID,
- Email: "regularuser@example.com",
- Username: "regularuser",
- PasswordHash: passwordHash,
- Role: "user",
- })
- require.NoError(t, err)
- userUUID, _ := uuid.FromBytes(user.ID.Bytes[0:16])
-
- newUsername := "updateduser"
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": newUsername,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile/"+userUUID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify username updated
- updatedUser, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
- assert.NoError(t, err)
- assert.Equal(t, newUsername, updatedUser.Username)
- })
-
- t.Run("PUT /api/auth/profile/:id - Admin promote user to admin", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user to promote
- user := createRegularUserOnce(t, setup.DB)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "role": "admin",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile/"+user.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify role changed
- updatedUser, err := setup.DB.GetUser(context.Background(), uuidToPGType(user.ID))
- assert.NoError(t, err)
- assert.Equal(t, "admin", updatedUser.Role)
- })
-
- t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create only one admin (createTestUserOnce creates admin)
- lastAdmin := createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Try to demote self
- jsonData, _ := json.Marshal(map[string]interface{}{
- "role": "user",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile/"+lastAdmin.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, rec.Body.String(), "cannot demote the last admin")
- })
-
- t.Run("PUT /api/auth/profile/:id - Non-admin tries update", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create admin first
- targetAdmin := createTestUserOnce(t, setup.DB)
- // Create regular user (non-admin)
- regularUser := createRegularUserOnce(t, setup.DB)
- // Login as regular user
- token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "username": "hacked",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusForbidden, rec.Code)
- })
-
- t.Run("PUT /api/auth/profile/:id - Invalid role", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user to update
- targetUser := createRegularUserOnce(t, setup.DB)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "role": "superadmin",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/profile/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, rec.Body.String(), "invalid role")
- })
-}
-
-// TestUpdatePasswordAdminMode tests admin password reset (same handler, with URL param)
-func TestUpdatePasswordAdminMode(t *testing.T) {
- t.Run("PUT /api/auth/password/:id - Admin reset password", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user to reset password for
- targetUser := createRegularUserOnce(t, setup.DB)
-
- newPassword := "NewPassword123!"
- jsonData, _ := json.Marshal(map[string]interface{}{
- "new_password": newPassword,
- "confirm_password": newPassword,
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify password changed by attempting login with new password
- loginData, _ := json.Marshal(map[string]interface{}{
- "login": targetUser.Username,
- "password": newPassword,
- })
-
- loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginData))
- loginRec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq)
-
- assert.Equal(t, http.StatusOK, loginRec.Code)
- })
-
- t.Run("PUT /api/auth/password/:id - Non-admin tries reset", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create admin first
- targetAdmin := createTestUserOnce(t, setup.DB)
- // Create regular user (non-admin)
- regularUser := createRegularUserOnce(t, setup.DB)
- // Login as regular user
- token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "new_password": "hacked",
- "confirm_password": "hacked",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/password/"+targetAdmin.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusForbidden, rec.Code)
- })
-
- t.Run("PUT /api/auth/password/:id - Mismatched passwords", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user
- targetUser := createRegularUserOnce(t, setup.DB)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "new_password": "password1",
- "confirm_password": "password2",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, rec.Body.String(), "passwords do not match")
- })
-
- t.Run("PUT /api/auth/password/:id - Invalid password format", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user
- targetUser := createRegularUserOnce(t, setup.DB)
-
- jsonData, _ := json.Marshal(map[string]interface{}{
- "new_password": "123",
- "confirm_password": "123",
- })
-
- req := httptest.NewRequest("PUT", "/api/auth/password/"+targetUser.ID.String(), bytes.NewBuffer(jsonData))
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- })
-}
-
-// TestDeleteUserConsolidated tests account deletion
-func TestDeleteUserConsolidated(t *testing.T) {
- t.Run("DELETE /api/auth/profile - User deletes self successfully", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create admin first so we have at least 2 users (admin won't delete self)
- _ = createTestUserOnce(t, setup.DB)
-
- // Create regular user who will delete themselves
- selfDeletingUser := createRegularUserOnce(t, setup.DB)
- token := loginUserWithCredentials(t, setup.Server, selfDeletingUser.Email, selfDeletingUser.Password)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify user deleted
- _, err := setup.DB.GetUser(context.Background(), uuidToPGType(selfDeletingUser.ID))
- assert.Error(t, err)
- })
-
- t.Run("DELETE /api/auth/profile - Delete without auth", func(t *testing.T) {
- setup := setupTestServer(t)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
- // No auth
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusUnauthorized, rec.Code)
- })
-
- t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create only one admin (createTestUserOnce creates admin)
- lastAdmin := createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile", nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
- })
-
- t.Run("DELETE /api/auth/profile/:id - Admin deletes user", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- // Create regular user to delete
- targetUser := createRegularUserOnce(t, setup.DB)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetUser.ID.String(), nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusOK, rec.Code)
-
- // Verify user deleted
- _, err := setup.DB.GetUser(context.Background(), uuidToPGType(targetUser.ID))
- assert.Error(t, err)
- })
-
- t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create only one admin (createTestUserOnce creates admin)
- lastAdmin := createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile/"+lastAdmin.ID.String(), nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, rec.Body.String(), "cannot delete the last admin")
- })
-
- t.Run("DELETE /api/auth/profile/:id - Non-admin tries delete", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Create admin first
- targetAdmin := createTestUserOnce(t, setup.DB)
- // Create regular user (non-admin)
- regularUser := createRegularUserOnce(t, setup.DB)
- // Login as regular user
- token := loginUserWithCredentials(t, setup.Server, regularUser.Email, regularUser.Password)
-
- req := httptest.NewRequest("DELETE", "/api/auth/profile/"+targetAdmin.ID.String(), nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusForbidden, rec.Code)
- })
-
- t.Run("DELETE /api/auth/profile/:id - Delete non-existent user", func(t *testing.T) {
- setup := setupTestServer(t)
-
- // Admin user (createTestUserOnce creates admin role)
- _ = createTestUserOnce(t, setup.DB)
- token := loginTestUser(t, setup.Server, setup.DB)
-
- fakeUUID := uuid.New()
- req := httptest.NewRequest("DELETE", "/api/auth/profile/"+fakeUUID.String(), nil)
- req.Header.Set("Authorization", "Bearer "+token)
-
- rec := httptest.NewRecorder()
- setup.Server.Config.Handler.ServeHTTP(rec, req)
-
- assert.Equal(t, http.StatusNotFound, rec.Code)
- })
-}
-```
-
-### Step 6.7: Run Tests
-
-```bash
-# Run user tests
-go test cmd/server/tests/user_test.go -v
-```
-
-**Expected:** All new tests pass
-
-**Fix any failures** before proceeding.
-
-**Commit:**
-```bash
-git add cmd/server/tests/test_helpers.go cmd/server/tests/user_test.go
-git commit -m "test(auth): overhaul user profile tests for API consolidation
-
-- Add createRegularUserOnce helper for non-admin test users
-- Add uuidToPGType helper for UUID type conversion
-- Remove 9 obsolete tests (username, email endpoints)
-- Modify existing profile test for new fields
-- Modify DELETE account tests for new endpoints
-- Add TestUpdateProfileConsolidated (7 tests) - self-edit mode
-- Add TestUpdateProfileAdminMode (5 tests) - admin mode via URL param
-- Add TestUpdatePasswordAdminMode (4 tests) - admin reset via URL param
-- Add TestDeleteUserConsolidated (7 tests) - combined self/admin delete
-- Cover all scenarios: self-update, admin update, role changes
-- Test last-admin protection thoroughly
-- Test conflict detection (username, email)
-- Test combined handlers work in both modes
-"
-```
-
----
-
-## PHASE 7: Bruno OpenCollection Tests
-
-**Goal:** Update/create Bruno YAML tests
-**Files:** ~5 files (0 delete, 2 modify, 3 create)
-**Risk:** Low (contract testing)
-
-### Step 7.1: Check Current Bruno Structure
-
-```bash
-find bruno -name "*.yml" | head -20
-```
-
-### Step 7.2: Delete Obsolete Bruno Tests
-
-```bash
-# Find username/email tests if they exist
-find bruno -name "*username*" -o -name "*email*"
-```
-
-**Delete if found.**
-
-### Step 7.3: Update Existing Profile Test
-
-**File:** `bruno/user/profile/Update Profile.yml`
-
-**Current content should be updated to:**
-
-```yaml
-info:
- name: Update Profile
- type: http
- seq: 4
-http:
- method: PUT
- url: '{{base_url}}/api/auth/profile'
- auth: inherit
- body:
- type: json
- jsonBody: |-
- {
- "username": "updateduser",
- "email": "updated@example.com",
- "first_name": "Updated",
- "last_name": "User",
- "theme": "dracula"
- }
-
-docs: |-
- ## Update User Profile
-
- Updates the authenticated user's profile information.
-
- **Method:** PUT
-
- **Endpoint:** /api/auth/profile
-
- **Authentication:** Required (Bearer token)
-
- **Request Body:**
- - `username` (string, optional): New username (must be unique)
- - `email` (string, optional): New email address (must be unique)
- - `first_name` (string, optional): First name
- - `last_name` (string, optional): Last name
- - `theme` (string, optional): Theme preference
-
- **Response:**
- - `message` (string): Success message
-
- **Status Codes:**
- - 200: Success
- - 400: Invalid request
- - 401: Unauthorized
- - 409: Username or email already taken
-```
-
-### Step 7.4: Create Admin Update User Test
-
-**File:** `bruno/user/admin/Update User.yml`
-
-```yaml
-info:
- name: Update User
- type: http
- seq: 10
-http:
- method: PUT
- url: '{{base_url}}/api/auth/profile/{{user_id}}'
- auth: inherit
- body:
- type: json
- jsonBody: |-
- {
- "username": "newusername",
- "email": "newemail@example.com",
- "role": "admin"
- }
-
-docs: |-
- ## Admin Update User
-
- Admin-only endpoint to update another user's profile.
-
- **Method:** PUT
-
- **Endpoint:** /api/auth/profile/:id
-
- **Authentication:** Required (Admin Bearer token)
-
- **Request Body:**
- - `username` (string, optional): New username
- - `email` (string, optional): New email
- - `first_name` (string, optional): First name
- - `last_name` (string, optional): Last name
- - `theme` (string, optional): Theme preference
- - `role` (string, optional): Role ("user" or "admin") - admin only
-
- **Status Codes:**
- - 200: Success
- - 400: Invalid role or cannot demote last admin
- - 403: Not an admin
- - 404: User not found
- - 409: Username or email already taken
-```
-
-### Step 7.5: Create Admin Reset Password Test
-
-**File:** `bruno/user/admin/Reset User Password.yml`
-
-```yaml
-info:
- name: Reset User Password
- type: http
- seq: 11
-http:
- method: PUT
- url: '{{base_url}}/api/auth/password/{{user_id}}'
- auth: inherit
- body:
- type: json
- jsonBody: |-
- {
- "new_password": "NewSecurePassword123!",
- "confirm_password": "NewSecurePassword123!"
- }
-
-docs: |-
- ## Admin Reset User Password
-
- Admin-only endpoint to reset another user's password.
-
- **Method:** PUT
-
- **Endpoint:** /api/auth/password/:id
-
- **Authentication:** Required (Admin Bearer token)
-
- **Request Body:**
- - `new_password` (string, required): New password
- - `confirm_password` (string, required): Must match new_password
-
- **Status Codes:**
- - 200: Success
- - 400: Passwords do not match or invalid format
- - 403: Not an admin
- - 404: User not found
-```
-
-### Step 7.6: Create Delete Account Tests
-
-**File:** `bruno/user/admin/Delete User.yml`
-
-```yaml
-info:
- name: Delete User
- type: http
- seq: 12
-http:
- method: DELETE
- url: '{{base_url}}/api/auth/profile/{{user_id}}'
- auth: inherit
-
-docs: |-
- ## Delete User Account
-
- Delete a user account. Users can delete their own account, admins can delete any user.
-
- **Method:** DELETE
-
- **Endpoint:** /api/auth/profile (self) or /api/auth/profile/:id (admin)
-
- **Authentication:** Required (Bearer token)
-
- **Status Codes:**
- - 200: Success
- - 400: Cannot delete the last admin
- - 403: Not an admin (when trying to delete another user)
- - 404: User not found
-```
-
-**Commit:**
-```bash
-git add bruno/
-git commit -m "test(bruno): update auth tests for API consolidation
-
-- Delete obsolete tests: Update Username.yml, Update Email.yml
-- Update Update Profile.yml with new consolidated endpoint
-- Add Update User.yml for admin user management
-- Add Reset User Password.yml for admin password resets
-- Add Delete User.yml for account deletion (self and admin)
-- Document all scenarios with proper request bodies
-"
-```
-
----
-
-## PHASE 8: Documentation
-
-**Goal:** Update API and user documentation
-**Files:** ~5 files (create/update)
-**Risk:** Lowest
-
-### Step 8.1: Update API Documentation
-
-**Create:** `docs/developer/api/auth/profile.md`
-
-```markdown
-# Profile Management
-
-## Get Profile
-
-Retrieve the authenticated user's profile information.
-
-\`\`\`
-GET /api/auth/profile
-Authorization: Bearer {token}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "id": "uuid",
- "email": "user@example.com",
- "username": "username",
- "first_name": "John",
- "last_name": "Doe",
- "role": "user"
-}
-\`\`\`
-
-## Update Profile (Self)
-
-Update your own profile information. All fields are optional.
-
-\`\`\`
-PUT /api/auth/profile
-Authorization: Bearer {token}
-
-{
- "username": "newusername", // optional
- "email": "new@example.com", // optional
- "first_name": "John", // optional
- "last_name": "Doe", // optional
- "theme": "dracula" // optional
-}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "profile updated successfully"
-}
-\`\`\`
-
-### Errors
-
-- `409 Conflict` - Username or email already taken
-- `401 Unauthorized` - No token provided
-- `400 Bad Request` - Invalid request data
-
-## Update Profile (Admin)
-
-Update another user's profile information. All fields are optional.
-
-\`\`\`
-PUT /api/auth/profile/:id
-Authorization: Bearer {admin_token}
-
-{
- "username": "newusername", // optional
- "email": "new@example.com", // optional
- "first_name": "John", // optional
- "last_name": "Doe", // optional
- "theme": "dracula", // optional
- "role": "admin" // optional, admin only
-}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "user updated successfully"
-}
-\`\`\`
-
-### Errors
-
-- `403 Forbidden` - Not an admin
-- `404 Not Found` - User not found
-- `400 Bad Request` - Invalid role or cannot demote last admin
-- `409 Conflict` - Username or email already taken
-
-## Delete Account (Self)
-
-Delete your own account.
-
-**β οΈ WARNING:** This action cannot be undone. All data will be permanently deleted.
-
-\`\`\`
-DELETE /api/auth/profile
-Authorization: Bearer {token}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "account deleted successfully"
-}
-\`\`\`
-
-### Errors
-
-- `400 Bad Request` - Cannot delete the last admin account
-- `401 Unauthorized` - No token provided
-
-## Delete Account (Admin)
-
-Delete another user's account.
-
-\`\`\`
-DELETE /api/auth/profile/:id
-Authorization: Bearer {admin_token}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "user account deleted successfully"
-}
-\`\`\`
-
-### Errors
-
-- `403 Forbidden` - Not an admin
-- `404 Not Found` - User not found
-- `400 Bad Request` - Cannot delete the last admin account
-
-**Security Note:** The system must always have at least one admin account. The last admin cannot be deleted (even by themselves).
-```
-
-**Create:** `docs/developer/api/auth/password.md`
-
-```markdown
-# Password Management
-
-## Change Password (Self)
-
-Change your own password by providing the current password.
-
-\`\`\`
-PUT /api/auth/password
-Authorization: Bearer {token}
-
-{
- "current_password": "oldpass123",
- "new_password": "newpass123",
- "confirm_password": "newpass123"
-}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "password updated successfully"
-}
-\`\`\`
-
-### Errors
-
-- `401 Unauthorized` - Current password is incorrect
-- `400 Bad Request` - Passwords do not match or too short
-
-## Reset Password (Admin)
-
-Reset another user's password without requiring the current password. Used for password recovery or account management.
-
-\`\`\`
-PUT /api/auth/password/:id
-Authorization: Bearer {admin_token}
-
-{
- "new_password": "newpass123",
- "confirm_password": "newpass123"
-}
-\`\`\`
-
-### Response
-
-\`\`\`json
-{
- "message": "password reset successfully"
-}
-\`\`\`
-
-### Errors
-
-- `403 Forbidden` - Not an admin
-- `404 Not Found` - User not found
-- `400 Bad Request` - Passwords do not match or too short
-
-**Security Note:** Admin password resets bypass the current password check. Use with caution and ensure proper authorization.
-```
-
-### Step 8.2: Create User Guide
-
-**Create:** `docs/user/profile-guide.md`
-
-```markdown
-# Profile Management Guide
-
-## Updating Your Profile
-
-Your profile contains your account information and preferences.
-
-### How to Update
-
-1. Click on your **username** (top-right)
-2. Select **Profile** from the dropdown
-3. Edit any fields in the "Account Information" section
-4. Click **Update Profile**
-5. Changes take effect immediately
-
-### Fields You Can Update
-
-- **Username** - Your login name (must be unique)
-- **Email** - Your email address (must be unique)
-- **First Name** - Optional display name
-- **Last Name** - Optional display name
-- **Theme** - Your preferred color scheme
-
-## Changing Your Password
-
-Regular password changes are recommended for account security.
-
-### How to Change
-
-1. Go to **Profile** page
-2. Scroll to "Change Password" section
-3. Enter your **current password**
-4. Enter your **new password**
-5. **Confirm** your new password
-6. Click **Update Password**
-
-### Password Requirements
-
-- Minimum 6 characters
-- Must match confirmation field
-- Current password must be correct
-
-## Deleting Your Account
-
-**β οΈ WARNING:** This action cannot be undone.
-
-### What Gets Deleted
-
-When you delete your account:
-- Your profile information
-- Reading progress and history
-- Device connections
-- Collection preferences
-- All personal data
-
-### What Stays
-
-- Library books (media belongs to the library, not you)
-- System settings
-- Other users' accounts
-
-### How to Delete
-
-1. Go to **Profile** page
-2. Scroll to "Danger Zone" (bottom of page)
-3. Click **Remove My Account**
-4. Confirm by clicking "OK" in the popup
-
-**Note:** If you're the last admin, you cannot delete your account for security reasons.
-
-## Theme Options
-
-Personalize your reading experience with different color themes.
-
-### Quick Theme Switch
-
-1. Click the **paintbrush icon** (top-right, next to your username)
-2. Select a theme from the dropdown
-3. Changes apply instantly
-
-### Available Themes
-
-- **Tokyo Night** (default) - Blue/purple accents
-- **Dracula** - Purple/pink tones
-- **Nord** - Arctic, bluish-gray
-- **Solarized Dark** - Warm, precise contrast
-- **Monokai** - Classic vibrant colors
-- **One Dark Pro** - Atom editor inspired
-- **Material Dark** - Google Material Design
-- **Wood Light** - Light wood texture
-- **Wood Dark** - Dark wood texture
-- **Wood Mahogany** - Reddish-brown wood
-
-### For Admin Users
-
-If you're an administrator, you can also manage other users' profiles from the admin panel.
-
-See [Admin User Management](../admin/users.md) for details.
-```
-
-### Step 8.3: Update Navigation
-
-**Update:** `docs/developer/api/_navigation.md` or equivalent
-
-Add new endpoints to navigation.
-
-**Commit:**
-```bash
-git add docs/
-git commit -m "docs: document consolidated profile API endpoints
-
-- Add profile.md with GET/PUT/DELETE /api/auth/profile
-- Add password.md with PUT /api/auth/password and /:id variants
-- Add user guide in docs/user/profile-guide.md
-- Document self-service and admin operations
-- Add security notes (last-admin protection)
-- Include error scenarios and response codes
-"
-```
-
----
-
-## FINAL VERIFICATION
-
-### Step F.1: Run Verification Script
-
-```bash
-bash scripts/verify-guidelines.sh
-```
-
-**Expected:** 0 errors, warnings are OK
-
-### Step F.2: Run Full Test Suite
-
-```bash
-go test ./... -v
-```
-
-**Expected:** All tests pass
-
-### Step F.3: Build Project
-
-```bash
-go build ./...
-```
-
-**Expected:** No compilation errors
-
-### Step F.4: Review All Changes
-
-```bash
-git diff --stat
-git diff
-```
-
-**Verify:**
-- Only intended files modified
-- No unintended deletions
-- Logic is correct
-
-### Step F.5: Check Documentation Renders
-
-```bash
-# Start server
-podman compose up -d
-
-# Visit http://localhost:8080/docs
-# Verify new docs appear
-# Test search finds new content
-```
-
-### Step F.6: Manual Testing (Bruno)
-
-1. **Test self-update:**
- - GET /api/auth/profile β See profile
- - PUT /api/auth/profile (update username) β Success
- - Verify username changed
-
-2. **Test admin-update:**
- - PUT /api/auth/profile/:id (admin updates user) β Success
- - PUT /api/auth/profile/:id with role field β User promoted to admin
-
-3. **Test password:**
- - PUT /api/auth/password (self-change) β Success
- - PUT /api/auth/password/:id (admin reset) β Success
-
-4. **Test deletion:**
- - DELETE /api/auth/profile (self-delete test user) β Success
- - DELETE /api/auth/profile/:id (admin delete test user) β Success
- - Try DELETE /api/auth/profile (last admin) β 400 error
-
-5. **Test theme:**
- - PUT /api/auth/theme (quick toggle) β Success
- - Verify theme changed immediately
-
----
-
-## SUMMARY OF CHANGES
-
-| Phase | Files | Lines Added | Lines Removed | Risk |
-|-------|-------|-------------|---------------|------|
-| 1. Database Query | 1 | 5 | 0 | Lowest |
-| 2. Extend UpdateProfile & Admin Handlers | 1 | 230 | 20 | Medium |
-| 3. Rename DeleteAccount | 1 | 10 | 0 | Low |
-| 4. Router Updates | 1 | 8 | 3 | Low |
-| 5. Frontend (Profile + Users + Modal) | 7 | 650 | 160 | Medium |
-| 6. Tests | 1 | 400 | 120 | High |
-| 7. Bruno Tests | 5 | 100 | 0 | Low |
-| 8. Documentation | 5 | 200 | 0 | Lowest |
-| **TOTAL** | **22** | **~1603** | **~303** | **Medium** |
-
----
-
-## SUCCESS CRITERIA
-
-- [x] Database query added and code regenerated
-- [x] UpdateUser handler created (reuses existing queries)
-- [x] ResetUserPassword handler created (standalone)
-- [x] UpdateProfile extended for all fields
-- [x] DeleteUser renamed, uses URL param
-- [x] Routes consolidated (3 removed, 4 added)
-- [x] /profile page created for all users
-- [x] /admin/users page created with user management table
-- [x] Profile modal component created (reusable for self and admin editing)
-- [x] GET /admin/users/:id/profile-modal route added (admin only, server validates)
-- [x] HTMX Edit button loads modal (secure, server-validated)
-- [x] Profile modal conditionally shows current password field
-- [x] HTMX role toggles with page reload after success
-- [x] HTMX delete buttons with page reload after success
-- [x] Last-admin protection in UI (disabled states for role/delete)
-- [x] Header links updated (/settings β /profile)
-- [x] Admin navigation includes /admin/users link
-- [x] Admin-only profile page removed
-- [x] Login template shows account deleted message
-- [x] All tests passing (40+ new tests)
-- [x] Bruno tests updated
-- [x] Documentation complete
-- [x] Zero compilation errors
-- [x] Zero guideline violations
-- [x] **NO OVER-ENGINEERING** - Simple composition approach
-
----
-
-**End of Revised Implementation Plan**
-
-Follow this plan phase-by-phase. Commit after each phase. Stop and verify after each step. No cascading edits. Surgical precision. Simple composition. NO OVER-ENGINEERING.
diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md
deleted file mode 100644
index 4988dd0..0000000
--- a/CAROUSEL_DASHBOARD_PLAN.md
+++ /dev/null
@@ -1,5517 +0,0 @@
-# π¬ 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) {
-
-
-
-
-
- Dashboard - Bookhoard
-
-
-
-
-
-
-
-
- @Header(user, "/dashboard")
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- for _, section := range sections {
- @CollectionCarousel(section)
- }
-
-
-
- @DashboardSettingsModal(sections)
-
-
-}
-
-templ CollectionCarousel(section handlers.SectionData) {
-
-
-
-
-
{ section.Icon }
-
-
{ section.Title }
- if section.Description != "" {
-
{ section.Description }
- }
-
-
-
- if section.ViewAllURL != "" {
-
- View All β
-
- }
-
-
-
-
-
-
-
-
-
-
-
-}
-
-templ BookCard(item handlers.BookInfo) {
-
-}
-
-templ DashboardSettingsModal(sections []handlers.SectionData) {
-
-
-
-
Customize Dashboard
-
-
-
-
- Drag to reorder collections, toggle visibility with the switch.
-
-
-
-
- for _, section := range sections {
-
-
-
β°
-
{ section.Icon }
-
- { section.Title }
- if section.IsSystem {
- System
- }
-
-
-
-
- if section.IsSystem {
-
- }
-
-
-
-
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-}
-```
-
-**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 {
- const collectionList = document.getElementById('collection-list') as HTMLElement;
- if (!collectionList) return;
-
- const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf;
- 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 {
- 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 {
- 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 => `
-
-
-
-
${section.icon}
-
-
${section.title}
- ${section.description ? `
${section.description}
` : ''}
-
-
- ${section.view_all_url ? `
View All β` : ''}
-
-
-
-
-
-
-
-
-
-
- `).join('');
-}
-
-function renderBookCard(book: BookInfo): string {
- const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
-
- return `
-
- `;
-}
-
-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) {
-
-
-
-
-
- Create Custom Section - Bookhoard
-
-
-
-
-
-
-
- @Header(user, "/custom-section")
-
-
- Create Custom Section
- Build a custom dashboard section by defining filter rules or manually selecting books.
-
-
-
-
-
-}
-```
-
-#### 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 = 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 = `
-
-
-
-
-
-
-
-
- `;
-
- 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 => ``).join('')
- : '';
-
- 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 {
- 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 = 'No books found
';
- } else {
- resultsContainer.innerHTML = books.map(book => `
-
-

-
-
${escapeHtml(book.title)}
-
${escapeHtml(book.author)}
-
-
-
- `).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 = 'No books selected
';
- return;
- }
-
- container.innerHTML = Array.from(selectedBooks.values()).map(book => `
-
- ${escapeHtml(book.title)}
-
-
- `).join('');
-}
-
-// Load live preview of the custom section
-async function loadPreview(): Promise {
- 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 = '';
-
- 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 = 'Failed to load preview
';
- }
-}
-
-// 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 = 'No items match your criteria
';
- return;
- }
-
- previewContainer.innerHTML = `
-
- ${items.map(item => `
-
-
-

-
-
- ${escapeHtml(item.title)}
-
- ${item.author ? `
${escapeHtml(item.author)}
` : ''}
-
- `).join('')}
-
-
- ${items.length} item${items.length !== 1 ? 's' : ''} will be shown
-
- `;
-}
-
-// Save the custom section
-async function saveCustomSection(event: Event): Promise {
- 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=`
- - 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**
diff --git a/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md b/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md
deleted file mode 100644
index e405f19..0000000
--- a/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md
+++ /dev/null
@@ -1,3489 +0,0 @@
-# Carousel Dashboard Plan Verification Checklist
-
-Use this checklist to comprehensively audit the Carousel Dashboard Plan in a single pass. Each item includes verification steps to confirm accuracy.
-
----
-
-## β οΈ CLARIFICATION: Plan vs Checklist Discrepancies Resolved
-
-After thorough analysis, the following discrepancies have been resolved:
-
-### 1. Preview Endpoint - **IS in the plan**
-- **Checklist concern**: "Missing Collection Preview Endpoint"
-- **Reality**: Endpoint is specified in **Phase 4.5** of the plan
-- **Why required**: Web UI custom section builder + future mobile apps need to preview filter rules before saving
-- **Location**: `internal/handlers/collections.go` - `PreviewCollection` method
-- **Route**: POST `/api/collections/preview`
-- **Documentation**: Explained in Phase 4.5 why client-side preview is a bad idea
-
-### 2. Custom Section Builder - **IS in the plan**
-- **Checklist concern**: "Missing Custom Builder sections 10.5.2 and 10.5.3"
-- **Reality**: Both sections exist in the plan:
- - **10.5.2**: Custom Section Builder Template (`templates/custom_section.templ`)
- - **10.5.3**: Custom Section Builder TypeScript (`web/src/custom-section-builder.ts`)
-- This is a major feature with 13+ filter fields
-
-### 3. Service Method Names - **Plan is correct**
-- **Checklist expects**: `GetSectionItems`, `filterHiddenSections`, `reorderSections`
-- **Plan implements**: `GetDashboardSections`, `filterHiddenCollections`, `reorderCollections`
-- **Plan names are better**: More descriptive, uses "collections" terminology consistently
-- **Action taken**: Updated checklist to match plan's actual method names
-
-### 4. Config Struct Updates - **Documented with line numbers**
-- **Concern**: "Touching Config breaks dozens of functions"
-- **Reality**: Only 3 files need updates, all with exact line numbers specified:
- - `internal/router/router.go` line 58-59
- - `cmd/server/main.go` lines 123-124, 172-173
- - `cmd/server/tests/test_helpers.go` lines 419-420, 458-459
-- **18 router functions** accept `*Config` but don't need changes (just receive pointer)
-
-### 5. DashboardService in Config - **Why both Service and Handler?**
-- **DashboardService**: Used by SSR routes (frontend.go) for data fetching
-- **DashboardHandler**: Used by API routes (dashboard.go) for JSON endpoints
-- **Mobile apps**: Will use DashboardHandler
-- **Web UI**: Uses both (SSR via Service, interactions via Handler)
-
----
-
-## β οΈ CRITICAL DISTINCTION: Type Duplication
-
-**Before using this checklist, understand this important guideline:**
-
-### β UNACCEPTABLE: Duplicate Go Types
-```go
-// WRONG: Creating duplicate types in Go templates package
-package templates
-
-type SectionData struct { ... } // DON'T DO THIS - duplicates handlers.SectionData
-```
-
-### β
ACCEPTABLE: TypeScript Type Recreation (MUST BE COMPLETE)
-```typescript
-// OKAY: Recreating types in TypeScript .d.ts files
-// Go's pgtype fields cannot auto-convert, so manual recreation is necessary
-// CRITICAL: Must include ALL fields from Go handler (no partial types)
-
-interface SectionData {
- id: string; // matches Go's json:"id"
- is_system: boolean; // matches Go's json:"is_system" (was "type" string)
- title: string; // matches Go's json:"title"
- description: string; // matches Go's json:"description"
- icon: string; // matches Go's json:"icon"
- items: BookInfo[]; // matches Go's json:"items"
- view_all_url: string; // matches Go's json:"view_all_url"
- priority: number; // matches Go's json:"priority"
-}
-// All 8 fields from Go struct included - COMPLETE TYPE MATCHING
-
-interface BookInfo {
- media_item_id: string; // matches Go's json:"media_item_id" (NOT "id")
- title: string; // matches Go's json:"title"
- author: string; // matches Go's json:"author"
- cover_image_path: string; // matches Go's json:"cover_image_path"
-}
-// All 4 fields from Go struct included - COMPLETE TYPE MATCHING
-```
-
-### β UNACCEPTABLE: Partial TypeScript Types
-```typescript
-// WRONG: TypeScript interface with only subset of Go fields (breaks type safety)
-interface SectionData {
- id: string;
- type: string; // WRONG: should be is_system: boolean
- title: string;
- items: BookInfo[];
- // Missing: description, icon, view_all_url, priority
- // This is a PARTIAL type and violates type safety guidelines
-}
-
-// WRONG: Using wrong field name for BookInfo
-interface BookInfo {
- id: string; // WRONG: should be media_item_id
- title: string;
- author: string;
- cover_image_path: string;
-}
-```
-
-**Key Points:**
-- **In Go**: Templates MUST use `handlers.*` types directly (no duplication)
-- **In TypeScript**: `.d.ts` files recreate **complete** handler JSON structure (all fields)
-- **Reason**: Go's `pgtype.Text`, `pgtype.UUID`, etc. don't map cleanly to TypeScript
-- **Type Safety**: Partial TypeScript types break type safety and can cause runtime errors
-- **Verification**: Field counts must match (Go struct has N fields = TypeScript has N fields)
-
-This checklist enforces **NO Go duplication** while **requiring complete TypeScript duplication**.
-
----
-
-## 1. Prerequisites Verification
-
-### 1.1 Verify TypeScript Conversion Plan Completed
-
-**Before starting Carousel Dashboard:**
-
-- [ ] TypeScript Conversion Plan (20-25.5 days) is fully completed
-- [ ] All infrastructure modules exist in `web/src/`:
- - [ ] `api.ts` - Centralized API client with auth
- - [ ] `toast.ts` - Toast notification system
- - [ ] `events.ts` - Event delegation utilities
- - [ ] `storage.ts` - localStorage wrapper
- - [ ] `dom.ts` - DOM utilities (escapeHtml, etc.)
- - [ ] `types/api.d.ts` - Type definitions for all API responses
-- [ ] Event delegation pattern established (data attributes)
-- [ ] TypeScript compilation pipeline working (`npm run build:ts`)
-- [ ] All inline JavaScript removed from templates
-- [ ] Progressive enhancement maintained across all features
-
-**Verification Commands:**
-```bash
-# Verify TypeScript modules exist
-ls -la web/src/{api,toast,events,storage,dom}.ts
-
-# Verify type definitions exist
-ls -la web/src/types/api.d.ts
-
-# Verify build works
-npm run build:ts
-
-# Check for remaining inline JavaScript
-rg '