feat(store,runner,web): channel unavailability notice (migration 013)
YouTube channels that 404 on playlist discovery (deleted/private) are now: 1. Wrapped in domain.ErrChannelUnavailable by the YouTube adapter (instead of a generic error), so the runner can identify them without string-matching. 2. Stored per-user in channel_errors (migration 013, RLS-guarded) via runner's new UpsertChannelError path — removed from the generic Errors counter, counted separately as ChannelUnavailable. 3. Shown on the account page under "Unavailable channels" with name, chip-warn badge, and first-seen date, so users know why some subscribed channels produce no videos.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ChannelError is a channel that returned HTTP 404 on a discovery pass.
|
||||
type ChannelError struct {
|
||||
ChannelID string
|
||||
ChannelName string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// UpsertChannelError records (or refreshes) a 404 channel for the current user.
|
||||
// Called by the runner inside a withUser scope; RLS guards user isolation.
|
||||
func (s *Store) UpsertChannelError(ctx context.Context, userID, channelID, channelName string) error {
|
||||
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_errors (user_id, channel_id, channel_name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, channel_id)
|
||||
DO UPDATE SET channel_name = EXCLUDED.channel_name, last_seen = now()`,
|
||||
userID, channelID, channelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: upsert channel error: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListChannelErrors returns all 404-flagged channels for the user, newest first.
|
||||
func (s *Store) ListChannelErrors(ctx context.Context, userID string) ([]ChannelError, error) {
|
||||
var out []ChannelError
|
||||
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT channel_id, channel_name, first_seen, last_seen
|
||||
FROM channel_errors
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_seen DESC`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: list channel errors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ce ChannelError
|
||||
if err := rows.Scan(&ce.ChannelID, &ce.ChannelName, &ce.FirstSeen, &ce.LastSeen); err != nil {
|
||||
return fmt.Errorf("store: scan channel error: %w", err)
|
||||
}
|
||||
out = append(out, ce)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
@@ -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 and 012 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
// 011, 012, 013 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
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")
|
||||
require.True(t, loginEventsExists(t), "012 down leaves login_events intact")
|
||||
require.NoError(t, m.Steps(-1), "down 011 must not touch login_events")
|
||||
@@ -62,7 +64,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(3), "up must recreate 010 then re-apply 011 and 012")
|
||||
require.NoError(t, m.Steps(4), "up must recreate 010 then re-apply 011, 012, 013")
|
||||
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
|
||||
}
|
||||
|
||||
@@ -81,10 +83,11 @@ func autoSummarizeDefault(t *testing.T) string {
|
||||
// up sets the auto_summarize column default to TRUE (ADR-018), down restores
|
||||
// FALSE. The down intentionally does not revert existing rows — only the default.
|
||||
func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
newStore(t) // latest (012 applied)
|
||||
newStore(t) // latest (013 applied)
|
||||
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
|
||||
|
||||
m := fileMigrator(t)
|
||||
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")
|
||||
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
|
||||
@@ -92,6 +95,7 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
require.NoError(t, m.Steps(1), "up 011 re-applies the TRUE default")
|
||||
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")
|
||||
}
|
||||
|
||||
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_errors;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- channel_errors: channels that returned HTTP 404 (deleted/private) on the most
|
||||
-- recent scheduler pass. Surfaced on the account page so users know why some
|
||||
-- subscribed channels produce no videos. Upserted per-pass; cleared when the
|
||||
-- channel starts returning results again (runner calls UpsertChannelError only
|
||||
-- on 404, so a recovered channel simply stops appearing after its row ages out
|
||||
-- or the user takes action). ON DELETE CASCADE keeps rows tidy on account deletion.
|
||||
CREATE TABLE channel_errors (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_errors_user_id ON channel_errors(user_id);
|
||||
|
||||
ALTER TABLE channel_errors ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE channel_errors FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY channel_errors_isolation ON channel_errors
|
||||
FOR ALL
|
||||
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||
Reference in New Issue
Block a user