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