Files
bookhoard/internal/router/search.go
T
John O'Keefe 96a40f7d88 feat(filter): label endpoint names id values for client-held trees
The SSR embed (65f257f3) covers trees the server renders; one path
still fell through to the flawed fallback — applying a SAVED filter
from a cold bookshelf. That page was rendered before any filter
existed (nothing embedded), and the fallback reused the suggest facet,
whose rows are MEDIA-CONSTRAINED: "feed in [A,B,C] AND title contains
Chapter" labeled only the feeds with a matching episode (the exact
live repro from the original bug report).

- GET /api/filter/labels?field=X&ids=a,b,c resolves names straight
  from the entity tables via the shared ResolveFieldLabels core
  (extracted from FilterTreeLabels): no media join, no tree
  constraint. RLS/visibility ride the request identity — a
  zero-episode subscribed feed resolves its title (pinned; the facet
  path structurally could not), an unsubscribed feed id stays unnamed
  (pinned), non-id fields are 400 (pinned), ids cap at 100.
- Client: resolveIdLabels/resolveLabelsAsync use ONLY the endpoint;
  the quiet suggest-fetch mode is deleted (its only caller was the
  flawed fallback). The saved-filter apply flow resolves through the
  endpoint and rebuilds the chips bar when labels land — chips render
  raw ids only while the round trip is in flight, never stuck.
- 188 web tests green; Go endpoint pins + filter/episode slices green;
  contract_pins documents the endpoint and the fallback removal.
2026-11-01 13:40:25 -05:00

132 lines
5.2 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"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)
// Filter label resolution (2026-11-01): names id-kind filter values
// from their source tables for trees the server didn't render (the
// cold saved-filter apply). Visibility/RLS ride the request identity.
protected.GET("/filter/labels", cfg.MediaHandler.ResolveFilterLabels)
// 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)
})
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
// Rule-builder live count (faceted plan Phase 9): the composed
// tree's true match count (COUNT(*)), rules + library scope.
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
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")
}
// Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true
// swaps the book cards for YOUR subscriptions' episodes — visibility
// is the RLS policy's job (D20), the Go side adds nothing. The books
// branch keeps its podcasts exclusion; this is the only door in.
if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" {
eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q"))
if err != nil {
log.Printf("Episode picker search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
var buf bytes.Buffer
if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously
// dropped publisher_filter and narrator_filter entirely — the shared
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
// Filter rule builder: a malformed qf is a 400, never a silently
// ignored token.
if params.FilterParseErr != "" {
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
}
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
// Family-lens gate: an explicit library_id outside the requester's
// visible set is a 403 (same rule as the JSON branch).
if params.LibraryID.Valid {
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
if err != nil {
log.Printf("VisibleLibrarySet failed: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
visible := false
for _, id := range ids {
if id == params.LibraryID {
visible = true
break
}
}
if !visible {
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
}
}
// 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 := handlers.BookCardsFromUnified(results)
// Stamp active conflict flags so cards route the play action correctly
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
switch {
case c.QueryParam("show_checkbox") == "true":
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
case c.QueryParam("layout") == "dropdown":
// Header-search dropdown: compact templ rows (rendering
// ownership) carrying the viewer's progress/completed overlay.
// Replaces the old innerHTML string renderer in search.ts.
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
default:
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())
}