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