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.
This commit is contained in:
1 parent
a20741ccc9
commit
96a40f7d88
8 files changed
+274
-78
No files matched your search
@@ -248,6 +248,31 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
|
||||
// resolve id labels through it — 2026-11-01).
|
||||
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
|
||||
|
||||
// ResolveFilterLabels — GET /api/filter/labels?field=X&ids=a,b,c names
|
||||
// id-kind filter values straight from their source tables: the client
|
||||
// path that never touches a server render (applying a SAVED filter
|
||||
// from a cold bookshelf) would otherwise fall back to the suggest
|
||||
// facet, whose rows are media-constrained and label only the ids whose
|
||||
// media matches the tree's sibling rules (2026-11-01 feed repro).
|
||||
// Visibility/RLS ride the request identity, same as the SSR embed.
|
||||
func (h *MediaHandler) ResolveFilterLabels(c *echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
field := c.QueryParam("field")
|
||||
if !services.IsIDValuedField(field) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "field is not an id-valued filter field"})
|
||||
}
|
||||
ids := services.ParseUUIDValues(strings.Split(c.QueryParam("ids"), ","))
|
||||
if len(ids) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "ids required"})
|
||||
}
|
||||
if len(ids) > services.MaxResolveIDs {
|
||||
ids = ids[:services.MaxResolveIDs]
|
||||
}
|
||||
labels := h.SearchService().ResolveFieldLabels(
|
||||
c.Request().Context(), user.ID, field, ids)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"labels": labels})
|
||||
}
|
||||
|
||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
return h.searchService.ExecuteSearch(ctx, params)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ func registerSearchRoutes(cfg *Config) {
|
||||
// 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)
|
||||
|
||||
@@ -29,9 +29,25 @@ import (
|
||||
// a legal pick, the resolver only names it.
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// maxResolveIDs bounds one field's resolution query (picks accumulate
|
||||
// per rule; menus cap far below this).
|
||||
const maxResolveIDs = 100
|
||||
// MaxResolveIDs bounds one field's resolution query (picks accumulate
|
||||
// per rule; menus cap far below this). The label endpoint truncates to
|
||||
// the same cap.
|
||||
const MaxResolveIDs = 100
|
||||
|
||||
// IsIDValuedField — whether the field's values are entity ids (the
|
||||
// label endpoint's accepted universe: id-kind + a known source).
|
||||
func IsIDValuedField(field string) bool {
|
||||
f, ok := FilterFieldByKey(field)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valueKindFor(f.Kind, field) == VKId && f.Suggest != ""
|
||||
}
|
||||
|
||||
// ParseUUIDValues splits comma-joined picks into uuids, skipping junk.
|
||||
func ParseUUIDValues(values []string) []uuid.UUID {
|
||||
return parseUUIDValues(values)
|
||||
}
|
||||
|
||||
// FilterTreeLabels resolves every id-kind rule value in the tree to its
|
||||
// display name. Best-effort by design: a resolve failure degrades to
|
||||
@@ -64,7 +80,7 @@ func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID
|
||||
continue
|
||||
}
|
||||
for _, id := range parseUUIDValues(c.Values) {
|
||||
if len(byField[c.Field]) >= maxResolveIDs {
|
||||
if len(byField[c.Field]) >= MaxResolveIDs {
|
||||
return
|
||||
}
|
||||
byField[c.Field] = append(byField[c.Field], id)
|
||||
@@ -74,48 +90,62 @@ func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID
|
||||
walk(tree.Children)
|
||||
|
||||
for field, ids := range byField {
|
||||
f, _ := FilterFieldByKey(field)
|
||||
idArgs := make([]uuid.UUID, len(ids))
|
||||
copy(idArgs, ids)
|
||||
var sqlStr string
|
||||
var args []any
|
||||
switch f.Suggest {
|
||||
case suggestLibrary:
|
||||
// The facet's visibility shape, minus the media join: a
|
||||
// hidden library is invisible even by id (family lens).
|
||||
sqlStr = `SELECT l.id::text, l.name
|
||||
FROM libraries l
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
|
||||
args = []any{idArgs, userID}
|
||||
case suggestFeed:
|
||||
// RLS-governed (subscription + visibility predicate): an
|
||||
// unsubscribed feed id resolves to nothing.
|
||||
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestCollection:
|
||||
// RLS-governed (app_can_read_collection).
|
||||
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestAddedBy:
|
||||
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
default:
|
||||
continue
|
||||
for k, v := range s.ResolveFieldLabels(ctx, userID, field, ids) {
|
||||
labels[k] = v
|
||||
}
|
||||
rows, err := s.raw.Query(ctx, sqlStr, args...)
|
||||
if err != nil {
|
||||
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
|
||||
continue
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// ResolveFieldLabels names one field's picked ids against its source
|
||||
// table — the shared core of the SSR embed and the label endpoint.
|
||||
// Unknown fields resolve to nothing (the caller validates).
|
||||
func (s *SearchService) ResolveFieldLabels(ctx context.Context, userID pgtype.UUID, field string, ids []uuid.UUID) map[string]string {
|
||||
labels := map[string]string{}
|
||||
f, ok := FilterFieldByKey(field)
|
||||
if !ok {
|
||||
return labels
|
||||
}
|
||||
idArgs := make([]uuid.UUID, len(ids))
|
||||
copy(idArgs, ids)
|
||||
var sqlStr string
|
||||
var args []any
|
||||
switch f.Suggest {
|
||||
case suggestLibrary:
|
||||
// The facet's visibility shape, minus the media join: a
|
||||
// hidden library is invisible even by id (family lens).
|
||||
sqlStr = `SELECT l.id::text, l.name
|
||||
FROM libraries l
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
|
||||
args = []any{idArgs, userID}
|
||||
case suggestFeed:
|
||||
// RLS-governed (subscription + visibility predicate): an
|
||||
// unsubscribed feed id resolves to nothing.
|
||||
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestCollection:
|
||||
// RLS-governed (app_can_read_collection).
|
||||
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestAddedBy:
|
||||
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
default:
|
||||
return labels
|
||||
}
|
||||
rows, err := s.raw.Query(ctx, sqlStr, args...)
|
||||
if err != nil {
|
||||
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
|
||||
return labels
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v, display string
|
||||
if err := rows.Scan(&v, &display); err != nil {
|
||||
break
|
||||
}
|
||||
for rows.Next() {
|
||||
var v, display string
|
||||
if err := rows.Scan(&v, &display); err != nil {
|
||||
break
|
||||
}
|
||||
labels[v] = display
|
||||
}
|
||||
rows.Close()
|
||||
labels[v] = display
|
||||
}
|
||||
return labels
|
||||
}
|
||||
Reference in new issue
Block a user