feat(store): add read methods for stored summaries

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 20:58:04 +02:00
co-authored by Claude Opus 4.8
parent 2695b5d91e
commit c153ff35ce
2 changed files with 316 additions and 0 deletions
+168
View File
@@ -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
}
+148
View File
@@ -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")
}