diff --git a/documentations/refactor/27-phase-19b-paddock-briefing-rss.md b/documentations/refactor/27-phase-19b-paddock-briefing-rss.md
new file mode 100644
index 0000000..35156d9
--- /dev/null
+++ b/documentations/refactor/27-phase-19b-paddock-briefing-rss.md
@@ -0,0 +1,110 @@
+# Phase 19B Paddock Briefing RSS Backend Spike
+
+## Goal
+
+Prototype the backend foundation for a future fan-facing Paddock Briefing module
+without scraping article pages or touching Race Hub UI. The spike keeps news as a
+local-first cache: feeds can be fetched and normalized by backend code, stored in
+SQLite, and read through a small API shape.
+
+## Source Evaluation
+
+Recommended first-party or publisher-owned feeds:
+
+- FIA official RSS, `https://www.fia.com/rss/news`: keep as the official source
+ for federation announcements and regulatory context.
+- BBC Sport F1, `https://feeds.bbci.co.uk/sport/formula1`: strong free headline
+ source for UK-centered coverage.
+- Autosport F1, `https://www.autosport.com/rss/f1/news/`: useful motorsport
+ specialist feed; retain summaries only when provided by the feed.
+- RaceFans F1, `https://www.racefans.net/category/f1-news/feed/`: useful
+ independent specialist feed with a clean WordPress RSS surface.
+- Guardian Formula One,
+ `https://www.theguardian.com/sport/formulaone/rss`: broad editorial coverage
+ and stable RSS conventions.
+
+Optional sources to evaluate before shipping:
+
+- RACER F1, `https://racer.com/f1/feed`: reasonable supplemental specialist
+ feed.
+- Formula 1 YouTube Atom,
+ `https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg`:
+ video-only briefing cards, separate from article news.
+- Motorsport.com F1, `https://www.motorsport.com/rss/f1/news/`: not included in
+ the default prototype list until terms and caching expectations are reviewed.
+
+Avoid Formula1.com scraping or hidden endpoints, X/Twitter scraping, Reddit as a
+primary news source, and feed aggregator products such as RSS.app or Feedspot.
+
+## Legal And Product Caveats
+
+Only fetch publisher-provided RSS/Atom XML. Do not fetch article bodies, bypass
+paywalls, scrape Open Graph metadata, or store full article content. The product
+surface should show source, title, canonical URL, publish time, category, and a
+short feed-provided summary/snippet when available. Each card should link users
+to the publisher site for the article.
+
+Before enabling a source by default, review the publisher feed terms, robots/TOS
+language around caching, and whether feed summaries are intended for display.
+Keep TTLs conservative and make source attribution visible in the UI.
+
+## Implemented Proof
+
+This spike adds:
+
+- `internal/news`: a standard-library RSS/Atom parser and polite fetch helper
+ with a 10-second default timeout and a box-box User-Agent.
+- URL-based deduplication with UTM parameter stripping.
+- `news_sources` and `news_items` tables in SQLite migration `003_news.sql`.
+- Store/query methods for upserting cached feed metadata/items and listing
+ newest cached items.
+- `GET /api/v1/news`, with optional `limit` and `source` query params.
+- Unit tests using local XML fixtures only.
+
+The endpoint is intentionally read-only against the local SQLite cache. It does
+not fetch feeds during web requests, avoiding unexpected network work in the
+product UI path. A later ingestion command can call `internal/news.Fetch`, upsert
+sources/items, and mark `fetched_at`/`expires_at` according to a TTL policy.
+
+## API Shape
+
+`GET /api/v1/news?limit=25&source=racefans-f1`
+
+Response:
+
+```json
+[
+ {
+ "source": "racefans-f1",
+ "title": "Example headline",
+ "url": "https://publisher.example/story",
+ "published_at": "2026-05-25T14:00:00Z",
+ "summary": "Feed-provided snippet",
+ "category": "news",
+ "fetched_at": "2026-05-25T14:10:00Z"
+ }
+]
+```
+
+Default limit is 25; maximum accepted limit is 100.
+
+## Follow-Up Frontend Plan
+
+Add a Paddock Briefing surface outside Race Hub while Race Hub redesign work is
+active. Recommended first UI slice:
+
+- Query `/api/v1/news?limit=12`.
+- Group by recency with source badges and external-link treatment.
+- Show snippets only when present, with clear publisher attribution.
+- Add source filters after the cache refresh command exists.
+- Treat video feed items as a separate rail or filter, not mixed into hard-news
+ headlines by default.
+
+## Open Questions
+
+- Should news refresh live behind an explicit CLI command, opportunistic startup
+ refresh, or a manual button in an admin/data-health screen?
+- What default TTL should each source use? A 15-30 minute TTL is reasonable for
+ race weekends; longer may be enough outside live sessions.
+- Should the cache keep historical briefing items indefinitely, or prune after a
+ rolling window such as 30-90 days?
diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md
index bc1626a..d700375 100644
--- a/documentations/refactor/README.md
+++ b/documentations/refactor/README.md
@@ -92,6 +92,9 @@ not implementation tickets yet.
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
`/` around race-weekend identity and splits ingestion/admin concerns into
the new `/admin` (Data Health) route.
+- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
+ backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
+ future fan-facing briefing API.
## External References
diff --git a/internal/news/news.go b/internal/news/news.go
new file mode 100644
index 0000000..9646bf2
--- /dev/null
+++ b/internal/news/news.go
@@ -0,0 +1,260 @@
+package news
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+const UserAgent = "box-box/phase-19b-rss-spike"
+
+// Source describes a feed that can be fetched and normalized.
+type Source struct {
+ ID string
+ Name string
+ URL string
+ Category string
+}
+
+// Item is the normalized shape consumed by storage and API layers.
+type Item struct {
+ Source string
+ Title string
+ URL string
+ PublishedAt time.Time
+ Summary string
+ Category string
+ FetchedAt time.Time
+}
+
+// DefaultSources are free RSS/Atom feeds worth using for the Paddock Briefing spike.
+var DefaultSources = []Source{
+ {ID: "fia", Name: "FIA", URL: "https://www.fia.com/rss/news", Category: "official"},
+ {ID: "bbc-f1", Name: "BBC Sport F1", URL: "https://feeds.bbci.co.uk/sport/formula1", Category: "news"},
+ {ID: "autosport-f1", Name: "Autosport F1", URL: "https://www.autosport.com/rss/f1/news/", Category: "news"},
+ {ID: "racefans-f1", Name: "RaceFans F1", URL: "https://www.racefans.net/category/f1-news/feed/", Category: "news"},
+ {ID: "guardian-f1", Name: "Guardian Formula One", URL: "https://www.theguardian.com/sport/formulaone/rss", Category: "news"},
+ {ID: "racer-f1", Name: "RACER F1", URL: "https://racer.com/f1/feed", Category: "news"},
+ {ID: "f1-youtube", Name: "Formula 1 YouTube", URL: "https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg", Category: "video"},
+}
+
+// Fetch retrieves and parses one RSS or Atom feed with the provided HTTP client.
+func Fetch(ctx context.Context, client *http.Client, source Source) ([]Item, error) {
+ if client == nil {
+ client = &http.Client{Timeout: 10 * time.Second}
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", UserAgent)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("fetch %s: status %d", source.ID, resp.StatusCode)
+ }
+ return Parse(source, resp.Body, time.Now().UTC())
+}
+
+// Parse normalizes RSS 2.0 or Atom XML into Items.
+func Parse(source Source, r io.Reader, fetchedAt time.Time) ([]Item, error) {
+ payload, err := io.ReadAll(io.LimitReader(r, 2<<20))
+ if err != nil {
+ return nil, err
+ }
+
+ var rss rssFeed
+ if err := xml.Unmarshal(payload, &rss); err == nil && len(rss.Channel.Items) > 0 {
+ return normalizeRSS(source, rss.Channel.Items, fetchedAt), nil
+ }
+
+ var atom atomFeed
+ if err := xml.Unmarshal(payload, &atom); err != nil {
+ return nil, err
+ }
+ if len(atom.Entries) == 0 {
+ return nil, fmt.Errorf("parse %s: no RSS items or Atom entries found", source.ID)
+ }
+ return normalizeAtom(source, atom.Entries, fetchedAt), nil
+}
+
+// DeduplicateByURL keeps the newest instance of each canonical URL.
+func DeduplicateByURL(items []Item) []Item {
+ byURL := make(map[string]Item, len(items))
+ for _, item := range items {
+ key := canonicalURL(item.URL)
+ if key == "" {
+ continue
+ }
+ item.URL = key
+ if existing, ok := byURL[key]; !ok || item.PublishedAt.After(existing.PublishedAt) {
+ byURL[key] = item
+ }
+ }
+
+ out := make([]Item, 0, len(byURL))
+ for _, item := range byURL {
+ out = append(out, item)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].PublishedAt.After(out[j].PublishedAt)
+ })
+ return out
+}
+
+type rssFeed struct {
+ Channel struct {
+ Items []rssItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ GUID string `xml:"guid"`
+ PubDate string `xml:"pubDate"`
+ Description string `xml:"description"`
+ Categories []string `xml:"category"`
+}
+
+type atomFeed struct {
+ Entries []atomEntry `xml:"entry"`
+}
+
+type atomEntry struct {
+ Title string `xml:"title"`
+ ID string `xml:"id"`
+ Updated string `xml:"updated"`
+ Published string `xml:"published"`
+ Summary string `xml:"summary"`
+ Content string `xml:"content"`
+ Links []atomLink `xml:"link"`
+ Categories []struct {
+ Term string `xml:"term,attr"`
+ Label string `xml:"label,attr"`
+ } `xml:"category"`
+}
+
+type atomLink struct {
+ Href string `xml:"href,attr"`
+ Rel string `xml:"rel,attr"`
+}
+
+func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
+ items := make([]Item, 0, len(raw))
+ for _, entry := range raw {
+ link := strings.TrimSpace(entry.Link)
+ if link == "" {
+ link = strings.TrimSpace(entry.GUID)
+ }
+ items = append(items, Item{
+ Source: source.ID,
+ Title: cleanText(entry.Title),
+ URL: link,
+ PublishedAt: parseFeedTime(entry.PubDate),
+ Summary: cleanText(entry.Description),
+ Category: firstNonEmpty(entry.Categories, source.Category),
+ FetchedAt: fetchedAt,
+ })
+ }
+ return DeduplicateByURL(items)
+}
+
+func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
+ items := make([]Item, 0, len(raw))
+ for _, entry := range raw {
+ category := source.Category
+ if len(entry.Categories) > 0 {
+ category = firstNonEmpty([]string{entry.Categories[0].Label, entry.Categories[0].Term}, source.Category)
+ }
+ items = append(items, Item{
+ Source: source.ID,
+ Title: cleanText(entry.Title),
+ URL: atomEntryURL(entry),
+ PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
+ Summary: cleanText(firstNonEmpty([]string{entry.Summary, entry.Content}, "")),
+ Category: category,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return DeduplicateByURL(items)
+}
+
+func atomEntryURL(entry atomEntry) string {
+ for _, link := range entry.Links {
+ if link.Rel == "" || link.Rel == "alternate" {
+ return strings.TrimSpace(link.Href)
+ }
+ }
+ if len(entry.Links) > 0 {
+ return strings.TrimSpace(entry.Links[0].Href)
+ }
+ return strings.TrimSpace(entry.ID)
+}
+
+func parseFeedTime(value string) time.Time {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return time.Time{}
+ }
+ layouts := []string{
+ time.RFC1123Z,
+ time.RFC1123,
+ time.RFC3339,
+ time.RFC3339Nano,
+ "Mon, 02 Jan 2006 15:04:05 -0700",
+ "Mon, 2 Jan 2006 15:04:05 -0700",
+ }
+ for _, layout := range layouts {
+ if ts, err := time.Parse(layout, value); err == nil {
+ return ts.UTC()
+ }
+ }
+ return time.Time{}
+}
+
+func cleanText(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.ReplaceAll(value, "\n", " ")
+ value = strings.ReplaceAll(value, "\t", " ")
+ return strings.Join(strings.Fields(value), " ")
+}
+
+func firstNonEmpty(values []string, fallback string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return fallback
+}
+
+func canonicalURL(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return raw
+ }
+ parsed.Fragment = ""
+ q := parsed.Query()
+ for key := range q {
+ if strings.HasPrefix(strings.ToLower(key), "utm_") {
+ q.Del(key)
+ }
+ }
+ parsed.RawQuery = q.Encode()
+ return parsed.String()
+}
diff --git a/internal/news/news_test.go b/internal/news/news_test.go
new file mode 100644
index 0000000..d9be6b6
--- /dev/null
+++ b/internal/news/news_test.go
@@ -0,0 +1,72 @@
+package news
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseRSSDeduplicatesByURL(t *testing.T) {
+ fixture := `
+
+
+ -
+ First story
+ https://example.com/f1/story?utm_source=rss
+ Mon, 25 May 2026 10:00:00 GMT
+ Latest from the paddock
+ Formula 1
+
+ -
+ Duplicate story newer
+ https://example.com/f1/story
+ Mon, 25 May 2026 11:00:00 GMT
+ Updated headline
+
+
+`
+ source := Source{ID: "example", Category: "news"}
+ items, err := Parse(source, strings.NewReader(fixture), time.Unix(100, 0).UTC())
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("items len = %d, want 1", len(items))
+ }
+ if items[0].Title != "Duplicate story newer" {
+ t.Fatalf("title = %q, want newer duplicate", items[0].Title)
+ }
+ if items[0].URL != "https://example.com/f1/story" {
+ t.Fatalf("url = %q, want canonical URL", items[0].URL)
+ }
+ if items[0].PublishedAt.IsZero() {
+ t.Fatal("PublishedAt was not parsed")
+ }
+}
+
+func TestParseAtom(t *testing.T) {
+ fixture := `
+
+
+ Formula 1 video
+
+ 2026-05-25T12:30:00Z
+ Highlights from the weekend
+
+
+`
+ source := Source{ID: "f1-youtube", Category: "video"}
+ items, err := Parse(source, strings.NewReader(fixture), time.Unix(200, 0).UTC())
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("items len = %d, want 1", len(items))
+ }
+ if items[0].Source != "f1-youtube" || items[0].Category != "video" {
+ t.Fatalf("item = %+v, want source/category preserved", items[0])
+ }
+ if items[0].URL != "https://www.youtube.com/watch?v=abc123" {
+ t.Fatalf("url = %q, want alternate link", items[0].URL)
+ }
+}
diff --git a/internal/query/news.go b/internal/query/news.go
new file mode 100644
index 0000000..4e53cd5
--- /dev/null
+++ b/internal/query/news.go
@@ -0,0 +1,51 @@
+package query
+
+import (
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/store"
+)
+
+// NewsItem is the frontend-facing cached briefing shape.
+type NewsItem struct {
+ Source string `json:"source"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ PublishedAt *time.Time `json:"published_at,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Category string `json:"category,omitempty"`
+ FetchedAt time.Time `json:"fetched_at"`
+}
+
+// ListNews returns cached briefing items.
+func (s *Service) ListNews(limit int, source string) ([]NewsItem, error) {
+ rows, err := s.store.ListNewsItems(limit, source)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]NewsItem, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, NewsItem{
+ Source: row.Source,
+ Title: row.Title,
+ URL: row.URL,
+ PublishedAt: row.PublishedAt,
+ Summary: row.Summary,
+ Category: row.Category,
+ FetchedAt: row.FetchedAt,
+ })
+ }
+ return out, nil
+}
+
+func NewsItemToStore(item NewsItem) store.NewsItem {
+ return store.NewsItem{
+ URL: item.URL,
+ Source: item.Source,
+ Title: item.Title,
+ PublishedAt: item.PublishedAt,
+ Summary: item.Summary,
+ Category: item.Category,
+ FetchedAt: item.FetchedAt,
+ }
+}
diff --git a/internal/store/migrations/003_news.sql b/internal/store/migrations/003_news.sql
new file mode 100644
index 0000000..65be7b7
--- /dev/null
+++ b/internal/store/migrations/003_news.sql
@@ -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);
diff --git a/internal/store/models.go b/internal/store/models.go
index 30f1619..d1c602b 100644
--- a/internal/store/models.go
+++ b/internal/store/models.go
@@ -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
diff --git a/internal/store/news.go b/internal/store/news.go
new file mode 100644
index 0000000..3ad5940
--- /dev/null
+++ b/internal/store/news.go
@@ -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
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 2b5d537..4626185 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -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)
diff --git a/internal/web/api.go b/internal/web/api.go
index 8b6d09c..51c1426 100644
--- a/internal/web/api.go
+++ b/internal/web/api.go
@@ -115,6 +115,24 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, sessions)
}
+// --- /api/v1/news ---
+
+func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
+ if !s.hasLocalQuery() {
+ writeJSON(w, []query.NewsItem{})
+ return
+ }
+
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ source := strings.TrimSpace(r.URL.Query().Get("source"))
+ items, err := s.query.ListNews(limit, source)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, false)
+ return
+ }
+ writeJSON(w, items)
+}
+
// --- /api/v1/drivers ---
func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go
index b3d3eb6..f8e74cb 100644
--- a/internal/web/racehub_test.go
+++ b/internal/web/racehub_test.go
@@ -192,6 +192,52 @@ func TestHandleSeasonsWithData(t *testing.T) {
}
}
+func TestHandleNewsWithLocalData(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Unix(1800000000, 0).UTC()
+ published := now.Add(-30 * time.Minute)
+ if err := st.UpsertNewsSource(store.NewsSource{
+ Source: "racefans-f1",
+ Name: "RaceFans F1",
+ FeedURL: "https://www.racefans.net/category/f1-news/feed/",
+ Category: "news",
+ Enabled: true,
+ UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("UpsertNewsSource() error = %v", err)
+ }
+ if err := st.UpsertNewsItem(store.NewsItem{
+ URL: "https://example.com/story",
+ Source: "racefans-f1",
+ Title: "RaceFans story",
+ PublishedAt: &published,
+ Summary: "Brief summary",
+ Category: "news",
+ FetchedAt: now,
+ }); err != nil {
+ t.Fatalf("UpsertNewsItem() error = %v", err)
+ }
+
+ srv := testServer(t, st)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/news?source=racefans-f1&limit=5", nil)
+ rec := httptest.NewRecorder()
+ srv.handleNews(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ var items []query.NewsItem
+ if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("items len = %d, want 1", len(items))
+ }
+ if items[0].Source != "racefans-f1" || items[0].Title != "RaceFans story" {
+ t.Fatalf("items[0] = %+v, want seeded item", items[0])
+ }
+}
+
func TestHandleWeekendNotFound(t *testing.T) {
st := openTestStore(t)
srv := testServer(t, st)
diff --git a/internal/web/server.go b/internal/web/server.go
index 994fff3..0cdcb41 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -64,6 +64,7 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
+ mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)