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