← back to Norma
app/api/news/live/route.ts
297 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NewsRow {
id: string;
headline: string;
outlet: string | null;
url: string | null;
published_at: string | null;
summary: string | null;
tags: string[] | null;
relevance_score: number | null;
is_used: boolean;
session_id: string | null;
source_type: string;
raw_data: Record<string, unknown> | null;
created_at: string;
updated_at: string;
author_name: string | null;
author_linkedin: string | null;
}
interface RssArticle {
title: string;
link: string;
pubDate: string;
source: string;
}
interface LiveNewsResponse {
articles: NewsRow[];
freshCount: number;
scrapedCount: number;
lastScraped: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const RSS_FEEDS = [
'https://news.google.com/rss/search?q=student+loan+debt+forgiveness&hl=en-US&gl=US&ceid=US:en',
'https://news.google.com/rss/search?q=SAVE+plan+student+loan+repayment&hl=en-US&gl=US&ceid=US:en',
'https://news.google.com/rss/search?q=CFPB+student+loan+servicer&hl=en-US&gl=US&ceid=US:en',
];
const FRESHNESS_INTERVAL = '2 hours';
const FRESH_THRESHOLD = 10;
const FETCH_TIMEOUT_MS = 10_000;
const MAX_ARTICLES = 50;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a tags array based on keyword matches in the headline.
* Always includes 'student-debt' as the base tag.
*/
function buildTags(title: string): string[] {
const tags: string[] = ['student-debt'];
const lower = title.toLowerCase();
if (lower.includes('forgiv')) tags.push('forgiveness');
if (lower.includes('save')) tags.push('SAVE-plan');
if (lower.includes('repay')) tags.push('repayment');
if (lower.includes('cfpb') || lower.includes('consumer')) tags.push('CFPB');
if (lower.includes('servicer') || lower.includes('mohela') || lower.includes('navient')) tags.push('servicer');
if (lower.includes('default') || lower.includes('collection')) tags.push('collections');
if (lower.includes('biden') || lower.includes('trump')) tags.push('policy');
return tags;
}
/**
* Parse RSS XML and extract article items using regex.
* Returns an array of RssArticle objects.
*/
function parseRssXml(xml: string): RssArticle[] {
const articles: RssArticle[] = [];
const items = xml.match(/<item>([\s\S]*?)<\/item>/g) || [];
for (const item of items) {
const title =
item
.match(/<title>(.*?)<\/title>/)?.[1]
?.replace(/<!\[CDATA\[|\]\]>/g, '')
.trim() || '';
const link = item.match(/<link\/?>(\s*)([\s\S]*?)(?=<)/)?.[2]?.trim() ||
item.match(/<link>(.*?)<\/link>/)?.[1]?.trim() || '';
const pubDate = item.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() || '';
const source =
item
.match(/<source.*?>(.*?)<\/source>/)?.[1]
?.replace(/<!\[CDATA\[|\]\]>/g, '')
.trim() || '';
if (title && link) {
articles.push({ title, link, pubDate, source });
}
}
return articles;
}
/**
* Fetch a single RSS feed with timeout. Returns parsed articles or empty array on error.
*/
async function fetchRssFeed(url: string): Promise<RssArticle[]> {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
'User-Agent': 'Norma-NewsBot/1.0',
Accept: 'application/rss+xml, application/xml, text/xml',
},
});
if (!response.ok) {
console.error(`[api/news/live] RSS fetch failed (${response.status}): ${url}`);
return [];
}
const xml = await response.text();
return parseRssXml(xml);
} catch (err) {
console.error(`[api/news/live] RSS fetch error for ${url}:`, (err as Error).message);
return [];
}
}
/**
* Check whether a headline already exists in news_items (case-insensitive).
*/
async function headlineExists(headline: string, orgId: string | null): Promise<boolean> {
if (orgId) {
const result = await query(
'SELECT 1 FROM news_items WHERE LOWER(headline) = LOWER($1) AND org_id = $2 LIMIT 1',
[headline, orgId],
);
return result.rows.length > 0;
}
const result = await query(
'SELECT 1 FROM news_items WHERE LOWER(headline) = LOWER($1) LIMIT 1',
[headline],
);
return result.rows.length > 0;
}
/**
* Insert a single scraped article into the database.
* Returns the inserted row, or null if it was a duplicate / insert failed.
*/
async function insertArticle(article: RssArticle, orgId: string | null): Promise<NewsRow | null> {
const tags = buildTags(article.title);
// Parse published date; fall back to now() if unparseable
let publishedAt: Date | null = null;
if (article.pubDate) {
const parsed = new Date(article.pubDate);
if (!isNaN(parsed.getTime())) {
publishedAt = parsed;
}
}
try {
const result = await query<NewsRow>(
`INSERT INTO news_items (headline, outlet, url, published_at, summary, tags, relevance_score, source_type, org_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'live_scrape', $8)
ON CONFLICT DO NOTHING
RETURNING *`,
[
article.title,
article.source || null,
article.link || null,
publishedAt ? publishedAt.toISOString() : null,
null, // summary — not available from RSS
tags,
null, // relevance_score
orgId,
],
);
return result.rows[0] ?? null;
} catch (err) {
console.error('[api/news/live] Insert error:', (err as Error).message);
return null;
}
}
// ---------------------------------------------------------------------------
// GET /api/news/live
// ---------------------------------------------------------------------------
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
// 1. Check for fresh articles in the database
const orgFilter = orgId ? ' AND org_id = $1' : '';
const orgParams = orgId ? [orgId] : [];
const freshResult = await query<NewsRow>(
`SELECT * FROM news_items
WHERE created_at > NOW() - INTERVAL '${FRESHNESS_INTERVAL}'${orgFilter}
ORDER BY published_at DESC NULLS LAST
LIMIT ${MAX_ARTICLES}`,
orgParams,
);
const freshCount = freshResult.rows.length;
let scrapedCount = 0;
const lastScrapedTime = new Date().toISOString();
// 2. If we have fewer than the threshold, scrape new articles
if (freshCount < FRESH_THRESHOLD) {
// Fetch all RSS feeds in parallel; each feed handles its own errors
const feedResults = await Promise.all(RSS_FEEDS.map(fetchRssFeed));
const allArticles: RssArticle[] = feedResults.flat();
// Deduplicate within the scraped batch by title (case-insensitive)
const seenTitles = new Set<string>();
const uniqueArticles: RssArticle[] = [];
for (const article of allArticles) {
const key = article.title.toLowerCase();
if (!seenTitles.has(key)) {
seenTitles.add(key);
uniqueArticles.push(article);
}
}
// Insert each article, skipping duplicates against the DB
for (const article of uniqueArticles) {
const exists = await headlineExists(article.title, orgId);
if (exists) continue;
const inserted = await insertArticle(article, orgId);
if (inserted) {
scrapedCount++;
}
}
}
// 3. Return all articles (fresh + newly scraped), sorted by published_at DESC
const finalOrgFilter = orgId ? ' WHERE org_id = $1' : '';
const finalResult = await query<NewsRow>(
`SELECT * FROM news_items${finalOrgFilter}
ORDER BY published_at DESC NULLS LAST
LIMIT ${MAX_ARTICLES}`,
orgParams,
);
const response: LiveNewsResponse = {
articles: finalResult.rows,
freshCount,
scrapedCount,
lastScraped: lastScrapedTime,
};
return NextResponse.json(response);
} catch (err) {
console.error('[api/news/live] GET error:', (err as Error).message);
// Even on error, attempt to return whatever is in the DB
try {
const fallbackOrgFilter = orgId ? ' WHERE org_id = $1' : '';
const fallbackParams = orgId ? [orgId] : [];
const fallbackResult = await query<NewsRow>(
`SELECT * FROM news_items${fallbackOrgFilter}
ORDER BY published_at DESC NULLS LAST
LIMIT ${MAX_ARTICLES}`,
fallbackParams,
);
return NextResponse.json({
articles: fallbackResult.rows,
freshCount: fallbackResult.rows.length,
scrapedCount: 0,
lastScraped: new Date().toISOString(),
error: 'Partial failure — returning cached articles',
});
} catch (dbErr) {
console.error('[api/news/live] Fallback DB query also failed:', (dbErr as Error).message);
return NextResponse.json({ error: 'Failed to fetch live news' }, { status: 500 });
}
}
}