← back to Grant
app/api/grants/discover/route.ts
125 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
import { auditLog } from '@/lib/audit';
import { callGemini } from '@/lib/gemini';
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_mission, ai_focus_areas, ai_keywords, ai_org_summary 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];
// Get existing grant titles to avoid duplicates
const existingRes = await query(
'SELECT title, funder FROM grants WHERE org_id = $1',
[org.id],
);
const existingTitles = existingRes.rows.map(
(r) => `${r.title} - ${r.funder}`,
);
const prompt = `You are a nonprofit grant research assistant. Find 8-10 REAL grant opportunities for this organization:
Organization: ${org.name}
Mission: ${org.ai_mission}
Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
Keywords: ${(org.ai_keywords || []).join(', ')}
Summary: ${org.ai_org_summary}
${existingTitles.length > 0 ? `Already discovered (DO NOT repeat): ${existingTitles.join('; ')}` : ''}
Return ONLY a JSON array (no markdown, no code fences) of grant objects with these exact fields:
- title: string (the actual grant program name)
- funder: string (the actual foundation, government agency, or corporation)
- funder_url: string (the actual website URL for the funder)
- amount_min: number (minimum grant amount in dollars)
- amount_max: number (maximum grant amount in dollars)
- description: string (2-3 sentences about what the grant funds)
- eligibility: string (who can apply)
- focus_areas: string[] (array of 2-4 focus area tags)
- application_url: string (URL to apply or learn more)
- deadline: string (ISO date string, estimate if exact date unknown, use dates in 2026)
- cycle: string (one of: "annual", "biannual", "rolling", "one-time")
- ai_fit_score: number (0-1 score of how well this matches the org, be realistic)
- ai_suggestion: string (1-2 sentences on why this is a good fit and how to approach)
- priority: string (one of: "high", "medium", "low")
- tags: string[] (array of relevant tags)
Use real foundations like Ford Foundation, Gates Foundation, Lumina Foundation, Kresge Foundation, Arnold Ventures, Robin Hood Foundation, Tides Foundation, etc. Also include federal grants from Education Dept, CFPB, etc. Make amounts realistic for education/advocacy nonprofits.`;
// 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
const result = await callGemini<Record<string, unknown>[]>({ prompt, maxTokens: 8192 });
if (!result.ok) {
console.error('[grants/discover] gemini failed:', result.reason, result.detail);
return NextResponse.json(
{ error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
{ status: result.status },
);
}
const grants = result.data;
if (!Array.isArray(grants)) {
return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
}
// Insert each grant
const inserted = [];
for (const g of grants) {
try {
const result = await query(
`INSERT INTO grants (org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, priority, tags, status, source_api)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'discovered', 'ai_discovery')
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, source_api, created_at, updated_at`,
[
org.id,
g.title || 'Untitled Grant',
g.funder || 'Unknown',
g.funder_url || null,
g.amount_min || null,
g.amount_max || null,
g.description || null,
g.eligibility || null,
g.focus_areas || [],
g.application_url || null,
g.deadline || null,
g.cycle || 'annual',
g.ai_fit_score != null ? g.ai_fit_score : 0.5,
g.ai_suggestion || null,
g.priority || 'medium',
g.tags || [],
],
);
inserted.push(result.rows[0]);
} catch (insertErr) {
console.error('[grants/discover] Insert error for:', g.title, insertErr);
}
}
// 2026-05-05 (architect P2-3): audit AI call.
auditLog('grant.ai_discovered', 'grant', null, org.id, user, {
discovered_count: inserted.length,
input_tokens: result.inputTokens,
output_tokens: result.outputTokens,
}, request.headers.get('x-forwarded-for') || undefined);
return NextResponse.json({
discovered: inserted.length,
grants: inserted,
});
} catch (err) {
console.error('[grants/discover]', err);
return NextResponse.json({ error: 'Discovery failed' }, { status: 500 });
}
}