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) { -
- - -
-

Account Information

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- - if showRoleField { -
- - -
- } -
-
- - -
-

Change Password

- - if requireCurrentPassword { - -
-
- - -
- -
- - -
- -
- - -
- - -
-
- } else { - -

- As an admin, you can change this user's password without knowing their current password. -

- -
-
- - -
- -
- - -
- - -
-
- } -
- - -
- if showCancelButton { - - } - -
- -
-
-} -``` - -### 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

-
- - -
- - - - - - - - - - - - @for _, user := range users { - - - - - - - - - - - - - - - - - } - -
UsernameEmailRoleCreatedActions
-
-
-
{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 -
- Profile Settings -
-``` - -**Add after it:** -```templ -
- Users -
-``` - -**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) { -
-
- if item.CoverImagePath != "" { - { - } else { - { - } -
- -

- { item.Title } -

- - if item.Author != "" { -

- { item.Author } -

- } -
-} - -templ DashboardSettingsModal(sections []handlers.SectionData) { - -} -``` - -**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 ` -
-
- ${book.title} -
-

- ${book.title} -

- ${book.author ? `

${book.author}

` : ''} -
- `; -} - -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.

- -
- -
-

Section Details

- -
-
- - -
- -
- - -
-
- -
- - -
- -
- - -
-
- - -
-
-

Filter Rules

- -
- -

- Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule. -

- -
- -
- -
- - -
-
- - -
-

Manual Book Selection

-

- Add specific books to this section. Use the search to find and select multiple books. -

- -
- -
- - -
-
- - - -
- -
-

No books selected

-
-
-
- - -
-
-

Live Preview

- -
- -
-

- Add filter rules or select books to see a preview of your custom section. -

-
-
- - -
- - -
-
-
- - -} -``` - -#### 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.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)} -
-

- ${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 '