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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user