From c153ff35cee7407f3d9411329293d3880d9d4033 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:58:04 +0200 Subject: [PATCH] feat(store): add read methods for stored summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListSummaries (recent-first, user-scoped, limit) and GetSummaryByVideo LEFT JOIN videos for title/url/published_at, null-safe when no videos row exists. Channel mirrors provider for now — channel_title lives on the not-yet-migrated subscriptions table (data-model.md). New file so it does not collide with Worker F's concurrent edits to store.go. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/store/reads.go | 168 ++++++++++++++++++++++++++ internal/adapters/store/reads_test.go | 148 +++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 internal/adapters/store/reads.go create mode 100644 internal/adapters/store/reads_test.go diff --git a/internal/adapters/store/reads.go b/internal/adapters/store/reads.go new file mode 100644 index 0000000..d488996 --- /dev/null +++ b/internal/adapters/store/reads.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrNotFound is returned by GetSummaryByVideo when no summary exists for the +// (userID, videoID) pair scoped to that user. +var ErrNotFound = errors.New("store: summary not found") + +// SummaryRow is a read-side view of a stored summary, enriched with the video's +// metadata when a matching videos row exists. It is deliberately separate from +// domain.Summary: it carries display fields (Title, Channel, URL, PublishedAt) +// that the engine never sees, sourced via a LEFT JOIN so a summary with no +// videos row still renders (Title/URL empty, PublishedAt zero). +// +// Channel currently mirrors the video provider ("youtube"): the per-channel +// channel_title lives on the subscriptions table (docs/data-model.md), which is +// not part of the Stage-0 store slice yet. When that table is migrated, swap the +// JOIN source — callers already fall back gracefully on an empty Channel. +type SummaryRow struct { + VideoID string + Title string // videos.title; empty when no videos row + Channel string // videos.provider for now; empty when no videos row + URL string // videos.url; empty when no videos row + PublishedAt time.Time // videos.published_at; zero when absent + Summary string + Highlights []string + Takeaways []string + AIProvider string + AIModel string + FallbackUsed bool + CreatedAt time.Time +} + +// selectSummary is the shared projection for both reads. videos is LEFT JOINed +// on its UUID id (= summaries.video_id) and the same user_id, so the join never +// crosses users and a missing videos row yields nulls, not a dropped summary. +const selectSummary = ` + SELECT s.video_id, + COALESCE(v.title, ''), + COALESCE(v.provider, ''), + COALESCE(v.url, ''), + v.published_at, + s.summary, + s.highlights, + s.takeaways, + COALESCE(s.ai_provider, ''), + COALESCE(s.ai_model, ''), + s.fallback_used, + s.created_at + FROM summaries s + LEFT JOIN videos v ON v.id = s.video_id AND v.user_id = s.user_id` + +// ListSummaries returns the user's summaries, most recent first, capped at limit. +// A non-positive limit defaults to 50. Scoped by user_id: one user never sees +// another's summaries (per-user isolation, docs/data-model.md). +func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]SummaryRow, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.pool.Query(ctx, + selectSummary+` + WHERE s.user_id = $1 + ORDER BY s.created_at DESC + LIMIT $2`, + userID, limit) + if err != nil { + return nil, fmt.Errorf("store: list summaries: %w", err) + } + defer rows.Close() + + var out []SummaryRow + for rows.Next() { + row, err := scanSummaryRow(rows) + if err != nil { + return nil, err + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate summaries: %w", err) + } + return out, 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. +func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) { + rows, err := s.pool.Query(ctx, + selectSummary+` + WHERE s.user_id = $1 AND s.video_id = $2`, + userID, videoID) + if err != nil { + return nil, fmt.Errorf("store: get summary: %w", err) + } + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: get summary: %w", err) + } + return nil, ErrNotFound + } + row, err := scanSummaryRow(rows) + if err != nil { + return nil, err + } + return &row, nil +} + +// scanSummaryRow reads one row in the selectSummary column order. published_at is +// nullable (no videos row, or an unset date) so it scans through a pointer. +func scanSummaryRow(rows pgx.Row) (SummaryRow, error) { + var ( + row SummaryRow + highlights []byte + takeaways []byte + publishedAt *time.Time + ) + if err := rows.Scan( + &row.VideoID, + &row.Title, + &row.Channel, + &row.URL, + &publishedAt, + &row.Summary, + &highlights, + &takeaways, + &row.AIProvider, + &row.AIModel, + &row.FallbackUsed, + &row.CreatedAt, + ); err != nil { + return SummaryRow{}, fmt.Errorf("store: scan summary: %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 +} + +// unmarshalList decodes a jsonb array column into a string slice, mirroring +// marshalList in store.go. Empty/NULL bytes decode to a nil slice. +func unmarshalList(b []byte) ([]string, error) { + if len(b) == 0 { + return nil, nil + } + var xs []string + if err := json.Unmarshal(b, &xs); err != nil { + return nil, err + } + return xs, nil +} diff --git a/internal/adapters/store/reads_test.go b/internal/adapters/store/reads_test.go new file mode 100644 index 0000000..550c9f0 --- /dev/null +++ b/internal/adapters/store/reads_test.go @@ -0,0 +1,148 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// seedVideo inserts a videos row whose id matches a summary's video_id, so the +// read-side LEFT JOIN has metadata to attach. published_at may be the zero time +// to exercise the NULL path. +func seedVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title, provider, url string, published time.Time) { + t.Helper() + var pub any + if !published.IsZero() { + pub = published + } + _, err := p.Exec(context.Background(), + `INSERT INTO videos (id, user_id, provider, provider_video_id, title, url, published_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + videoID, userID, provider, "pv-"+videoID[:8], title, url, pub) + require.NoError(t, err) +} + +// setCreatedAt forces a summary's created_at so ordering is deterministic. +func setCreatedAt(t *testing.T, p *pgxpool.Pool, userID, videoID string, at time.Time) { + t.Helper() + _, err := p.Exec(context.Background(), + `UPDATE summaries SET created_at = $3 WHERE user_id = $1 AND video_id = $2`, + userID, videoID, at) + require.NoError(t, err) +} + +func TestListSummariesRecentFirstWithVideoMetadata(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "older"))) + require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "newer"))) + seedVideo(t, p, userA, videoX, "X Title", "youtube", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + seedVideo(t, p, userA, videoY, "Y Title", "youtube", "https://y", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) + // Force ordering: videoY is the most recent. + setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) + + rows, err := s.ListSummaries(ctx, userA, 50) + require.NoError(t, err) + require.Len(t, rows, 2) + + require.Equal(t, videoY, rows[0].VideoID, "most recent created_at first") + require.Equal(t, "Y Title", rows[0].Title) + require.Equal(t, "youtube", rows[0].Channel) + require.Equal(t, "https://y", rows[0].URL) + require.Equal(t, 2026, rows[0].PublishedAt.Year()) + + require.Equal(t, videoX, rows[1].VideoID) + require.Equal(t, "X Title", rows[1].Title) +} + +func TestListSummariesWithoutVideoRowIsNullSafe(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + // Summary exists but no matching videos row (run loop hasn't populated it). + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "orphan"))) + + rows, err := s.ListSummaries(ctx, userA, 50) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Empty(t, rows[0].Title, "no videos row -> empty title (caller falls back to id)") + require.Empty(t, rows[0].Channel) + require.Empty(t, rows[0].URL) + require.True(t, rows[0].PublishedAt.IsZero(), "absent published_at -> zero time") + require.Equal(t, "orphan", rows[0].Summary) +} + +func TestListSummariesRespectsLimit(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "x"))) + require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "y"))) + setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) + + rows, err := s.ListSummaries(ctx, userA, 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, videoY, rows[0].VideoID, "limit keeps the most recent") +} + +func TestListSummariesIsUserScoped(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-only"))) + + rows, err := s.ListSummaries(ctx, userB, 50) + require.NoError(t, err) + require.Empty(t, rows, "user B must not see user A's summaries") +} + +func TestGetSummaryByVideoReturnsFullRow(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "full body"))) + + row, err := s.GetSummaryByVideo(ctx, userA, videoX) + require.NoError(t, err) + require.Equal(t, "full body", row.Summary) + require.Equal(t, []string{"h1", "h2"}, row.Highlights) + require.Equal(t, []string{"t1"}, row.Takeaways) + require.Equal(t, "local", row.AIProvider) + require.Equal(t, "qwen", row.AIModel) +} + +func TestGetSummaryByVideoNotFound(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + _, err := s.GetSummaryByVideo(ctx, userA, videoX) + require.ErrorIs(t, err, store.ErrNotFound) +} + +func TestGetSummaryByVideoIsUserScoped(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-owns"))) + + _, err := s.GetSummaryByVideo(ctx, userB, videoX) + require.ErrorIs(t, err, store.ErrNotFound, "the same video under another user is invisible") +}