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