← back to Norma
app/api/grants/suggest/route.ts
198 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
interface GrantRow {
id: string;
title: string;
funder: string;
funder_url: string | null;
amount_min: number | null;
amount_max: number | null;
description: string | null;
eligibility: string | null;
focus_areas: string[] | null;
application_url: string | null;
deadline: string | null;
notification_date: string | null;
grant_period_start: string | null;
grant_period_end: string | null;
cycle: string | null;
ai_fit_score: number | null;
ai_suggestion: string | null;
ai_application_tips: string | null;
status: string;
priority: string;
notes: string | null;
applied_at: string | null;
amount_requested: number | null;
amount_awarded: number | null;
tags: string[] | null;
is_bookmarked: boolean;
created_at: string;
updated_at: string;
}
interface Suggestion {
grant: GrantRow;
reason: string;
urgency_level: 'urgent' | 'soon' | 'plenty-of-time';
recommended_next_step: string;
days_until_deadline: number | null;
score: number;
}
// Organization-relevant focus area keywords for matching
const ORG_KEYWORDS = new Set([
'education',
'student-debt',
'advocacy',
'consumer-protection',
'civic-engagement',
]);
/**
* POST /api/grants/suggest
* AI grant suggestion endpoint (Phase 1: rule-based scoring).
*
* Scoring rules:
* - Base: ai_fit_score
* - +0.2 if deadline is within 30 days (urgent)
* - +0.1 if deadline is within 60 days
* - +0.15 if priority is 'high'
* - +0.1 for each focus_area that matches org keywords
*
* Returns top 5 suggestions sorted by score DESC, then deadline ASC.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
// Fetch grants in researching or preparing status
const grantsResult = await query<GrantRow>(
orgId
? `SELECT * FROM grants WHERE status IN ('researching', 'preparing') AND org_id = $1 ORDER BY deadline ASC NULLS LAST`
: `SELECT * FROM grants WHERE status IN ('researching', 'preparing') ORDER BY deadline ASC NULLS LAST`,
orgId ? [orgId] : []
);
if (grantsResult.rows.length === 0) {
return NextResponse.json({ suggestions: [] });
}
const now = new Date();
const scored: Suggestion[] = [];
for (const grant of grantsResult.rows) {
let score = grant.ai_fit_score ?? 0;
const reasons: string[] = [];
// Calculate days until deadline
let daysUntilDeadline: number | null = null;
if (grant.deadline) {
const deadlineDate = new Date(grant.deadline);
daysUntilDeadline = Math.ceil(
(deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
// Deadline urgency scoring (only one applies — most urgent wins)
if (daysUntilDeadline > 0 && daysUntilDeadline <= 30) {
score += 0.2;
reasons.push(`Deadline in ${daysUntilDeadline} days — urgent`);
} else if (daysUntilDeadline > 0 && daysUntilDeadline <= 60) {
score += 0.1;
reasons.push(`Deadline in ${daysUntilDeadline} days — approaching`);
}
}
// Priority scoring
if (grant.priority === 'high') {
score += 0.15;
reasons.push('Marked as high priority');
}
// Focus area keyword matching
const matchedKeywords: string[] = [];
if (grant.focus_areas) {
for (const area of grant.focus_areas) {
const normalized = area.toLowerCase().trim();
if (ORG_KEYWORDS.has(normalized)) {
score += 0.1;
matchedKeywords.push(area);
}
}
}
if (matchedKeywords.length > 0) {
reasons.push(`Focus areas match org mission: ${matchedKeywords.join(', ')}`);
}
// Base fit score reason
if (grant.ai_fit_score != null && grant.ai_fit_score >= 0.8) {
reasons.push(`Strong AI fit score (${grant.ai_fit_score})`);
} else if (grant.ai_fit_score != null) {
reasons.push(`AI fit score: ${grant.ai_fit_score}`);
}
// Determine urgency level
let urgencyLevel: 'urgent' | 'soon' | 'plenty-of-time';
if (daysUntilDeadline !== null && daysUntilDeadline <= 30) {
urgencyLevel = 'urgent';
} else if (daysUntilDeadline !== null && daysUntilDeadline <= 60) {
urgencyLevel = 'soon';
} else {
urgencyLevel = 'plenty-of-time';
}
// Determine recommended next step based on status and urgency
let recommendedNextStep: string;
if (grant.status === 'preparing') {
if (urgencyLevel === 'urgent') {
recommendedNextStep = 'Finalize and submit application immediately — deadline approaching.';
} else {
recommendedNextStep = 'Continue preparing application materials. Review eligibility requirements.';
}
} else {
// researching
if (urgencyLevel === 'urgent') {
recommendedNextStep = 'Move to preparing status now — research phase needs to end to meet deadline.';
} else if (urgencyLevel === 'soon') {
recommendedNextStep = 'Begin preparing application. Gather supporting materials and draft narrative.';
} else {
recommendedNextStep = 'Continue research. Review funder priorities and past grantees for alignment.';
}
}
scored.push({
grant,
reason: reasons.join('. ') + '.',
urgency_level: urgencyLevel,
recommended_next_step: recommendedNextStep,
days_until_deadline: daysUntilDeadline,
score: Math.min(Math.round(score * 100), 99),
});
}
// Sort by score DESC, then deadline ASC (null deadlines last)
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
// Both null: equal
if (a.days_until_deadline === null && b.days_until_deadline === null) return 0;
// Null goes last
if (a.days_until_deadline === null) return 1;
if (b.days_until_deadline === null) return -1;
return a.days_until_deadline - b.days_until_deadline;
});
// Return top 5
const top5 = scored.slice(0, 5);
return NextResponse.json({ suggestions: top5 });
} catch (err) {
console.error('[api/grants/suggest] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to generate grant suggestions' }, { status: 500 });
}
}