Files
bookhoard/internal/router/frontend.go
T
john-okeefe 9b3d8cc949 feat: implement collection library filter with WebSocket improvements and test coverage
This commit adds comprehensive functionality for filtering collections by library,
improves WebSocket real-time updates with user activity detection, and adds
extensive test coverage.

## Core Features

### Collection Library Filter
- Added library_id parameter to media-items search API
- Collections can now be filtered by specific library
- Toggle UI component for enabling/disabling library filter
- Default state is "checked" when library_id is present
- Consistent behavior across partial and fuzzy search modes

### WebSocket Auto-Reload Mitigation
- Added user activity detection to prevent disruptive page reloads
- Checks if user is actively typing in INPUT/TEXTAREA/SELECT elements
- Skips auto-reload when user is interacting with form elements
- Toast notifications still show for awareness
- Prevents data loss during editing operations

## Implementation Changes

### Backend
- internal/database/queries.sql.go: Added library filter support to search queries
- internal/handlers/media.go: Enhanced search with library_id parameter validation
- internal/handlers/collections.go: Updated collection handlers with library filtering
- internal/sync/websocket.go: Improved broadcast mechanism with user-scoped updates
- internal/router/frontend.go: Pass libraryID to collection templates

### Frontend
- templates/collections.templ: Added library filter toggle UI component
- web/src/collections.ts: TypeScript implementation with WebSocket integration
- templates/collections_templ.go: Generated template code

### Testing
- cmd/server/tests/search_test.go: Added TestCollectionSearchLibraryFilter
- cmd/server/tests/websocket_test.go: Added TestWebSocketUserScopedBroadcast
- New helper functions for creating libraries and media items via API
- Comprehensive test coverage for library filtering and user-scoped broadcasts

## API Documentation Updates

