← back to Grant
app/api/news/discover/route.ts
107 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
import { auditLog } from '@/lib/audit';
import { fetchFederalRegisterNews } from '@/lib/federalRegister';
/*
* POST /api/news/discover
*
* 2026-05-30: replaced the AI-fabricated news path (which asked Gemini to
* invent "realistic" headlines, "plausible" URLs, and "plausible" journalist
* names) with REAL data pulled live from the free, open Federal Register API
* (https://www.federalregister.gov/api/v1/documents.json — no API key).
*
* Every inserted row is now a real, published federal document with a
* verifiable html_url on federalregister.gov. The org's AI keywords drive a
* relevance term-search so results stay on-mission. The response contract
* ({ discovered, news }) and the news_items row shape are unchanged, so the
* NewsTab frontend keeps working without modification.
*/
export async function POST(request: NextRequest) {
const session = verifyAuthWithOrg(request);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = session.username;
try {
const orgId = await resolveOrgId(session);
if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
const orgRes = await query(
'SELECT id, name, ai_focus_areas, ai_keywords FROM organizations WHERE id = $1',
[orgId],
);
if (orgRes.rows.length === 0) {
return NextResponse.json({ error: 'No organization found' }, { status: 400 });
}
const org = orgRes.rows[0];
// Build the keyword set the FR term-search runs on: org keywords first,
// then focus areas as a fallback for relevance.
const keywords: string[] = [
...(Array.isArray(org.ai_keywords) ? org.ai_keywords : []),
...(Array.isArray(org.ai_focus_areas) ? org.ai_focus_areas : []),
].filter((k) => typeof k === 'string' && k.trim());
const fr = await fetchFederalRegisterNews({ keywords, perPage: 25 });
if (!fr.ok) {
console.error('[news/discover] Federal Register fetch failed:', fr.reason, fr.detail);
return NextResponse.json(
{ error: `Federal Register source unavailable (${fr.reason})` },
{ status: fr.reason === 'http' ? 502 : 503 },
);
}
// Dedupe against headlines already tracked for this org.
const existingRes = await query(
'SELECT headline FROM news_items WHERE org_id = $1',
[org.id],
);
const existing = new Set(existingRes.rows.map((r) => (r.headline || '').trim()));
const inserted = [];
for (const n of fr.items) {
if (existing.has(n.headline.trim())) continue;
try {
const result = await query(
`INSERT INTO news_items (org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type, created_at, updated_at`,
[
org.id,
n.headline,
n.outlet,
n.url,
n.published_at,
n.summary,
n.tags,
n.relevance_score,
n.author_name,
n.source_type,
],
);
inserted.push(result.rows[0]);
existing.add(n.headline.trim());
} catch (insertErr) {
console.error('[news/discover] Insert error:', n.headline, insertErr);
}
}
auditLog('news.fr_discovered', 'news_item', null, org.id, user, {
source: 'federal_register',
term: fr.term,
fetched_count: fr.items.length,
total_available: fr.count,
discovered_count: inserted.length,
}, request.headers.get('x-forwarded-for') || undefined);
return NextResponse.json({
discovered: inserted.length,
source: 'Federal Register',
news: inserted,
});
} catch (err) {
console.error('[news/discover]', err);
return NextResponse.json({ error: 'News discovery failed' }, { status: 500 });
}
}