Files
tapir/internal/adapters/store/reads.go
T
mathias 897a21a1d6 feat(store): expose provider_video_id on SummaryRow
Read-only addition (column + field + scan) so the web layer can build a
video embed URL. No write-path or restructuring.
2026-06-03 15:10:29 +02:00

180 lines
5.4 KiB
Go

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
}