feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)

The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.

Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.

Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.

Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).

Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
This commit is contained in:
John O'Keefe committed 2026-10-30 16:54:36 -04:00
1 parent f68fb19afb
commit abb7c84833
17 files changed
+694 -204

No files matched your search

+90 -9
View File
@@ -47,6 +47,9 @@ type CreateCollectionRequest struct {
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
// (AND). Empty/invalid keeps the stored value.
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
@@ -217,8 +220,10 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil {
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
LibraryID: libUUID,
VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr == nil {
bookCount = len(matches)
@@ -295,8 +300,10 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
LibraryID: libUUID,
VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
@@ -411,6 +418,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings,
"rule_match_mode": collection.RuleMatchMode,
"books": bookList,
"book_count": len(bookList),
"is_smart": isSmart,
@@ -459,6 +467,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
req.RuleMatchMode,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -974,6 +983,9 @@ func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"`
// MatchMode previews the collection's rule_match_mode: "any" (OR,
// default) or "all" (AND — the daily briefing). Empty = any.
MatchMode string `json:"match_mode"`
}
type BookMatch struct {
@@ -1004,18 +1016,26 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
if len(visibleIDs) > 0 {
visibleArg = visibleIDs
}
viewerID := pgtype.UUID{}
if u, ok := c.Get("user").(database.Users); ok {
viewerID = u.ID
}
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
LibraryID: pgtype.UUID{},
VisibleLibraryIds: visibleArg,
ViewerUserID: viewerID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
}
matchAll := req.MatchMode == services.RuleMatchAll
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
for _, row := range mediaItems {
matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll)
if matchReason != "" {
item := row.MediaItems
author := ""
if item.Author.Valid {
author = item.Author.String
@@ -1037,13 +1057,34 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
})
}
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
// checkRulesAgainstBook evaluates the candidate against the ruleset via
// the SERVICE evaluator (one vocabulary, one semantics — the handler
// keeps only the match-reason display). matchAll=true (the collection's
// "all" mode) requires every rule to hit.
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, viewerPercentage float64, rules []map[string]interface{}, matchAll bool) string {
service := h.collectionService
if matchAll {
matched := 0
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if !service.FieldMatches(item, viewerPercentage, field, operator, value) {
return ""
}
matched++
}
if matched > 0 {
return fmt.Sprintf("Matched all %d rules", matched)
}
return ""
}
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if h.evaluateRule(item, field, operator, value) {
if service.FieldMatches(item, viewerPercentage, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
}
}
@@ -1268,9 +1309,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Book-scope preview (the rules page remains books-first until the
// filter-unification pass); no viewer progress loaded here, so
// played/unplayed rules preview at the no-progress default.
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
@@ -1320,6 +1364,43 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
// ids omitted keep their relative order appended after (the service never
// deletes membership).
// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item
// 2.4): flips the smart ruleset's semantics any|all on its own channel
// — the rules page toggle writes exactly this column, nothing else.
func (h *CollectionHandler) SetMatchMode(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
var req struct {
Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
if err != nil || !owns {
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
}
if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
RuleMatchMode: req.Mode,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode})
}
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {