← back to Norma
app/api/ingest/grants-gov/route.ts
213 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import {
searchGrantsGov,
searchFederalRegister,
searchUSAspendingAwards,
type GrantsGovOpportunity,
} from '@/lib/grants-gov';
/**
* POST /api/ingest/grants-gov
*
* Ingests education-related grants from:
* 1. Grants.gov REST API (open federal opportunities)
* 2. FederalRegister.gov (policy notices)
* 3. USAspending (historical award data)
*
* Upserts into federal_grants and federal_grant_history tables.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const stats = { grantsGov: 0, federalRegister: 0, usaspending: 0, errors: [] as string[] };
// ── 1. Grants.gov ───────────────────────────────────────────────────────
try {
const searchTerms = [
'education student debt',
'student loan forgiveness',
'higher education financial aid',
'college scholarship grant',
'student assistance program',
];
const allOpps = new Map<string, GrantsGovOpportunity>();
for (const term of searchTerms) {
const opps = await searchGrantsGov(term, 50);
for (const opp of opps) {
if (opp.id && !allOpps.has(opp.id)) {
allOpps.set(opp.id, opp);
}
}
}
for (const opp of allOpps.values()) {
try {
await query(
`INSERT INTO federal_grants (
source_system, opportunity_id, opportunity_number, title, agency,
description, award_floor, award_ceiling, posted_date, close_date,
funding_category, official_url, tags, raw_data, fetched_at
) VALUES (
'grants_gov', $1, $2, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12, $13, NOW()
)
ON CONFLICT (opportunity_id) DO UPDATE SET
title = EXCLUDED.title,
agency = EXCLUDED.agency,
description = EXCLUDED.description,
award_floor = EXCLUDED.award_floor,
award_ceiling = EXCLUDED.award_ceiling,
close_date = EXCLUDED.close_date,
raw_data = EXCLUDED.raw_data,
updated_at = NOW()`,
[
opp.id,
opp.number,
opp.title,
opp.agency,
opp.synopsis?.synopsisDesc || opp.category?.description || '',
opp.awardFloor || null,
opp.awardCeiling || null,
opp.openDate || null,
opp.closeDate || null,
opp.category?.description || null,
'https://www.grants.gov/search-results-detail/' + opp.id,
['education', 'federal', opp.agency?.toLowerCase() || ''].filter(Boolean),
JSON.stringify(opp),
],
);
stats.grantsGov++;
} catch (err) {
stats.errors.push('grants_gov_insert: ' + (err as Error).message);
}
}
} catch (err) {
stats.errors.push('grants_gov_search: ' + (err as Error).message);
}
// ── 2. Federal Register ─────────────────────────────────────────────────
try {
const docs = await searchFederalRegister('education grant student loan', 50);
for (const doc of docs) {
try {
const oppId = 'fr_' + doc.document_number;
await query(
`INSERT INTO federal_grants (
source_system, opportunity_id, title, agency, description,
posted_date, official_url, tags, raw_data, fetched_at
) VALUES (
'federal_register', $1, $2, $3, $4,
$5, $6, $7, $8, NOW()
)
ON CONFLICT (opportunity_id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
raw_data = EXCLUDED.raw_data,
updated_at = NOW()`,
[
oppId,
doc.title,
doc.agencies?.[0]?.name || 'Federal Register',
doc.abstract || '',
doc.publication_date || null,
doc.html_url,
['federal-register', 'policy', 'education'],
JSON.stringify(doc),
],
);
stats.federalRegister++;
} catch (err) {
stats.errors.push('fed_register_insert: ' + (err as Error).message);
}
}
} catch (err) {
stats.errors.push('fed_register_search: ' + (err as Error).message);
}
// ── 3. USAspending Award History ────────────────────────────────────────
try {
const awards = await searchUSAspendingAwards(
['education', 'student loan', 'student debt', 'higher education'],
100,
);
for (const award of awards) {
try {
await query(
`INSERT INTO federal_grant_history (
assistance_listing_number, recipient_name, award_amount,
awarding_agency, start_date, end_date, cfda_number,
source_system, raw_data, fetched_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'usaspending', $8, NOW())`,
[
award['CFDA Number'] || null,
award['Recipient Name'] || null,
award['Award Amount'] || null,
award['Awarding Agency'] || null,
award['Start Date'] || null,
award['End Date'] || null,
award['CFDA Number'] || null,
JSON.stringify(award),
],
);
stats.usaspending++;
} catch (err) {
stats.errors.push('usaspending_insert: ' + (err as Error).message);
}
}
} catch (err) {
stats.errors.push('usaspending_search: ' + (err as Error).message);
}
return NextResponse.json({
success: true,
stats,
message: 'Ingested ' + stats.grantsGov + ' Grants.gov + ' + stats.federalRegister + ' Federal Register + ' + stats.usaspending + ' USAspending records',
});
}
/* ─── GET: Return ingested federal grants ────────────────────────────────── */
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { rows } = await query(`
SELECT id, source_system, opportunity_id, opportunity_number, title, agency,
description, award_floor, award_ceiling, posted_date, close_date,
funding_category, official_url, tags, fetched_at
FROM federal_grants
WHERE is_education_related = true
ORDER BY
CASE WHEN close_date IS NOT NULL AND close_date >= CURRENT_DATE THEN 0 ELSE 1 END,
close_date ASC NULLS LAST,
posted_date DESC NULLS LAST
LIMIT 200
`);
const { rows: awardStats } = await query(`
SELECT
COUNT(*) as total_awards,
COALESCE(SUM(award_amount), 0) as total_amount,
COALESCE(AVG(award_amount), 0) as avg_amount,
COALESCE(MAX(award_amount), 0) as max_amount
FROM federal_grant_history
`);
return NextResponse.json({
grants: rows,
total: rows.length,
awardHistory: awardStats[0] || null,
});
} catch (err) {
console.error('[ingest/grants-gov] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch federal grants' }, { status: 500 });
}
}