← back to Norma
app/api/agents/grants/match/route.ts
194 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
/**
* GET /api/agents/grants/match
* Match grants to an organization based on key_issues and focus_populations.
*
* Query params:
* org_id - required, UUID of the organization
* grant_type - optional: 'federal', 'state', 'foundation', 'corporate'
* issue_area - optional: filter by issue area
* budget_min - optional: minimum grant amount
* budget_max - optional: maximum grant amount
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { searchParams } = new URL(request.url);
const orgId = searchParams.get('org_id');
const grantType = searchParams.get('grant_type');
const issueArea = searchParams.get('issue_area');
const budgetMin = searchParams.get('budget_min');
const budgetMax = searchParams.get('budget_max');
if (!orgId) {
return NextResponse.json({ error: 'org_id is required' }, { status: 400 });
}
// Fetch organization
const orgResult = await query(
'SELECT id, name, key_issues, focus_populations, mission, type, city, state FROM organizations WHERE id = $1',
[orgId],
);
if (orgResult.rowCount === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 });
}
const org = orgResult.rows[0];
const orgIssues = (org.key_issues || []).map((i: string) => i.toLowerCase());
const orgPops = (org.focus_populations || []).map((p: string) => p.toLowerCase());
// Fetch grants from both tables
const results: any[] = [];
// 1. Internal grants table
{
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (issueArea) {
conditions.push(`$${idx++} = ANY(focus_areas)`);
params.push(issueArea);
}
if (budgetMin) {
conditions.push(`(amount_max IS NULL OR amount_max >= $${idx++})`);
params.push(parseInt(budgetMin));
}
if (budgetMax) {
conditions.push(`(amount_min IS NULL OR amount_min <= $${idx++})`);
params.push(parseInt(budgetMax));
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const grantsResult = await query(
`SELECT id, title, funder, amount_min, amount_max, description, eligibility,
focus_areas, deadline, status, application_url, ai_fit_score
FROM grants ${where}
ORDER BY deadline ASC NULLS LAST
LIMIT 100`,
params,
);
for (const g of grantsResult.rows) {
const focusAreas = (g.focus_areas || []).map((a: string) => a.toLowerCase());
const issueOverlap = orgIssues.filter((i: string) =>
focusAreas.some((a: string) => a.includes(i) || i.includes(a)),
).length;
const textMatch = orgIssues.filter((i: string) =>
(g.description || '').toLowerCase().includes(i) ||
(g.eligibility || '').toLowerCase().includes(i),
).length;
const score = Math.min(
100,
Math.round(
(issueOverlap / Math.max(orgIssues.length, 1)) * 50 +
(textMatch / Math.max(orgIssues.length, 1)) * 30 +
(g.ai_fit_score ? g.ai_fit_score * 20 : 10),
),
);
results.push({
id: g.id,
source: 'internal',
title: g.title,
funder: g.funder,
amount_min: g.amount_min,
amount_max: g.amount_max,
description: g.description,
eligibility: g.eligibility,
focus_areas: g.focus_areas,
deadline: g.deadline,
application_url: g.application_url,
status: g.status,
match_score: score,
});
}
}
// 2. Federal grants table
if (!grantType || grantType === 'federal') {
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (budgetMin) {
conditions.push(`(award_ceiling IS NULL OR award_ceiling >= $${idx++})`);
params.push(parseInt(budgetMin));
}
if (budgetMax) {
conditions.push(`(award_floor IS NULL OR award_floor <= $${idx++})`);
params.push(parseInt(budgetMax));
}
// Only open grants (close_date in the future or null)
conditions.push(`(close_date IS NULL OR close_date >= CURRENT_DATE)`);
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const fedResult = await query(
`SELECT id, title, agency, sub_agency, description, eligibility_text,
eligible_applicant_types, funding_category, award_floor, award_ceiling,
estimated_typical_award, close_date, official_url, tags
FROM federal_grants ${where}
ORDER BY close_date ASC NULLS LAST
LIMIT 100`,
params,
);
for (const g of fedResult.rows) {
const desc = (g.description || '').toLowerCase();
const elig = (g.eligibility_text || '').toLowerCase();
const tags = (g.tags || []).map((t: string) => t.toLowerCase());
const issueOverlap = orgIssues.filter((i: string) =>
desc.includes(i) || elig.includes(i) || tags.some((t: string) => t.includes(i) || i.includes(t)),
).length;
const popOverlap = orgPops.filter((p: string) =>
desc.includes(p) || elig.includes(p),
).length;
const score = Math.min(
100,
Math.round(
(issueOverlap / Math.max(orgIssues.length, 1)) * 45 +
(popOverlap / Math.max(orgPops.length, 1)) * 35 +
(g.is_education_related ? 15 : 5) + 5,
),
);
results.push({
id: g.id,
source: 'federal',
title: g.title,
funder: g.agency || 'Federal Agency',
amount_min: g.award_floor ? Number(g.award_floor) : null,
amount_max: g.award_ceiling ? Number(g.award_ceiling) : null,
description: g.description,
eligibility: g.eligibility_text,
focus_areas: g.tags || [],
deadline: g.close_date,
application_url: g.official_url,
status: 'open',
match_score: score,
});
}
}
// Sort by match_score descending
results.sort((a, b) => b.match_score - a.match_score);
return NextResponse.json({
grants: results.slice(0, 50),
total: results.length,
org_name: org.name,
org_issues: org.key_issues,
});
} catch (err) {
console.error('[agents/grants/match] Error:', (err as Error).message);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}