Add paddock briefing RSS backend spike

This commit is contained in:
2026-05-25 12:02:18 -04:00
parent 79b0b9f469
commit 84a8827244
12 changed files with 804 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS news_sources (
source TEXT PRIMARY KEY,
name TEXT NOT NULL,
feed_url TEXT NOT NULL,
category TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
fetched_at INTEGER,
expires_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS news_items (
url TEXT PRIMARY KEY,
source TEXT NOT NULL,
title TEXT NOT NULL,
published_at INTEGER,
summary TEXT,
category TEXT,
fetched_at INTEGER NOT NULL,
FOREIGN KEY (source) REFERENCES news_sources(source)
);
CREATE INDEX IF NOT EXISTS idx_news_items_published ON news_items (published_at DESC);
CREATE INDEX IF NOT EXISTS idx_news_items_source_published ON news_items (source, published_at DESC);

View File

@@ -28,6 +28,29 @@ type IngestionRun struct {
SummaryJSON string
}
// NewsSource stores RSS/Atom feed metadata for local-first briefing reads.
type NewsSource struct {
Source string
Name string
FeedURL string
Category string
Enabled bool
FetchedAt *time.Time
ExpiresAt *time.Time
UpdatedAt time.Time
}
// NewsItem stores a normalized feed item deduplicated by URL.
type NewsItem struct {
URL string
Source string
Title string
PublishedAt *time.Time
Summary string
Category string
FetchedAt time.Time
}
// Meeting is a race weekend record.
type Meeting struct {
MeetingKey int

134
internal/store/news.go Normal file
View File

@@ -0,0 +1,134 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertNewsSource inserts or updates RSS/Atom feed metadata.
func (s *Store) UpsertNewsSource(src NewsSource) error {
updatedAt := src.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
_, err := s.db.Exec(`
INSERT INTO news_sources (
source, name, feed_url, category, enabled, fetched_at, expires_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source) DO UPDATE SET
name = excluded.name,
feed_url = excluded.feed_url,
category = excluded.category,
enabled = excluded.enabled,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
`,
src.Source,
src.Name,
src.FeedURL,
nullString(src.Category),
boolInt(src.Enabled),
nullableTime(src.FetchedAt),
nullableTime(src.ExpiresAt),
updatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert news source: %w", err)
}
return nil
}
// UpsertNewsItem inserts or updates a normalized feed item.
func (s *Store) UpsertNewsItem(item NewsItem) error {
_, err := s.db.Exec(`
INSERT INTO news_items (
url, source, title, published_at, summary, category, fetched_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
source = excluded.source,
title = excluded.title,
published_at = excluded.published_at,
summary = excluded.summary,
category = excluded.category,
fetched_at = excluded.fetched_at
`,
item.URL,
item.Source,
item.Title,
nullableTime(item.PublishedAt),
nullString(item.Summary),
nullString(item.Category),
item.FetchedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert news item: %w", err)
}
return nil
}
// ListNewsItems returns newest cached news items, optionally filtered by source.
func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
if limit <= 0 || limit > 100 {
limit = 25
}
query := `
SELECT url, source, title, published_at, summary, category, fetched_at
FROM news_items
`
var args []any
if source != "" {
query += ` WHERE source = ?`
args = append(args, source)
}
query += ` ORDER BY COALESCE(published_at, fetched_at) DESC, fetched_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NewsItem
for rows.Next() {
var item NewsItem
var published sql.NullInt64
var summary, category sql.NullString
var fetched int64
if err := rows.Scan(
&item.URL,
&item.Source,
&item.Title,
&published,
&summary,
&category,
&fetched,
); err != nil {
return nil, err
}
item.PublishedAt = nullTimePtr(published)
item.Summary = summary.String
item.Category = category.String
item.FetchedAt = time.Unix(fetched, 0).UTC()
out = append(out, item)
}
return out, rows.Err()
}
func nullableTime(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return v.Unix()
}
func nullTimePtr(v sql.NullInt64) *time.Time {
if !v.Valid {
return nil
}
t := time.Unix(v.Int64, 0).UTC()
return &t
}

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil {
t.Fatalf("SchemaVersion() error = %v", err)
}
if version != 2 {
t.Fatalf("SchemaVersion() = %d, want 2", version)
if version != 3 {
t.Fatalf("SchemaVersion() = %d, want 3", version)
}
tables := []string{
@@ -48,6 +48,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
"race_control",
"weather",
"laps",
"news_sources",
"news_items",
}
for _, table := range tables {
var name string
@@ -84,6 +86,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
if count != 1 {
t.Fatalf("schema_migrations v2 count = %d, want 1", count)
}
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations v3: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations v3 count = %d, want 1", count)
}
}
func TestRawPayloadInsertAndRead(t *testing.T) {
@@ -597,6 +605,58 @@ func TestAnalyticsUpsertRead(t *testing.T) {
}
}
func TestNewsUpsertRead(t *testing.T) {
s := openTestStore(t)
now := time.Unix(1800000000, 0).UTC()
expires := now.Add(30 * time.Minute)
if err := s.UpsertNewsSource(NewsSource{
Source: "bbc-f1",
Name: "BBC Sport F1",
FeedURL: "https://feeds.bbci.co.uk/sport/formula1",
Category: "news",
Enabled: true,
FetchedAt: &now,
ExpiresAt: &expires,
UpdatedAt: now,
}); err != nil {
t.Fatalf("UpsertNewsSource() error = %v", err)
}
published := now.Add(-time.Hour)
item := NewsItem{
URL: "https://example.com/f1/story",
Source: "bbc-f1",
Title: "Paddock update",
PublishedAt: &published,
Summary: "Short briefing text",
Category: "news",
FetchedAt: now,
}
if err := s.UpsertNewsItem(item); err != nil {
t.Fatalf("UpsertNewsItem() error = %v", err)
}
updated := item
updated.Title = "Paddock update revised"
if err := s.UpsertNewsItem(updated); err != nil {
t.Fatalf("second UpsertNewsItem() error = %v", err)
}
items, err := s.ListNewsItems(10, "bbc-f1")
if err != nil {
t.Fatalf("ListNewsItems() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
if items[0].Title != updated.Title {
t.Fatalf("title = %q, want %q", items[0].Title, updated.Title)
}
if items[0].PublishedAt == nil || !items[0].PublishedAt.Equal(published) {
t.Fatalf("published_at = %v, want %v", items[0].PublishedAt, published)
}
}
func TestWithTxRollback(t *testing.T) {
s := openTestStore(t)