Updated the backend services and handlers to properly detect and pass
the has_cover parameter's validity state to the database layer.
Changes:
- services/search.go: Changed HasCover type from bool to pgtype.Bool
to support 3-state logic (NULL, TRUE, FALSE)
- handlers/media.go: Fixed 3-state detection by checking if has_cover
exists in query params before setting Valid flag
- router/search.go: Fixed 3-state detection to match media.go logic
- router/frontend.go: Use pgtype.Bool{Valid: false} for SSR initial
load to ensure no filtering occurs on first page load
The key fix is detecting whether the has_cover parameter was actually
sent in the request:
- Parameter not sent → pgtype.Bool{Bool: false, Valid: false}
- Parameter sent as "true" → pgtype.Bool{Bool: true, Valid: true}
- Parameter sent as "false" → pgtype.Bool{Bool: false, Valid: true}
Previously, media.go was hardcoding Valid: true, which meant it was
always filtering by has_cover=false (only books without covers) when
the parameter wasn't sent, causing searches to incorrectly return
0 results for queries like "1984".
This ensures consistency between the JSON API endpoint (media.go) and
the HTML endpoint (search.go), and fixes the critical bug where SSR
was returning 0 books on initial page load.
132 lines
4.0 KiB
Go
132 lines
4.0 KiB
Go
package router
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/utils"
|
|
"bookhoard/templates"
|
|
"bytes"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func registerSearchRoutes(cfg *Config) {
|
|
e := cfg.Echo
|
|
// JWT middleware for protected routes
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
// Search endpoint - returns JSON or HTML based on HX-Request header
|
|
protected.GET("/media-items/search", func(c *echo.Context) error {
|
|
// Check if HTMX request (expects HTML)
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return handleSearchHTML(c, cfg)
|
|
}
|
|
|
|
// Otherwise return JSON (API clients)
|
|
return cfg.MediaHandler.SearchMediaItems(c)
|
|
})
|
|
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
|
|
}
|
|
|
|
// handleSearchHTML renders HTML response for HTMX requests
|
|
func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.String(http.StatusUnauthorized, "Unauthorized")
|
|
}
|
|
// Extract query parameters
|
|
query := c.QueryParam("q")
|
|
libraryID := c.QueryParam("library_id")
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
// Validate library_id
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
lib, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.String(http.StatusBadRequest, "invalid library_id")
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
|
} else {
|
|
libUUID = pgtype.UUID{Valid: false}
|
|
}
|
|
// Extract filters
|
|
authorFilter := c.QueryParam("author_filter")
|
|
seriesFilter := c.QueryParam("series_filter")
|
|
genreFilter := c.QueryParam("genre_filter")
|
|
tagsFilter := c.QueryParam("tags_filter")
|
|
languageFilter := c.QueryParam("language_filter")
|
|
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
|
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
|
hasCover := false
|
|
hasCoverValid := false
|
|
if _, exists := c.QueryParams()["has_cover"]; exists {
|
|
hasCover = c.QueryParam("has_cover") == "true"
|
|
hasCoverValid = true
|
|
}
|
|
sortParam := c.QueryParam("sort")
|
|
if sortParam == "" {
|
|
sortParam = "title ASC"
|
|
}
|
|
// Build search params
|
|
params := services.SearchParams{
|
|
UserID: user.ID,
|
|
LibraryID: libUUID,
|
|
SearchQuery: query,
|
|
AuthorFilter: authorFilter,
|
|
SeriesFilter: seriesFilter,
|
|
GenreFilter: genreFilter,
|
|
TagsFilter: tagsFilter,
|
|
LanguageFilter: languageFilter,
|
|
YearMin: yearMin,
|
|
YearMax: yearMax,
|
|
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
|
Sort: sortParam,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
// Execute search using handler method
|
|
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
|
if err != nil {
|
|
log.Printf("Search error: %v", err)
|
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
|
|
}
|
|
// Convert to BookInfo
|
|
bookInfoList := make([]handlers.BookInfo, len(results))
|
|
for i, book := range results {
|
|
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: textToString(book.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
|
}
|
|
}
|
|
// Render using BooksGrid template
|
|
var buf bytes.Buffer
|
|
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
log.Printf("Template render error: %v", err)
|
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}
|
|
|
|
// textToString converts pgtype.Text to string
|
|
func textToString(t pgtype.Text) string {
|
|
if t.Valid {
|
|
return t.String
|
|
}
|
|
return ""
|
|
}
|