feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)

The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.

- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
  listable facets in a single round trip: each arm omits its OWN
  facet's filter block and carries every other registered facet's
  block (copied mechanically from SearchMediaItemsUnified) — classic
  faceted semantics: with filters active each facet counts under the
  others; with none, blocks are neutral and the counts are the v1
  global-per-library ones. One query serves both cases. The 14
  per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
  ({facets: {param: [{value, display?, count}]}}), filters bound via
  the shared ParseMediaSearchParams, library_id optional (the
  All-Libraries view, family lens still enforced); facet=X keeps the
  pinned single-facet shape (now routed through the same query with
  neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
  value lists — registry order, Batch D library-type scoping
  (FacetsForLibraryType: manga_type on manga, reading_direction on
  manga+comics, narrator on audiobooks; All view = union), prettified
  genre labels + language display names, active values marked. Family
  lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
  apply with the CURRENT filter query string; a value click toggles
  that facet's comma-joined filter (hidden input where the drawer has
  no field — same as deep links) and re-applies the form; chips and
  URL stay in sync. Values are server-rendered (rendering doctrine);
  bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
  implementation (services' display enrichment shares it; templ wraps
  it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
  registry facet's param, the bools as pgtype.Bool.

Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
John O'Keefe committed 2026-10-20 08:24:36 -04:00
1 parent 6152137156
commit f52bb6f073
18 files changed
+3001 -911

No files matched your search

+75 -28
View File
@@ -2446,22 +2446,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
}
return services.SearchParams{
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
SeriesFilter: c.QueryParam("series_filter"),
GenreFilter: c.QueryParam("genre_filter"),
PublisherFilter: c.QueryParam("publisher_filter"),
TagsFilter: c.QueryParam("tags_filter"),
NarratorFilter: c.QueryParam("narrator_filter"),
LanguageFilter: c.QueryParam("language_filter"),
YearMin: yearMin,
YearMax: yearMax,
HasCover: boolFacet("has_cover"),
StoryArcFilter: c.QueryParam("story_arc_filter"),
ImprintFilter: c.QueryParam("imprint_filter"),
EditionFilter: c.QueryParam("edition_filter"),
AgeRatingFilter: c.QueryParam("age_rating_filter"),
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
SeriesFilter: c.QueryParam("series_filter"),
GenreFilter: c.QueryParam("genre_filter"),
PublisherFilter: c.QueryParam("publisher_filter"),
TagsFilter: c.QueryParam("tags_filter"),
NarratorFilter: c.QueryParam("narrator_filter"),
LanguageFilter: c.QueryParam("language_filter"),
YearMin: yearMin,
YearMax: yearMax,
HasCover: boolFacet("has_cover"),
StoryArcFilter: c.QueryParam("story_arc_filter"),
ImprintFilter: c.QueryParam("imprint_filter"),
EditionFilter: c.QueryParam("edition_filter"),
AgeRatingFilter: c.QueryParam("age_rating_filter"),
FormatGroupFilter: c.QueryParam("format_group_filter"),
MangaTypeFilter: c.QueryParam("manga_type_filter"),
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
@@ -2490,11 +2490,21 @@ func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (
return false, nil
}
// GetFacets handles GET /api/media-items/facets?facet=X&library_id=...
// — the filter panel's value+count listing (faceted-navigation Batch C).
// Registry-driven: the facet param must name a FacetArray or
// FacetScalar entry (bool/range facets render as toggles, not lists).
// Family-lens: an explicit library_id outside the visible set is 403.
// GetFacets handles GET /api/media-items/facets — the filter panel's
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
//
// Two shapes:
// - ?facet=X&library_id=… → the PINNED single-facet shape
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
// entry (bool/range facets render as toggles, not lists).
// - no facet param → the all-facets shape ({"facets": {param:
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
// params the search endpoint takes) — each facet counts under the
// others, the SQL omitting each arm's own block. library_id is
// optional: absent = the All-Libraries view.
//
// Family-lens: an explicit library_id outside the visible set is 403
// in both shapes.
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
@@ -2503,8 +2513,43 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
facetParam := c.QueryParam("facet")
libraryID := c.QueryParam("library_id")
if facetParam == "" || libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet and library_id are required"})
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
if facetParam == "" {
// All-facets shape: filters bind exactly like the search
// endpoint's (ParseMediaSearchParams), so the panel's counts
// and the grid's rows can never disagree about what's active.
var libPg pgtype.UUID
if libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
}
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
if err != nil {
c.Logger().Error("facet counts error", "error", err.Error())
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"facets": counts,
})
}
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
}
facet := services.FacetByParam(facetParam)
if facet == nil {
@@ -2527,11 +2572,6 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
if err != nil {
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
@@ -2543,6 +2583,13 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
})
}
// FacetCountsForView is the SSR facet panel's data path (faceted plan
// Phase 7): the all-facets counts under the active filters for one
// library — or across every visible library when libraryID is NULL.
func (mh *MediaHandler) FacetCountsForView(ctx context.Context, userID, libraryID pgtype.UUID, params services.SearchParams, limit int) (map[string][]services.FacetValue, error) {
return mh.searchService.FacetCounts(ctx, userID, libraryID, params, limit)
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {