feat(store): summarization-mode methods + all-videos read
Add the store surface for summarization mode: - SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent user reads as manual (the safe default). - RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound when the video is absent or not owned (RLS hides another user's row). - RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the queued set per pass (mirrors SeenVideoIDs), clear after summarizing. - ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so discovered-but-unsummarized videos appear with empty summary fields. SummaryRow gains additive Summarized + SummarizeRequested fields; the summary-only reads are untouched. RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it touches zero rows (store-level scoping can't prove this — the test pool is a superuser that bypasses RLS, same caveat documented there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,16 @@ type SummaryRow struct {
|
||||
FallbackUsed bool
|
||||
CreatedAt time.Time
|
||||
Actions []string // current active actions for this video; nil when none
|
||||
|
||||
// Summarized reports whether a summary exists for this video. The summary-only
|
||||
// reads (ListSummaries/GetSummaryByVideo) always yield true; the all-videos
|
||||
// read (ListVideos) yields false for a discovered-but-unsummarized video, whose
|
||||
// Summary/Highlights/AIProvider fields are then empty.
|
||||
Summarized bool
|
||||
// SummarizeRequested reflects videos.summarize_requested: the manual queue flag
|
||||
// set by the web "Summarize" button and cleared by the next `tapir run`. Only
|
||||
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
|
||||
SummarizeRequested bool
|
||||
}
|
||||
|
||||
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
||||
@@ -101,6 +111,156 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// selectVideo is the all-videos projection: it drives from the videos table and
|
||||
// LEFT JOINs the (at most one) summary, so a discovered-but-unsummarized video
|
||||
// still appears with empty summary fields. The column order mirrors selectSummary
|
||||
// for the shared fields, then appends summarized + summarize_requested. created_at
|
||||
// falls back to the video's seen_at when there is no summary, so the read-side row
|
||||
// always carries a sortable timestamp.
|
||||
const selectVideo = `
|
||||
SELECT v.id,
|
||||
v.provider_video_id,
|
||||
COALESCE(v.title, ''),
|
||||
v.provider,
|
||||
COALESCE(v.url, ''),
|
||||
v.published_at,
|
||||
COALESCE(s.summary, ''),
|
||||
s.highlights,
|
||||
s.takeaways,
|
||||
COALESCE(s.ai_provider, ''),
|
||||
COALESCE(s.ai_model, ''),
|
||||
COALESCE(s.fallback_used, FALSE),
|
||||
COALESCE(s.created_at, v.seen_at),
|
||||
(s.id IS NOT NULL) AS summarized,
|
||||
v.summarize_requested
|
||||
FROM videos v
|
||||
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||
|
||||
// ListVideos returns ALL of the user's videos — summarized and not — most recent
|
||||
// first by seen_at, capped at limit (non-positive defaults to 50). Unsummarized
|
||||
// videos come back with Summarized=false and empty summary fields, so the list
|
||||
// view can render them with a "Summarize" affordance. Scoped by user_id.
|
||||
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var out []SummaryRow
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx,
|
||||
selectVideo+`
|
||||
WHERE v.user_id = $1
|
||||
ORDER BY v.seen_at DESC
|
||||
LIMIT $2`,
|
||||
userID, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: list videos: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
row, err := scanVideoRow(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("store: iterate videos: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.attachActions(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVideoRow returns a single video row (summarized or not) for (userID,
|
||||
// videoID), used to re-render one card after queuing it. Returns ErrNotFound when
|
||||
// the user has no such video. Scoped by user_id.
|
||||
func (s *Store) GetVideoRow(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
||||
var (
|
||||
row SummaryRow
|
||||
found bool
|
||||
)
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx,
|
||||
selectVideo+`
|
||||
WHERE v.user_id = $1 AND v.id = $2`,
|
||||
userID, videoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: get video: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("store: get video: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
row, err = scanVideoRow(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
holder := []SummaryRow{row}
|
||||
if err := s.attachActions(ctx, userID, holder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &holder[0], nil
|
||||
}
|
||||
|
||||
// scanVideoRow reads one row in the selectVideo column order. published_at is
|
||||
// nullable so it scans through a pointer.
|
||||
func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
|
||||
var (
|
||||
row SummaryRow
|
||||
highlights []byte
|
||||
takeaways []byte
|
||||
publishedAt *time.Time
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&row.VideoID,
|
||||
&row.ProviderVideoID,
|
||||
&row.Title,
|
||||
&row.Channel,
|
||||
&row.URL,
|
||||
&publishedAt,
|
||||
&row.Summary,
|
||||
&highlights,
|
||||
&takeaways,
|
||||
&row.AIProvider,
|
||||
&row.AIModel,
|
||||
&row.FallbackUsed,
|
||||
&row.CreatedAt,
|
||||
&row.Summarized,
|
||||
&row.SummarizeRequested,
|
||||
); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
|
||||
}
|
||||
if publishedAt != nil {
|
||||
row.PublishedAt = *publishedAt
|
||||
}
|
||||
var err error
|
||||
if row.Highlights, err = unmarshalList(highlights); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: unmarshal highlights: %w", err)
|
||||
}
|
||||
if row.Takeaways, err = unmarshalList(takeaways); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: unmarshal takeaways: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
||||
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
||||
// summary. Scoped by user_id.
|
||||
|
||||
Reference in New Issue
Block a user