mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add paddock briefing RSS backend spike
This commit is contained in:
24
internal/store/migrations/003_news.sql
Normal file
24
internal/store/migrations/003_news.sql
Normal 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);
|
||||
@@ -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
134
internal/store/news.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user