import type { FastifyInstance, FastifyRequest } from 'fastify'; import { NewsRepository } from './NewsRepository'; import { YahooFinanceClient } from '../shared'; import type { NewsArticleRow } from '../shared/types'; interface StoryView { headline: string; tickers: string[]; source: string; catalyst: string | null; url: string; publishedAt: string; } /** * Read side of the news pipeline. Stored pipeline stories (curated, catalyst- * tagged, historical) are merged with a live per-ticker Yahoo search on * request — stored gives depth, live gives freshness. The RSS firehoses * can't be queried per-ticker on demand, which is why they go through the * polling pipeline instead. */ export class NewsController { constructor( private readonly repo: NewsRepository, private readonly yahoo?: YahooFinanceClient, ) {} register(app: FastifyInstance): void { app.get('/api/news/recent', this.recent.bind(this)); app.get('/api/news/:ticker', this.byTicker.bind(this)); } /** GET /api/news/:ticker?days=7&live=1 (live Yahoo merge on by default) */ private async byTicker(req: FastifyRequest) { const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); const query = req.query as { days?: string; live?: string }; const days = Math.min(Number(query.days ?? 7) || 7, 90); const live = query.live !== '0'; const sinceDay = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const stored = this.repo.newsForTicker(ticker, sinceDay).map(NewsController.serialize); const fresh = live ? await this.fetchLive(ticker) : []; // Merge, dedupe by URL, newest first const byUrl = new Map(); for (const s of [...stored, ...fresh]) byUrl.set(s.url, byUrl.get(s.url) ?? s); const stories = [...byUrl.values()].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); return { ticker, days, stories }; } /** Live per-ticker Yahoo news search — freshness layer, best-effort. */ private async fetchLive(ticker: string): Promise { if (!this.yahoo) return []; try { const items = await this.yahoo.search(ticker, { newsCount: 8 }); return items .filter((n) => n.title && n.link) .map((n) => ({ headline: n.title as string, tickers: [ticker], source: 'yahoo', catalyst: null, url: n.link as string, publishedAt: n.providerPublishTime ? new Date(n.providerPublishTime).toISOString() : new Date().toISOString(), })); } catch { return []; } } /** GET /api/news/recent?limit=50 */ private async recent(req: FastifyRequest) { const limit = Math.min(Number((req.query as { limit?: string }).limit ?? 50) || 50, 200); return { stories: this.repo.recent(limit).map(NewsController.serialize) }; } private static serialize(row: NewsArticleRow) { return { headline: row.headline, tickers: JSON.parse(row.ticker_list) as string[], source: row.source, catalyst: row.catalyst, url: row.url, publishedAt: row.published_at, }; } }