feat(web): real channel filter — multi-select of the user's channels
The free-text 'channel' filter was dead: it exact-matched SummaryRow.Channel,
which is just the provider ('youtube'), because videos never stored their source
channel. Now they do.
- migration 014: videos.channel_title (nullable; existing rows backfill on the
next discovery pass, pasted videos immediately).
- discovery (NewVideos) + paste (VideoByID) populate channel_title; UpsertVideo
persists it, preserving an existing title when an update arrives empty.
- store.DistinctChannels lists a user's channels (RLS-scoped); SummaryRow carries
ChannelTitle via the shared projection.
- Filter: single Channel -> Channels []string, matching on ChannelTitle; the feed
renders a multi-select of DistinctChannels (hidden until channels exist).
- migrate tests: 014 reversibility + fixed the relative-step counts in the 010/011
up/down tests (014 shifted the topology).
TDD throughout: channel persist + distinct, adapter channel wiring, multi-channel
filter match, handler channel filter, migration up/down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,9 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
|
||||
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
|
||||
|
||||
m := fileMigrator(t)
|
||||
// 011, 012, 013 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
// 011, 012, 013, 014 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
require.NoError(t, m.Steps(-1), "down 014 drops channel_title, login_events intact")
|
||||
require.True(t, loginEventsExists(t), "014 down leaves login_events intact")
|
||||
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors, login_events intact")
|
||||
require.True(t, loginEventsExists(t), "013 down leaves login_events intact")
|
||||
require.NoError(t, m.Steps(-1), "down 012 is a no-op, login_events intact")
|
||||
@@ -64,7 +66,7 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
|
||||
require.NoError(t, m.Steps(-1), "down 010 must drop login_events")
|
||||
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
|
||||
|
||||
require.NoError(t, m.Steps(4), "up must recreate 010 then re-apply 011, 012, 013")
|
||||
require.NoError(t, m.Steps(5), "up must recreate 010 then re-apply 011, 012, 013, 014")
|
||||
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
|
||||
}
|
||||
|
||||
@@ -87,6 +89,7 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
|
||||
|
||||
m := fileMigrator(t)
|
||||
require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
|
||||
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors")
|
||||
require.NoError(t, m.Steps(-1), "down 012 is a no-op")
|
||||
require.NoError(t, m.Steps(-1), "down 011 reverts the column default")
|
||||
@@ -96,6 +99,31 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
require.Equal(t, "true", autoSummarizeDefault(t))
|
||||
require.NoError(t, m.Steps(1), "up 012 runs clean (no FORCE RLS on fresh schema)")
|
||||
require.NoError(t, m.Steps(1), "up 013 creates channel_errors")
|
||||
require.NoError(t, m.Steps(1), "up 014 recreates channel_title")
|
||||
}
|
||||
|
||||
// channelTitleExists reports whether videos.channel_title is present.
|
||||
func channelTitleExists(t *testing.T) bool {
|
||||
t.Helper()
|
||||
var exists bool
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'videos' AND column_name = 'channel_title')`).Scan(&exists))
|
||||
return exists
|
||||
}
|
||||
|
||||
// TestMigration014VideoChannelTitleUpDown proves 014 is reversible: down drops
|
||||
// videos.channel_title, up recreates it.
|
||||
func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
|
||||
newStore(t) // latest (014 applied)
|
||||
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
|
||||
|
||||
m := fileMigrator(t)
|
||||
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
|
||||
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
|
||||
|
||||
require.NoError(t, m.Steps(1), "up 014 must recreate channel_title")
|
||||
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
|
||||
}
|
||||
|
||||
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE videos DROP COLUMN channel_title;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Store the source channel's title per video so the list can offer a real
|
||||
-- channel filter (multi-select of the user's channels) instead of the dead
|
||||
-- free-text field that only ever matched the provider string. Nullable: existing
|
||||
-- rows backfill on the next discovery pass (UpsertVideo writes it); pasted videos
|
||||
-- get it immediately from videos.list. No FK to a channels table at Stage 0 — the
|
||||
-- title is a denormalised display/filter value, consistent with the existing
|
||||
-- subscription_id-stays-NULL stance (data-model.md).
|
||||
ALTER TABLE videos ADD COLUMN channel_title TEXT;
|
||||
@@ -29,6 +29,7 @@ type SummaryRow struct {
|
||||
ProviderVideoID string // videos.provider_video_id; empty when no videos row
|
||||
Title string // videos.title; empty when no videos row
|
||||
Channel string // videos.provider for now; empty when no videos row
|
||||
ChannelTitle string // videos.channel_title; the source channel, for display + filtering
|
||||
URL string // videos.url; empty when no videos row
|
||||
PublishedAt time.Time // videos.published_at; zero when absent
|
||||
Summary string
|
||||
@@ -137,7 +138,8 @@ const selectVideo = `
|
||||
COALESCE(s.created_at, v.seen_at),
|
||||
(s.id IS NOT NULL) AS summarized,
|
||||
v.summarize_requested,
|
||||
COALESCE(v.transcript_status, '')
|
||||
COALESCE(v.transcript_status, ''),
|
||||
COALESCE(v.channel_title, '')
|
||||
FROM videos v
|
||||
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||
|
||||
@@ -254,6 +256,7 @@ func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
|
||||
&row.Summarized,
|
||||
&row.SummarizeRequested,
|
||||
&row.TranscriptStatus,
|
||||
&row.ChannelTitle,
|
||||
); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
|
||||
}
|
||||
|
||||
@@ -46,14 +46,15 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
||||
}
|
||||
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at, channel_title)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
url = EXCLUDED.url,
|
||||
published_at = EXCLUDED.published_at
|
||||
title = EXCLUDED.title,
|
||||
url = EXCLUDED.url,
|
||||
published_at = EXCLUDED.published_at,
|
||||
channel_title = COALESCE(NULLIF(EXCLUDED.channel_title, ''), videos.channel_title)
|
||||
RETURNING id`,
|
||||
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
||||
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt), v.ChannelTitle,
|
||||
).Scan(&id); err != nil {
|
||||
return fmt.Errorf("store: upsert video: %w", err)
|
||||
}
|
||||
@@ -110,3 +111,31 @@ func (s *Store) NewestUnsummarizedVideoIDs(ctx context.Context, userID string, l
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// DistinctChannels returns the user's distinct, non-empty source channel titles
|
||||
// (the channels they have videos from), alphabetically — the option list for the
|
||||
// feed's channel filter. RLS-scoped via withUser.
|
||||
func (s *Store) DistinctChannels(ctx context.Context, userID string) ([]string, error) {
|
||||
var out []string
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT DISTINCT channel_title FROM videos
|
||||
WHERE user_id = $1 AND channel_title IS NOT NULL AND channel_title <> ''
|
||||
ORDER BY channel_title`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: distinct channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return fmt.Errorf("store: scan channel: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return rows.Err()
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -113,3 +113,29 @@ func TestNewestUnsummarizedVideoIDs(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, none, "limit 0 returns nothing")
|
||||
}
|
||||
|
||||
func TestUpsertVideoPersistsChannelAndDistinctChannels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
mk := func(pid, channel string) {
|
||||
v := ytVideo(userA, pid, pid)
|
||||
v.ChannelTitle = channel
|
||||
_, err := s.UpsertVideo(ctx, v)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
mk("aa11111aaaa", "Acme Talks")
|
||||
mk("bb22222bbbb", "Acme Talks") // same channel
|
||||
mk("cc33333cccc", "Zeta Channel")
|
||||
// userB's channel must not leak.
|
||||
vb := ytVideo(userB, "dd44444dddd", "x")
|
||||
vb.ChannelTitle = "Bravo Only"
|
||||
_, err := s.UpsertVideo(ctx, vb)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := s.DistinctChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"Acme Talks", "Zeta Channel"}, got,
|
||||
"distinct, alphabetical, user-scoped (no Bravo Only)")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestVideoByID(t *testing.T) {
|
||||
if got := r.URL.Query().Get("part"); got != "snippet" {
|
||||
t.Errorf("expected part=snippet, got %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"Never Gonna Give You Up","publishedAt":"2026-05-20T09:00:00Z"}}]}`))
|
||||
_, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"Never Gonna Give You Up","channelTitle":"Rick Astley","publishedAt":"2026-05-20T09:00:00Z"}}]}`))
|
||||
})
|
||||
|
||||
v, err := a.VideoByID(context.Background(), "u1", id)
|
||||
@@ -34,6 +34,9 @@ func TestVideoByID(t *testing.T) {
|
||||
if v.ProviderVideoID != id || v.Title != "Never Gonna Give You Up" {
|
||||
t.Errorf("unexpected video: %+v", v)
|
||||
}
|
||||
if v.ChannelTitle != "Rick Astley" {
|
||||
t.Errorf("ChannelTitle = %q, want Rick Astley", v.ChannelTitle)
|
||||
}
|
||||
if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v="+id {
|
||||
t.Errorf("video not wired correctly: %+v", v)
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom
|
||||
Provider: domain.ProviderYouTube,
|
||||
ProviderVideoID: vid,
|
||||
Title: item.Snippet.Title,
|
||||
ChannelTitle: sub.ChannelTitle,
|
||||
URL: "https://www.youtube.com/watch?v=" + vid,
|
||||
PublishedAt: item.Snippet.PublishedAt,
|
||||
})
|
||||
@@ -270,6 +271,7 @@ func (a *Adapter) VideoByID(ctx context.Context, userID, videoID string) (domain
|
||||
Provider: domain.ProviderYouTube,
|
||||
ProviderVideoID: videoID,
|
||||
Title: it.Snippet.Title,
|
||||
ChannelTitle: it.Snippet.ChannelTitle,
|
||||
URL: "https://www.youtube.com/watch?v=" + videoID,
|
||||
PublishedAt: it.Snippet.PublishedAt,
|
||||
}, nil
|
||||
@@ -376,8 +378,9 @@ type playlistItemListResponse struct {
|
||||
type videoListResponse struct {
|
||||
Items []struct {
|
||||
Snippet struct {
|
||||
Title string `json:"title"`
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
Title string `json:"title"`
|
||||
ChannelTitle string `json:"channelTitle"`
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
} `json:"snippet"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestNewVideos(t *testing.T) {
|
||||
}`))
|
||||
})
|
||||
|
||||
sub := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"}
|
||||
sub := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme", ChannelTitle: "Acme Channel"}
|
||||
vids, err := a.NewVideos(context.Background(), sub)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVideos: %v", err)
|
||||
@@ -143,6 +143,9 @@ func TestNewVideos(t *testing.T) {
|
||||
if v.ProviderVideoID != "vid1" || v.Title != "Designing for Attention" {
|
||||
t.Errorf("unexpected video: %+v", v)
|
||||
}
|
||||
if v.ChannelTitle != "Acme Channel" {
|
||||
t.Errorf("ChannelTitle = %q, want Acme Channel", v.ChannelTitle)
|
||||
}
|
||||
if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v=vid1" {
|
||||
t.Errorf("video not wired correctly: %+v", v)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user