← back to Grant

app/api/grants/discover-real/route.ts

147 lines

/**
 * /api/grants/discover-real — REAL grants from Grants.gov public API.
 *
 * 2026-05-05 (tick 18): replaces the AI-fabricated /api/grants/discover
 * pattern (which Gemini just makes up "plausible URLs at that outlet"
 * and then ships the fakes to users — credibility/liability risk per
 * P2-2). This route hits api.grants.gov/v1/api/search2 — free, no auth,
 * real federal grant opportunities.
 *
 * The original /api/grants/discover route stays in place for now (until
 * a UI cutover decides). Both write to the same `grants` table; this
 * route tags rows with source_api='grants_gov'.
 */

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
import { auditLog } from '@/lib/audit';

const SEARCH_URL = 'https://api.grants.gov/v1/api/search2';

type GrantsGovHit = {
  id: string;
  number?: string;
  title?: string;
  agencyCode?: string;
  agency?: string;
  openDate?: string; // MM/DD/YYYY
  closeDate?: string; // MM/DD/YYYY
  oppStatus?: string;
  docType?: string;
  cfdaList?: string[];
};

type GrantsGovResponse = {
  errorcode: number;
  msg: string;
  data?: {
    hitCount?: number;
    oppHits?: GrantsGovHit[];
  };
};

// MM/DD/YYYY → YYYY-MM-DD (PG date)
function normalizeDate(s?: string): string | null {
  if (!s) return null;
  const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
  if (!m) return null;
  return `${m[3]}-${m[1]}-${m[2]}`;
}

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 body = await request.json().catch(() => ({}));
    const keyword = String(body.keyword || 'education').slice(0, 200);
    const rows = Math.min(Math.max(parseInt(body.rows ?? '25', 10) || 25, 1), 100);

    const orgId = await resolveOrgId(session);
    if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });

    // 25s timeout matches the Gemini wrapper's pattern for consistency.
    let res: Response;
    try {
      res = await fetch(SEARCH_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          keyword,
          oppStatuses: 'forecasted|posted',
          rows,
        }),
        signal: AbortSignal.timeout(25_000),
      });
    } catch (e) {
      console.error('[grants/discover-real] grants.gov fetch aborted:', (e as Error).message);
      return NextResponse.json({ error: 'Grants.gov timeout' }, { status: 504 });
    }

    if (!res.ok) {
      console.error('[grants/discover-real] grants.gov error:', res.status);
      return NextResponse.json({ error: 'Grants.gov upstream error' }, { status: 502 });
    }

    const json = (await res.json()) as GrantsGovResponse;
    if (json.errorcode !== 0) {
      console.error('[grants/discover-real] grants.gov errorcode:', json.errorcode, json.msg);
      return NextResponse.json({ error: 'Grants.gov returned error' }, { status: 502 });
    }

    const hits = json.data?.oppHits ?? [];
    const inserted: unknown[] = [];
    for (const h of hits) {
      // Skip if we already imported this opportunity number.
      if (h.number) {
        const dup = await query(
          'SELECT id FROM grants WHERE org_id = $1 AND grants_gov_id = $2 LIMIT 1',
          [orgId, h.id],
        );
        if (dup.rows.length > 0) continue;
      }

      try {
        const result = await query(
          `INSERT INTO grants (org_id, title, funder, funder_url, deadline, status, priority, source_api, grants_gov_id, ai_suggestion, application_url)
           VALUES ($1, $2, $3, $4, $5, 'discovered', 'medium', 'grants_gov', $6, $7, $8)
           RETURNING id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, grants_gov_id, federal_register_id, source_api, created_at, updated_at`,
          [
            orgId,
            h.title || `Untitled (${h.number || h.id})`,
            h.agency || h.agencyCode || 'Federal',
            null,
            normalizeDate(h.closeDate),
            h.id,
            h.cfdaList && h.cfdaList.length ? `CFDA ${h.cfdaList.join(', ')}` : null,
            `https://www.grants.gov/search-results-detail/${h.id}`,
          ],
        );
        inserted.push(result.rows[0]);
      } catch (insertErr) {
        console.error('[grants/discover-real] insert error:', h.title, (insertErr as Error).message);
      }
    }

    auditLog('grant.real_discovered', 'grant', null, orgId, user, {
      keyword,
      hit_count: json.data?.hitCount ?? 0,
      inserted_count: inserted.length,
      source: 'grants.gov',
    }, request.headers.get('x-forwarded-for') || undefined);

    return NextResponse.json({
      keyword,
      total_hits: json.data?.hitCount ?? 0,
      discovered: inserted.length,
      grants: inserted,
      source: 'grants.gov · public API · no AI fabrication',
    });
  } catch (err) {
    console.error('[grants/discover-real]', err);
    return NextResponse.json({ error: 'Real discovery failed' }, { status: 500 });
  }
}