diff --git a/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md b/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md new file mode 100644 index 0000000..3a6cb08 --- /dev/null +++ b/GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md @@ -0,0 +1,740 @@ +# GET /api/saved-filters/:id Endpoint Implementation Plan + +**Status:** Planning Phase +**Created:** March 21, 2026 +**Priority:** Medium +**Complexity:** Low + +--- + +## Overview + +Add a **GET /api/saved-filters/:id** endpoint to retrieve a single saved filter by ID. This enables future mobile/SPA clients to fetch filter details on-demand without relying on server-side rendering. + +### Key Decision Points + +- ✅ **Reuse existing service method** - `GetSavedFilterByID` query already exists +- ✅ **Follow established patterns** - Match existing handler structure +- ✅ **Minimal code changes** - Only add one new handler method +- ✅ **Backward compatible** - Doesn't affect existing endpoints +- ✅ **Future-proof** - Enables mobile/SPA clients +- ✅ **Follows PROJECT_GUIDELINES.md** - Service layer, test helpers, docs + +--- + +## Current State Analysis + +### Existing Saved Filters API + +**Endpoints Implemented:** +- ✅ `GET /api/saved-filters?resource_type=X` - List all filters (Line 54 in filters.go) +- ✅ `POST /api/saved-filters` - Create filter (Line 91 in filters.go) +- ✅ `PUT /api/saved-filters/:id` - Update filter (Line 160 in filters.go) +- ✅ `DELETE /api/saved-filters/:id` - Delete filter (Line 138 in filters.go) + +**Database Query Already Exists:** +```go +// internal/database/queries.sql.go - Line 4480+ +func (q *Queries) GetSavedFilterByID(ctx context.Context, db database.GetSavedFilterByIDParams) (database.SavedFilters, error) +``` + +**Service Layer Already Uses It:** +```go +// internal/services/filters.go:71-77 +existing, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{ + ID: pgtype.UUID{Bytes: filterID, Valid: true}, + UserID: pgtype.UUID{Bytes: userID, Valid: true}, +}) +``` + +### Why This Endpoint Is Needed + +**Current Limitation:** +- SSR approach stores filter data in HTML (data attributes) +- Mobile apps can't access HTML data attributes +- SPA clients need direct API access to filter details +- Future clients need RESTful CRUD operations + +**Solution:** +- Add GET /:id endpoint following REST conventions +- Reuse existing service/database code +- Follow established handler patterns +- Complete CRUD API for saved filters + +--- + +## Implementation Plan + +### Phase 1: Add Handler Method + +#### File: `internal/handlers/filters.go` + +**Location:** After `GetSavedFilters` method (around line 88) + +**Change Type:** Add new handler method + +**Implementation:** + +```go +// GetSavedFilterByID - GET /api/saved-filters/:id +// Retrieve a single saved filter by ID +func (h *FiltersHandler) GetSavedFilterByID(c *echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + // Parse filter ID from URL parameter + filterID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid filter ID", + }) + } + + // Use service layer (includes business logic + ownership verification) + filter, err := h.filtersService.GetSavedFilterByID(c.Request().Context(), userUUID, filterID) + if err != nil { + if err.Error() == "filter not found or access denied" { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "filter not found", + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch filter", + }) + } + + // Return JSON response (JSONB handled automatically) + response := SavedFilterResponse{ + ID: filterID, + Name: filter.Name, + ResourceType: filter.ResourceType, + Filters: json.RawMessage(filter.Filters), // Return JSONB as-is + CreatedAt: filter.CreatedAt.Time.Format(time.RFC3339), + UpdatedAt: filter.UpdatedAt.Time.Format(time.RFC3339), + } + + return c.JSON(http.StatusOK, response) +} +``` + +**Notes:** +- ✅ Follows existing handler pattern (same as `GetSavedFilters`) +- ✅ Uses service layer (no direct database access) +- ✅ Returns `SavedFilterResponse` struct (already defined) +- ✅ Proper error handling (400, 404, 500) +- ✅ Ownership verification via service layer +- ✅ Matches existing authentication pattern + +--- + +### Phase 2: Add Service Layer Method + +#### File: `internal/services/filters.go` + +**Location:** After `GetSavedFilters` method (around line 32) + +**Change Type:** Add new service method + +**Implementation:** + +```go +// GetSavedFilterByID - Retrieve a single saved filter by ID +func (s *FiltersService) GetSavedFilterByID(ctx context.Context, userID uuid.UUID, filterID uuid.UUID) (database.SavedFilters, error) { + filter, err := s.db.GetSavedFilterByID(ctx, database.GetSavedFilterByIDParams{ + ID: pgtype.UUID{Bytes: filterID, Valid: true}, + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + }) + if err != nil { + return database.SavedFilters{}, fmt.Errorf("filter not found or access denied: %w", err) + } + + return filter, nil +} +``` + +**Notes:** +- ✅ Business logic: Verify filter exists and belongs to user +- ✅ Returns descriptive error for "not found or access denied" +- ✅ Reuses existing database query +- ✅ Follows existing service pattern (similar to UpdateSavedFilter:71-77) +- ✅ No new database code needed + +--- + +### Phase 3: Register Route + +#### File: `internal/router/filters.go` + +**Location:** Line 12 (after existing routes) + +**Change Type:** Add route registration + +**Current Code:** +```go +filters.GET("", cfg.FiltersHandler.GetSavedFilters) +filters.POST("", cfg.FiltersHandler.CreateSavedFilter) +filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter) +filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter) +``` + +**Updated Code:** +```go +filters.GET("", cfg.FiltersHandler.GetSavedFilters) +filters.GET("/:id", cfg.FiltersHandler.GetSavedFilterByID) // NEW +filters.POST("", cfg.FiltersHandler.CreateSavedFilter) +filters.PUT("/:id", cfg.FiltersHandler.UpdateSavedFilter) +filters.DELETE("/:id", cfg.FiltersHandler.DeleteSavedFilter) +``` + +**Notes:** +- ✅ Follows RESTful routing conventions +- ✅ `/:id` route must come BEFORE `""` route (already correct order) +- ✅ Matches existing route patterns + +--- + +### Phase 4: Integration Tests + +#### File: `cmd/server/tests/filters_test.go` + +**Location:** After existing tests (around line 216) + +**Change Type:** Add test cases + +**Implementation:** + +```go +t.Run("GET /api/saved-filters/:id retrieves single filter", func(t *testing.T) { + // First create a filter + reqBody := map[string]interface{}{ + "name": "Test Filter", + "resource_type": "media-items", + "filters": map[string]string{"search": "test"}, + } + body, _ := json.Marshal(reqBody) + + createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+setup.Token) + + createResp, err := client.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + assert.Equal(t, http.StatusCreated, createResp.StatusCode) + + var createdFilter map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&createdFilter) + filterID := createdFilter["id"].(string) + + // Now retrieve the filter by ID + getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) + getReq.Header.Set("Authorization", "Bearer "+setup.Token) + + getResp, err := client.Do(getReq) + require.NoError(t, err) + defer getResp.Body.Close() + + assert.Equal(t, http.StatusOK, getResp.StatusCode) + + var retrievedFilter map[string]interface{} + json.NewDecoder(getResp.Body).Decode(&retrievedFilter) + assert.Equal(t, "Test Filter", retrievedFilter["name"]) + assert.Equal(t, "media-items", retrievedFilter["resource_type"]) + assert.Equal(t, filterID, retrievedFilter["id"]) + assert.NotEmpty(t, retrievedFilter["filters"]) +}) + +t.Run("GET /api/saved-filters/:id without auth returns 401", func(t *testing.T) { + httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil) + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +}) + +t.Run("GET /api/saved-filters/:id with invalid UUID returns 400", func(t *testing.T) { + httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/invalid-uuid", nil) + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +}) + +t.Run("GET /api/saved-filters/:id with non-existent filter returns 404", func(t *testing.T) { + fakeID := uuid.New().String() + httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+fakeID, nil) + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +}) + +t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) { + // Create filter with user 1 + reqBody := map[string]interface{}{ + "name": "User1 Filter", + "resource_type": "media-items", + "filters": map[string]string{"search": "test"}, + } + body, _ := json.Marshal(reqBody) + + createReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/saved-filters", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+setup.Token) + + createResp, err := client.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + var createdFilter map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&createdFilter) + filterID := createdFilter["id"].(string) + + // Try to access with user 2 (different token) + // Note: This requires setup.AdminToken or creating a second user + // For now, we'll test with a non-existent filter to verify 404 logic + httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) + httpReq.Header.Set("Authorization", "Bearer "+setup.AdminToken) // Different user + + getResp, err := client.Do(httpReq) + require.NoError(t, err) + defer getResp.Body.Close() + + // Should return 404 (not found or access denied) + assert.Equal(t, http.StatusNotFound, getResp.StatusCode) +}) +``` + +**Notes:** +- ✅ Uses `setupTestServer(t)` helper (PROJECT_GUIDELINES.md compliant) +- ✅ Tests all contexts: no auth, user, different user +- ✅ Tests error cases: 400, 401, 404 +- ✅ Tests success case: 200 +- ✅ Follows existing test pattern in filters_test.go +- ✅ Single setup shared across subtests + +--- + +### Phase 5: Bruno API Collection + +#### File: `bruno/saved-filters/Get Saved Filter By ID.yml` + +**Location:** `/home/nymusicman/Code/bookhoard/bruno/saved-filters/` + +**Change Type:** Create new file + +**Implementation:** + +```yaml +info: + name: Get Saved Filter By ID + type: http + seq: 4 +http: + method: GET + url: '{{base_url}}/api/saved-filters/{{filter_id}}' + auth: inherit + +docs: |- + ## Get Saved Filter By ID + + Retrieves a single saved filter by its ID. + + **Method:** GET + + **Endpoint:** /api/saved-filters/:id + + **Authentication:** Required (Bearer token) + + **URL Parameters:** + - `id` (UUID, required): The unique identifier of the saved filter + - Example: "550e8400-e29b-41d4-a716-446655440000" + - Must be a valid UUID format + + **Response:** + Single saved filter object: + ```json + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Sci-Fi Books", + "resource_type": "media-items", + "filters": { + "genre_filter": "Science Fiction", + "sort": "title ASC" + }, + "created_at": "2024-03-20T12:00:00Z", + "updated_at": "2024-03-20T12:00:00Z" + } + ``` + + **Status Codes:** + - 200: Success - Returns the saved filter + - 400: Bad Request - Invalid filter ID format + - 401: Unauthorized - Invalid or missing authentication token + - 404: Not Found - Filter doesn't exist or doesn't belong to user + + **Example Usage:** + ```bash + # Get a specific saved filter + curl -H "Authorization: Bearer YOUR_TOKEN" \ + "{{base_url}}/api/saved-filters/550e8400-e29b-41d4-a716-446655440000" + ``` + + **Notes:** + - Filters are user-specific (ownership verified via JWT) + - Returns 404 if filter doesn't exist OR doesn't belong to authenticated user + - Use `GET /api/saved-filters?resource_type=X` to list all filters first + + **Scenarios:** + - Get filter by ID from list response + - Load filter details for editing + - Verify filter exists before updating + - Mobile app on-demand filter loading +``` + +**Notes:** +- ✅ Follows existing Bruno YAML pattern +- ✅ Includes comprehensive documentation +- ✅ Uses variable placeholders (`{{filter_id}}`) +- ✅ Documents all status codes +- ✅ Includes example usage +- ✅ Placed in correct directory structure + +--- + +### Phase 6: API Documentation + +#### File: `docs/developer/api/saved-filters/index.md` + +**Location:** After DELETE section (around line 106) + +**Change Type:** Add new endpoint documentation + +**Implementation:** + +```markdown +### GET /api/saved-filters/:id + +Retrieve a single saved filter by ID. + +**URL Parameters:** +- `id` (UUID, required) - Filter ID to retrieve + +**Response:** Single saved filter object (HTTP 200) + +**Example Request:** +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \\ + "http://localhost:8765/api/saved-filters/550e8400-e29b-41d4-a716-446655440000" +``` + +**Example Response:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Sci-Fi Books", + "resource_type": "media-items", + "filters": { + "genre_filter": "Science Fiction", + "sort": "title ASC" + }, + "created_at": "2024-03-20T12:00:00Z", + "updated_at": "2024-03-20T12:00:00Z" +} +``` + +**Error Responses:** +- 400 Bad Request - Invalid filter ID format +- 401 Unauthorized - Invalid or missing authentication token +- 404 Not Found - Filter doesn't exist or doesn't belong to user + +**Use Cases:** +- Mobile apps: Fetch filter details on-demand +- SPAs: Load filter data without page reload +- Editing: Pre-fill filter update form +- Verification: Check filter exists before operations + +--- +``` + +**Notes:** +- ✅ Follows existing documentation pattern +- ✅ Includes example requests/responses +- ✅ Documents all error codes +- ✅ Added to existing index.md file +- ✅ Provides use cases for new endpoint + +--- + +## Testing Plan + +### Test Cases + +#### 1. Success Case - Retrieve Existing Filter +**Steps:** +1. Create a saved filter via POST +2. GET /api/saved-filters/:id with returned ID +3. Verify response matches created filter + +**Expected:** +- ✅ HTTP 200 status +- ✅ Filter object returned +- ✅ All fields present (id, name, resource_type, filters, timestamps) +- ✅ Filters field is valid JSON + +#### 2. Error Case - Invalid UUID Format +**Steps:** +1. GET /api/saved-filters/invalid-uuid +2. Check response + +**Expected:** +- ✅ HTTP 400 status +- ✅ Error message: "invalid filter ID" + +#### 3. Error Case - Filter Not Found +**Steps:** +1. Generate random UUID +2. GET /api/saved-filters/:random-uuid +3. Check response + +**Expected:** +- ✅ HTTP 404 status +- ✅ Error message: "filter not found" + +#### 4. Error Case - No Authentication +**Steps:** +1. GET /api/saved-filters/:id without Authorization header +2. Check response + +**Expected:** +- ✅ HTTP 401 status +- ✅ Standard auth error response + +#### 5. Security Case - Cross-User Access +**Steps:** +1. User A creates filter +2. User B tries to GET User A's filter +3. Check response + +**Expected:** +- ✅ HTTP 404 status (not 403 - hide existence) +- ✅ Error message: "filter not found" +- ✅ No information leakage about other users' filters + +#### 6. Integration Case - From List to Detail +**Steps:** +1. GET /api/saved-filters?resource_type=media-items +2. Pick first filter ID from list +3. GET /api/saved-filters/:id +4. Verify data consistency + +**Expected:** +- ✅ IDs match +- ✅ Names match +- ✅ Filters data matches +- ✅ Timestamps match + +--- + +## Summary of Changes + +| File | Lines Changed | Type | Complexity | +|------|--------------|------|------------| +| `internal/handlers/filters.go` | +35 lines | Add handler method | Low | +| `internal/services/filters.go` | +8 lines | Add service method | Low | +| `internal/router/filters.go` | +1 line | Add route registration | Low | +| `cmd/server/tests/filters_test.go` | +120 lines | Add integration tests | Medium | +| `bruno/saved-filters/Get Saved Filter By ID.yml` | +80 lines | Create Bruno file | Low | +| `docs/developer/api/saved-filters/index.md` | +35 lines | Update documentation | Low | + +**Total:** ~279 lines of code across 6 files + +**No new database code needed** - All queries and handlers already exist! + +--- + +## Advantages of This Approach + +✅ **100% Pattern-compliant** - Follows established handler/service/test patterns +✅ **Minimal code changes** - Reuses existing database queries and service methods +✅ **Complete CRUD API** - Adds missing GET /:id endpoint +✅ **Future-proof** - Enables mobile/SPA clients +✅ **Backward compatible** - Doesn't affect existing endpoints +✅ **Security maintained** - Ownership verification via service layer +✅ **Well-tested** - Comprehensive integration tests using test_helpers +✅ **Well-documented** - Bruno YAML + API docs +✅ **No database changes** - All queries already exist + +--- + +## Rollback Plan + +If issues arise, rollback is straightforward: + +### Step 1: Revert Handler Changes +```bash +git checkout HEAD~1 internal/handlers/filters.go +``` + +### Step 2: Revert Service Changes +```bash +git checkout HEAD~1 internal/services/filters.go +``` + +### Step 3: Revert Router Changes +```bash +git checkout HEAD~1 internal/router/filters.go +``` + +### Step 4: Revert Test Changes +```bash +git checkout HEAD~1 cmd/server/tests/filters_test.go +``` + +### Step 5: Remove New Files +```bash +rm bruno/saved-filters/Get\ Saved\ Filter\ By\ ID.yml +git checkout HEAD~1 docs/developer/api/saved-filters/index.md +``` + +### Step 6: Rebuild +```bash +podman compose down +podman compose build +podman compose up -d +``` + +--- + +## Potential Issues & Mitigations + +### Issue 1: Route Order Conflict +**Problem:** `/:id` route must come before `""` route + +**Mitigation:** +- Already correct - existing routes are in proper order +- Verified: `filters.PUT("/:id")` is after `filters.GET("")` + +### Issue 2: UUID Parsing Errors +**Problem:** Malformed UUIDs cause panic + +**Mitigation:** +- Handler uses `uuid.Parse()` with error checking +- Returns 400 for invalid UUID format +- Follows existing pattern in UpdateSavedFilter + +### Issue 3: Service Layer Error Messages +**Problem:** Database errors leak implementation details + +**Mitigation:** +- Service layer wraps errors in descriptive messages +- Returns "filter not found or access denied" (hides which) +- Handler maps service errors to HTTP status codes + +### Issue 4: Test Database Cleanup +**Problem:** Tests create filters that persist + +**Mitigation:** +- Uses `setupTestServer(t)` which handles cleanup +- Test server is isolated from development database +- Tests follow existing pattern (no special cleanup needed) + +--- + +## Future Enhancements + +### Phase 2 Improvements (Optional) + +1. **Caching Headers** + - Add `Cache-Control: private, max-age=300` for 5-minute client caching + - Add `ETag` header for conditional requests + - Reduces unnecessary API calls + +2. **Filter Validation Endpoint** + - Add `POST /api/saved-filters/:id/validate` to test filters + - Returns count of matching items without applying filter + - Useful for preview before loading + +3. **Batch Filter Load** + - Add `POST /api/saved-filters/batch` with `ids: []` array + - Reduces round-trips for mobile apps + - Returns array of filter objects + +--- + +## Checklist + +### Implementation +- [ ] Add `GetSavedFilterByID()` method to `internal/handlers/filters.go` +- [ ] Add `GetSavedFilterByID()` method to `internal/services/filters.go` +- [ ] Register route in `internal/router/filters.go` +- [ ] Add integration tests to `cmd/server/tests/filters_test.go` +- [ ] Create Bruno YAML file `bruno/saved-filters/Get Saved Filter By ID.yml` +- [ ] Update documentation `docs/developer/api/saved-filters/index.md` + +### Testing +- [ ] Test success case (200 - filter found) +- [ ] Test error case (400 - invalid UUID) +- [ ] Test error case (404 - filter not found) +- [ ] Test error case (401 - no auth) +- [ ] Test security case (cross-user access returns 404) +- [ ] Test integration case (list → detail flow) +- [ ] Run full test suite: `go test ./... -v` + +### Build & Deploy +- [ ] Build TypeScript: `npm run build:ts` +- [ ] Rebuild container: `podman compose build` +- [ ] Restart services: `podman compose up -d` +- [ ] Verify endpoint with Bruno or curl + +### Documentation +- [ ] Update API documentation +- [ ] Verify Bruno YAML renders correctly +- [ ] Test docs search finds new content +- [ ] Add comments to code explaining logic + +--- + +## References + +- Current saved filters implementation: `internal/handlers/filters.go` +- Service layer pattern: `internal/services/filters.go` +- Existing tests: `cmd/server/tests/filters_test.go` +- API documentation: `docs/developer/api/saved-filters/index.md` +- Bruno collection: `bruno/saved-filters/` +- PROJECT_GUIDELINES.md: Service layer architecture, testing patterns + +--- + +## Questions & Decisions Log + +### Q1: Should we add pagination to GET /api/saved-filters? +**Decision:** Not in this implementation. Current endpoint returns all filters for a resource type, which is reasonable given user-specific data and likely small counts (< 100). Can add pagination later if needed. + +### Q2: Should we add filtering/sorting to GET /api/saved-filters? +**Decision:** Not in this implementation. Current implementation filters by `resource_type` query parameter which is sufficient. Can add more sophisticated filtering later if needed. + +### Q3: Should we return 403 or 404 for cross-user access attempts? +**Decision:** Return 404 to hide existence of other users' filters (security best practice). Don't reveal whether a filter exists - just "not found or access denied". + +### Q4: Should we add ETag/If-None-Match support? +**Decision:** Not in Phase 1. Can add in Phase 2 as a caching enhancement. For now, simple GET /:id is sufficient. + +### Q5: Do we need to validate that the filter ID belongs to the resource type? +**Decision:** Not necessary. The `resource_type` is part of the filter object returned. Clients can verify it matches their expectations. Ownership verification is already handled by the service layer. + +--- + +**Last Updated:** March 21, 2026 +**Document Version:** 1.0 +**Status:** Ready for Implementation +**Guideline Compliance:** ✅ Fully compliant with PROJECT_GUIDELINES.md +**Backend Changes:** ✅ Minimal - Reuses existing queries and patterns diff --git a/SSR_BOOKSHELF_IMPLEMENTATION.md b/SSR_BOOKSHELF_IMPLEMENTATION.md new file mode 100644 index 0000000..7e35328 --- /dev/null +++ b/SSR_BOOKSHELF_IMPLEMENTATION.md @@ -0,0 +1,803 @@ +# SSR Bookshelf Implementation Plan - Option A: Full SSR + +**Status:** Planning Phase +**Created:** March 21, 2026 +**Priority:** Medium +**Complexity:** Medium + +--- + +## Overview + +Implement **full Server-Side Rendering (SSR)** for the bookshelf page, following PROJECT_GUIDELINES.md SSR-first principles. Both books and saved filters will be rendered server-side, with client-side JavaScript handling only UI state and event listeners. + +**IMPORTANT:** No backend changes needed! We'll use existing database queries directly from `frontend.go`. + +### Key Decision Points + +- ✅ **SSR first page of books** - Server renders initial book grid +- ✅ **SSR saved filters** - Call existing `GetSavedFilters` query directly (no new backend code) +- ✅ **No async x-init** - Client-side only sets up event listeners (like dashboard) +- ✅ **HTMX-based pagination** - Continue using HTMX for pagination/filter changes +- ✅ **Follows PROJECT_GUIDELINES.md** - Complies with SSR-first principles +- ✅ **Minimal changes** - Only frontend.go, template, and TypeScript + +--- + +## Current State Analysis + +### Dashboard SSR Pattern (Reference Implementation) + +**Server-Side:** Full SSR with collections pre-rendered +```go +// frontend.go:172-254 +sections, err := cfg.DashboardService.GetDashboardSections(...) +visibleSections := cfg.DashboardService.FilterHiddenCollections(...) +sectionData := handlers.BuildSections(visibleSections, libraryID) +templates.Dashboard(user, sectionData, ...).Render(...) +``` + +**Client-Side:** Event listeners only, no data fetching +```typescript +// dashboard.ts:429-515 +function initDashboard() { + initDragAndDrop(); + document.addEventListener("click", ...); // Event delegation only + // No data fetching! +} +``` + +### Bookshelf Current Implementation + +**Server-Side:** No SSR books - empty grid +```go +// frontend.go:120-169 +libraries, err := cfg.Queries.GetUserVisibleLibraries(...) +templates.BookShelf(user, libData, libraryID, errorMsg).Render(...) +// Note: No books fetched, no pagination data +``` + +**Client-Side:** Async init with HTMX trigger +```typescript +// bookshelf.ts:58-97 +async initBookshelf() { + this.loadSavedFiltersIntoState(); + const response = await fetch("/api/saved-filters?resource_type=media-items"); + this.savedFilters = filters; + + // Triggers HTMX to load books + window.htmx.trigger(librarySelect, "change"); +} +``` + +--- + +## ⚠️ CRITICAL TYPE CHANGE REQUIRED + +**Before implementing, you MUST change line 178 in `internal/router/frontend.go`:** + +```go +// Current (line 178) - WRONG TYPE: +var books []database.ListMediaItemsByLibraryRow + +// Must change to: +var books []database.ListMediaItemsFilteredRow +``` + +**Why:** `ListMediaItemsFiltered` returns `[]ListMediaItemsFilteredRow`, not `[]ListMediaItemsByLibraryRow`. If you don't make this change, you will get a compilation error. + +--- + +## Implementation Plan + +### Phase 1: Server-Side Changes + +#### File: `internal/router/frontend.go` + +**Location:** Lines 120-169 (bookshelf route handler) + +**CRITICAL - Line 178 MUST CHANGE:** +```go +// Current (WRONG): +var books []database.ListMediaItemsByLibraryRow + +// Change to: +var books []database.ListMediaItemsFilteredRow // ✅ CORRECT TYPE +``` + +**Change Type:** Add book fetching AND saved filters fetching logic + +**Implementation:** + +```go +frontendProtected.GET("/bookshelf", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + + var errorMsg string + + // Get library_id from query param or user's first library + libraryID := c.QueryParam("library_id") + if libraryID == "" { + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err == nil && len(libraries) > 0 { + libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) + libraryID = libUUID.String() + } else { + errorMsg = "No libraries available" + } + } + + // Get libraries for dropdown + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + log.Printf("GetUserVisibleLibraries failed: %v", err) + libraries = []database.GetUserVisibleLibrariesRow{} + if errorMsg == "" { + errorMsg = "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: getText(lib.Description), + TypeName: lib.TypeName, + } + } + + // ========== NEW CODE START ========== + // Fetch saved filters for SSR (using existing query) + var savedFilters []database.SavedFilters + if libraryID != "" && errorMsg == "" { + savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{ + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + ResourceType: pgtype.Text{String: "media-items", Valid: true}, + }) + if err != nil { + log.Printf("GetSavedFilters failed: %v", err) + savedFilters = []database.SavedFilters{} // Empty list, not critical error + } + } + + // Fetch first page of books for SSR + // NOTE: Use ListMediaItemsFilteredRow, NOT ListMediaItemsByLibraryRow + var books []database.ListMediaItemsFilteredRow + var bookInfoList []handlers.BookInfo + totalCount := 0 + limit := 50 + offset := 0 + + if libraryID != "" && errorMsg == "" { + libUUID, err := uuid.Parse(libraryID) + if err == nil { + // Check URL params for pagination + if limitStr := c.QueryParam("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + if offsetStr := c.QueryParam("offset"); offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + offset = o + } + } + + // Fetch books with default filters + books, err = cfg.Queries.ListMediaItemsFiltered(c.Request().Context(), database.ListMediaItemsFilteredParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + AuthorFilter: pgtype.Text{String: "", Valid: false}, + SeriesFilter: pgtype.Text{String: "", Valid: false}, + GenreFilter: pgtype.Text{String: "", Valid: false}, + LanguageFilter: pgtype.Text{String: "", Valid: false}, + YearMin: pgtype.Int4{Valid: false}, + YearMax: pgtype.Int4{Valid: false}, + HasCover: pgtype.Bool{Valid: false}, + Sort: pgtype.Text{String: "created_at DESC", Valid: true}, + Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, + }) + + if err != nil { + log.Printf("ListMediaItemsFiltered failed: %v", err) + // Continue without books - will show empty state + } else { + // Convert database rows to BookInfo structs (matching BuildSections pattern) + bookInfoList = make([]handlers.BookInfo, len(books)) + for i, book := range books { + bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16]) + bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16]) + bookInfoList[i] = handlers.BookInfo{ + MediaItemID: bookUUID.String(), + Title: book.Title, + Author: getText(book.Author), + CoverImagePath: utils.ResolveMediaURL(bookLibUUID, book.CoverImagePath), + } + } + totalCount = len(bookInfoList) + } + } + } + + // Convert saved filters to JSON for template + import "encoding/json" + + filtersJSON, err := json.Marshal(savedFilters) + if err != nil { + log.Printf("Failed to marshal saved filters: %v", err) + filtersJSON = []byte("[]") + } + // ========== NEW CODE END ========== + + var buf bytes.Buffer + err = templates.BookShelf( + user, + libData, + libraryID, + errorMsg, + string(filtersJSON), // NEW: SSR saved filters as JSON string + bookInfoList, // NEW: SSR books + limit, // NEW: pagination limit + offset, // NEW: pagination offset + totalCount, // NEW: current page count + ).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +}) +``` + +**Notes:** +- ✅ Uses existing `GetSavedFilters` query directly (no new backend code) +- ✅ Uses existing `ListMediaItemsFiltered` query +- ✅ Converts to `handlers.BookInfo` struct (already defined) +- ✅ Handles errors gracefully (shows empty grid/filters if fetch fails) +- ✅ Respects URL params for pagination +- ✅ **Guideline-compliant:** All data fetched server-side, no async x-init +- ✅ **No backend changes needed** - All queries already exist + +--- + +### Phase 2: Template Changes + +#### File: `templates/bookshelf.templ` + +**Location 1:** Line 3 (templ signature) + +**Change:** Add new parameters + +```templ +templ BookShelf( + user User, + libraries []LibraryData, + currentLibraryID string, + errorMessage string, + savedFiltersJSON string, // NEW: JSON string of saved filters + books []handlers.BookInfo, // NEW + limit int, // NEW + offset int, // NEW + count int, // NEW +) { +``` + +**Location 2:** Lines 277-278 (books grid section) + +**Current:** +```templ + +
📚 No books found
+Try adjusting your filters or add some books to your library.
+