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 ProviderVideoID string // videos.provider_video_id; empty when no videos row 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 Actions []string // current active actions for this video; nil when none } // 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.provider_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) } if err := s.attachActions(ctx, userID, out); err != nil { return nil, 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 } holder := []SummaryRow{row} if err := s.attachActions(ctx, userID, holder); err != nil { return nil, err } return &holder[0], 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.ProviderVideoID, &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 }