### Bruno Tests (Comprehensive Documentation)
- bruno/collections/*: Added detailed API documentation for all collection endpoints
- bruno/devices/*: Added device management and sync API documentation
- bruno/devices/kobo/api.yml: Kobo-specific sync protocol docs
- bruno/devices/koreader/api.yml: KOReader-specific sync protocol docs
- bruno/opds/*: Added OPDS feed and download endpoint documentation
- bruno/library/browse-folders.yml: Library folder browsing API docs

### New Bruno Tests
- bruno/media-items/Search All Libraries.yml: Test search without library filter
- bruno/media-items/Search Specific Library.yml: Test search with library filter
- bruno/media-items/Search Invalid Library ID.yml: Test error handling

## Documentation

- docs/developer/api/media-items/search_media_items.md: Updated with library_id parameter
- IMPLEMENTATION_COLLECTION_FIX.md: Comprehensive implementation guide with test scenarios

## Testing

### Integration Tests
- Library filter tests verify correct filtering across multiple libraries
- Invalid library_id tests ensure proper error handling
- WebSocket tests verify user-scoped broadcast behavior
- User A no longer receives User B's collection updates

### Manual Testing Scenarios
- Open collection in multiple tabs - updates propagate correctly
- Type in search box while another tab adds books - no disruptive reload
- Add/remove books from collection - toast notifications appear
- Toggle library filter - results update dynamically

## Technical Details

- WebSocket broadcasts are now user-scoped for privacy
- Active element detection uses tagName and contenteditable attributes
- Library ID validation uses UUID format checking
- Progressive enhancement maintained - page works without JavaScript
- All changes follow PROJECT_GUIDELINES.md conventions
- TypeScript only for frontend logic
- TailwindCSS only for styling
- Procedural programming style throughout

## Breaking Changes

None - all changes are additive and backward compatible.
2026-03-04 22:37:47 -05:00

849 lines
26 KiB
Go

package router
import (
"bytes"
"context"
"log"
"net/http"
"time"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
"github.com/google/uuid"
)
func renderErrorPage(c echo.Context, message string, errorType string) error {
var buf bytes.Buffer
err := templates.ErrorPage(message, errorType).Render(c.Request().Context(), &buf)
if err != nil {
log.Printf("renderErrorPage failed to render template: %v", err)
return c.HTML(http.StatusInternalServerError, "Internal server error")
}
return c.HTML(http.StatusInternalServerError, buf.String())
}
func registerFrontendRoutes(cfg *Config) {
e := cfg.Echo
// ============================================================================
// FRONTEND ROUTES - DO NOT DELETE
// These routes serve Server-Side Rendered (SSR) HTML pages for the web UI.
// They are NOT API endpoints and should NOT be removed during refactors.
// All authenticated frontend routes use the jwtMiddleware to validate tokens.
// ============================================================================
// Use existing JWT middleware (sets database user object in context)
jwtMiddleware := createJWTMiddleware(cfg)
// Protected route group for API routes
protected := e.Group("/api", jwtMiddleware)
// ============================================================================
// PUBLIC FRONTEND ROUTES (No authentication required)
// ============================================================================
// Public routes for login and registration pages
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)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
e.GET("/register", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.Register().Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Root route - landing page with smart login detection
e.GET("/", func(c echo.Context) error {
var buf bytes.Buffer
var err error
tokenString := c.Request().Header.Get("Authorization")
if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " {
tokenString = tokenString[7:]
} else {
cookie, err := c.Cookie("token")
if err == nil {
tokenString = cookie.Value
}
}
loggedIn := false
if tokenString != "" {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.Cfg.JWTSecret), nil
})
loggedIn = err == nil && token.Valid
}
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// PROTECTED FRONTEND ROUTES (Authentication required)
// ============================================================================
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
// Helper to extract text from pgtype.Text
getText := func(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
// Dashboard page
frontendProtected.GET("/dashboard", 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
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()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
}
limit := 20
if prefs.ItemsPerSection.Int32 > 0 {
limit = int(prefs.ItemsPerSection.Int32)
}
// Get ALL sections (unfiltered) for the modal
allSections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.CollectionOrder,
[]string{}, // No filtering - get all sections
)
if err != nil {
log.Printf("Dashboard sections query failed: %v", err)
allSections = []services.DashboardSection{}
errorMsg = "Error loading dashboard"
}
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
userUUID2, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
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,
}
}
sectionData := handlers.BuildSections(visibleSections, libraryID)
allSectionsData := handlers.BuildSections(allSections, libraryID)
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collections page
frontendProtected.GET("/collections", 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
var collections []database.Collections
collections, err = cfg.CollectionHandler.GetCollectionsData(c)
if err != nil {
log.Printf("GetCollectionsData failed: %v", err)
collections = []database.Collections{}
errorMsg = "Error loading collections"
}
colData := make([]templates.CollectionData, len(collections))
for i, col := range collections {
colData[i] = templates.CollectionData{
ID: uuid.UUID(col.ID.Bytes).String(),
Name: col.Name,
Description: getText(col.Description),
Color: getText(col.Color),
Icon: getText(col.Icon),
}
}
var buf bytes.Buffer
err = templates.Collection(user, colData, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collection create modal
frontendProtected.GET("/collections/create-modal", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.CollectionModal(templates.CollectionData{}).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collection edit modal
frontendProtected.GET("/collections/:id/edit-modal", func(c echo.Context) error {
// Parse collection ID
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
}
// Fetch collection data
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
}
// Convert to template type
colData := templates.CollectionData{
ID: uuid.UUID(collection.ID.Bytes).String(),
Name: collection.Name,
Description: getText(collection.Description),
Color: getText(collection.Color),
Icon: getText(collection.Icon),
}
// Ensure default color if empty
if colData.Color == "" {
colData.Color = "blue"
}
var buf bytes.Buffer
err = templates.CollectionModal(colData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Restore system collection modal
frontendProtected.GET("/collections/restore-modal", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.RestoreSystemCollectionModal().Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collection detail page (works for both system and user collections)
frontendProtected.GET("/collections/:id", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse collection ID from URL
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
}
// Fetch collection details
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
if err.Error() == "no rows in result set" {
return renderErrorPage(c, "Collection not found", "not_found")
}
return renderErrorPage(c, "Error loading collection", "collection_load_error")
}
// Fetch books in collection
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
if collection.QueryType.Valid && collection.QueryType.String != "" {
// System collection - use query type
// System collection - need library_id for system collections
// Get library_id from query param or default to user's first library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
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()
}
}
libUUID, _ := uuid.Parse(libraryID)
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if err != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
// Find the matching section and convert items
for _, section := range sections {
if section.CollectionID.String() == collectionID {
// Convert []database.MediaItems to []handlers.BookInfo
bookCards := make([]handlers.BookInfo, len(section.Items))
for i, item := range section.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
books = bookCards
break
}
}
} else {
// User collection - check if library_id filter is present
libraryID := c.QueryParam("library_id")
if libraryID != "" {
// Filter by library - reuse dashboard query
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
// Use GetCollectionItemsForDashboard for library-filtered results
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: 1000,
})
if err != nil {
books = []handlers.BookInfo{}
} else {
// Convert to BookInfo format (non-excluded only)
var validItems []database.GetCollectionItemsForDashboardRow
for _, item := range collItems {
if !item.Excluded.Valid || !item.Excluded.Bool {
validItems = append(validItems, item)
}
}
bookCards := make([]handlers.BookInfo, len(validItems))
for i, item := range validItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
books = bookCards
}
} else {
// No library filter - show all books in collection
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
books = []handlers.BookInfo{}
}
// Convert to BookInfo format
bookCards := make([]handlers.BookInfo, len(collItems))
for i, item := range collItems {
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
books = bookCards
}
}
// Build collection data
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
}
// Get library_id from query params for template
libraryID := c.QueryParam("library_id")
// Render the CollectionDetail template
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Custom Section Builder page
frontendProtected.GET("/custom-section", 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
var libraries []database.GetUserVisibleLibrariesRow
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{}
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,
}
}
var buf bytes.Buffer
err = templates.CustomSectionBuilder(user, libData, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Progress page
frontendProtected.GET("/progress", 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
var progressData []handlers.ProgressWithMedia
progressData, err = cfg.ScannerHandler.GetAllProgressData(c)
if err != nil {
log.Printf("GetAllProgressData failed: %v", err)
progressData = []handlers.ProgressWithMedia{}
errorMsg = "Error loading progress"
}
var buf bytes.Buffer
err = templates.Progress(user, progressData, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Devices page
frontendProtected.GET("/devices", 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
var devices []handlers.DeviceInfo
var pendingList []templates.PendingRegistrationData
devices, err = cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
log.Printf("GetDevicesData failed: %v", err)
devices = []handlers.DeviceInfo{}
errorMsg = "Error loading devices"
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
log.Printf("GetPendingRegistrationsData failed: %v", err)
pendingList = []templates.PendingRegistrationData{}
if errorMsg == "" {
errorMsg = "Error loading pending registrations"
}
} else {
pendingList = convertPending(pendingMaps)
}
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Conflicts page
frontendProtected.GET("/conflicts", 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
var conflictsData []handlers.ConflictDetailResponse
var total, unresolved int
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
if err != nil {
log.Printf("GetConflictsData failed: %v", err)
conflictsData = []handlers.ConflictDetailResponse{}
total = 0
unresolved = 0
errorMsg = "Error loading conflicts"
}
var buf bytes.Buffer
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page
frontendProtected.GET("/analytics", 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.Analytics(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// 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())
})
// ============================================================================
// ADMIN FRONTEND ROUTES
// ============================================================================
// Admin routes
frontendProtected.GET("/admin", handlers.AdminMiddleware(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.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/", handlers.AdminMiddleware(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.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/library", 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 libraries (admin-only)
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
if err != nil {
log.Printf("ListLibrariesData failed: %v", err)
libraries = []database.ListLibrariesRow{}
}
// Convert to template LibraryData
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,
}
}
// Fetch all users for visibility management
users, err := cfg.Queries.ListUsers(c.Request().Context())
if err != nil {
log.Printf("ListUsers failed: %v", err)
users = []database.ListUsersRow{}
}
// Convert to template User types
userData := make([]templates.User, len(users))
for i, u := range users {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
userData[i] = templates.User{
ID: userUUID.String(),
Username: u.Username,
Email: u.Email,
Role: u.Role,
}
}
var buf bytes.Buffer
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// 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++
}
}
// Convert to template users
templateUsers := make([]templates.User, len(users))
for i, u := range users {
templateUsers[i] = templates.User{
ID: uuid.UUID(u.ID.Bytes).String(),
Username: u.Username,
Email: u.Email,
Role: u.Role,
Theme: getText(u.Theme),
FirstName: getText(u.FirstName),
LastName: getText(u.LastName),
CreatedAt: u.CreatedAt.Time,
}
}
var buf bytes.Buffer
err = templates.AdminUsers(user, templateUsers, adminCount).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin: Get profile modal for editing user
frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c echo.Context) error {
// Get target user ID from URL
targetUserID := c.Param("id")
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid user ID</div>")
}
// Fetch target user
targetUser, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(parsedUUID))
if err != nil {
return c.HTML(http.StatusNotFound, "<div>User not found</div>")
}
// Convert to template user
templateUser := templates.User{
ID: uuid.UUID(targetUser.ID.Bytes).String(),
Username: targetUser.Username,
Email: targetUser.Email,
Role: targetUser.Role,
Theme: getText(targetUser.Theme),
FirstName: getText(targetUser.FirstName),
LastName: getText(targetUser.LastName),
CreatedAt: targetUser.CreatedAt.Time,
}
// Render modal
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())
}))
// ============================================================================
// LEGACY API ROUTES (for backward compatibility)
// ============================================================================
// Keep legacy routes under /api for existing API consumers
protected.GET("/devices-page", 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
var devices []handlers.DeviceInfo
var pendingList []templates.PendingRegistrationData
devices, err = cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
log.Printf("GetDevicesData failed: %v", err)
devices = []handlers.DeviceInfo{}
errorMsg = "Error loading devices"
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
log.Printf("GetPendingRegistrationsData failed: %v", err)
pendingList = []templates.PendingRegistrationData{}
if errorMsg == "" {
errorMsg = "Error loading pending registrations"
}
} else {
pendingList = convertPending(pendingMaps)
}
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
protected.GET("/conflicts-page", 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
var conflictsData []handlers.ConflictDetailResponse
var total, unresolved int
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
if err != nil {
log.Printf("GetConflictsData failed: %v", err)
conflictsData = []handlers.ConflictDetailResponse{}
total = 0
unresolved = 0
errorMsg = "Error loading conflicts"
}
var buf bytes.Buffer
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// HEALTH CHECK
// ============================================================================
// Health check
e.GET("/health", func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
defer cancel()
if err := pingDB(cfg, ctx); err != nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"error": "database unavailable",
})
}
return c.JSON(http.StatusOK, map[string]string{
"status": "healthy",
"database": "connected",
})
})
}