← 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 });
  }
}