← back to Freddy

app/api/matches/generate/route.ts

139 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';
import { auditLog } from '@/lib/audit';

const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');

export async function POST(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    // Fetch top causes, orgs, and granters
    const [causesRes, orgsRes, grantersRes] = await Promise.all([
      query(`SELECT id, title, description, category, urgency_score, total_votes, tags
             FROM causes WHERE status = 'active' ORDER BY urgency_score DESC, total_votes DESC LIMIT 20`),
      query(`SELECT id, name, mission, category, focus_areas, trust_score, impact_score, total_votes
             FROM organizations WHERE status IN ('verified', 'featured') ORDER BY total_votes DESC LIMIT 20`),
      query(`SELECT id, name, funding_areas, annual_budget, min_grant, max_grant, preference_tags
             FROM granters WHERE status = 'active' LIMIT 20`),
    ]);

    if (causesRes.rows.length === 0 || orgsRes.rows.length === 0) {
      return NextResponse.json({
        error: 'Need at least one cause and one organization to generate matches',
      }, { status: 400 });
    }

    const prompt = `You are a funding match analyst. Given these causes, organizations, and granters, generate optimal funding matches.

CAUSES:
${JSON.stringify(causesRes.rows, null, 2)}

ORGANIZATIONS:
${JSON.stringify(orgsRes.rows, null, 2)}

GRANTERS:
${JSON.stringify(grantersRes.rows, null, 2)}

Generate up to 10 matches. Each match should pair a cause with the most suitable organization and optionally a granter. Score each match on:
- cause_alignment (0-1): How well the org's focus matches the cause
- public_support (0-1): Based on vote counts and urgency
- granter_fit (0-1): How well granter preferences align (0 if no granter)
- match_score (0-1): Overall weighted score

Return ONLY valid JSON array:
[{"cause_id":"uuid", "org_id":"uuid", "granter_id":"uuid or null", "match_score":0.85, "cause_alignment":0.9, "public_support":0.8, "granter_fit":0.85, "ai_reasoning":"2-3 sentence explanation"}]`;

    const geminiRes = await fetch(GEMINI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: { temperature: 0.5, maxOutputTokens: 4096 },
      }),
    });

    if (!geminiRes.ok) {
      console.error('[matches/generate] Gemini error');
      return NextResponse.json({ error: 'AI service error' }, { status: 502 });
    }

    const geminiData = await geminiRes.json();
    const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    let jsonStr = rawText.trim();
    if (jsonStr.startsWith('```')) {
      jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
    }

    let matches: Array<{
      cause_id: string;
      org_id: string;
      granter_id: string | null;
      match_score: number;
      cause_alignment: number;
      public_support: number;
      granter_fit: number;
      ai_reasoning: string;
    }>;

    try {
      matches = JSON.parse(jsonStr);
    } catch {
      console.error('[matches/generate] Parse error:', jsonStr.slice(0, 300));
      return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
    }

    // Validate IDs exist
    const validCauseIds = new Set(causesRes.rows.map(r => r.id));
    const validOrgIds = new Set(orgsRes.rows.map(r => r.id));
    const validGranterIds = new Set(grantersRes.rows.map(r => r.id));

    const inserted = [];
    for (const match of matches) {
      if (!validCauseIds.has(match.cause_id) || !validOrgIds.has(match.org_id)) continue;
      if (match.granter_id && !validGranterIds.has(match.granter_id)) {
        match.granter_id = null;
      }

      try {
        const result = await query(
          `INSERT INTO matches (cause_id, org_id, granter_id, match_score, cause_alignment, public_support, granter_fit, ai_reasoning)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
           RETURNING *`,
          [
            match.cause_id, match.org_id, match.granter_id,
            Math.min(1, Math.max(0, match.match_score)),
            Math.min(1, Math.max(0, match.cause_alignment)),
            Math.min(1, Math.max(0, match.public_support)),
            Math.min(1, Math.max(0, match.granter_fit)),
            match.ai_reasoning,
          ],
        );
        inserted.push(result.rows[0]);
      } catch (insertErr) {
        console.error('[matches/generate] Insert error:', (insertErr as Error).message);
      }
    }

    await auditLog('matches.generated', 'match', null, {
      user,
      generated: matches.length,
      inserted: inserted.length,
    });

    return NextResponse.json({
      generated: matches.length,
      inserted: inserted.length,
      matches: inserted,
    });
  } catch (err) {
    console.error('[matches/generate] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to generate matches' }, { status: 500 });
  }
}