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

+12 -3
View File
@@ -3498,6 +3498,11 @@ RETURNING *;
-- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
-- Smart-collection match semantics (plan item 2.4): flip any|all on its
-- own channel — the rules page toggle writes exactly this column.
-- name: UpdateCollectionMatchMode :exec
UPDATE collections SET rule_match_mode = $2 WHERE id = $1;
-- Get collection
-- name: GetCollection :one
SELECT * FROM collections WHERE id = $1;
@@ -3525,7 +3530,8 @@ SET
color = $4,
icon = $5,
auto_assign_rules = $6,
view_settings = $7
view_settings = $7,
rule_match_mode = $8
WHERE id = $1
RETURNING *;
@@ -4144,9 +4150,12 @@ SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
-- membership comes from the candidate book set instead of collection_items.
-- The set is scoped to the requesting user's visible libraries (family
-- lens enforced at the source) and live items only — the same filters the
-- static collection paths apply.
-- static collection paths apply. The viewer's progress rides along (plan
-- item 2.4): the played/unplayed episode rule evaluates per-user, exactly
-- like the hide_finished predicate (percentage >= 0.999 = finished).
-- name: GetVisibleLiveMediaForRules :many
SELECT mi.* FROM media_items mi
SELECT sqlc.embed(mi), COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND mi.archived_at IS NULL