← back to Grant
app/api/collaborations/discover/route.ts
121 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
import { auditLog } from '@/lib/audit';
import { callGemini } from '@/lib/gemini';
export async function POST(request: NextRequest) {
const session = verifyAuthWithOrg(request);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = session.username;
try {
const orgId = await resolveOrgId(session);
if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
const orgRes = await query(
'SELECT id, name, ai_mission, ai_focus_areas, ai_keywords, ai_org_summary, city, state FROM organizations WHERE id = $1',
[orgId],
);
if (orgRes.rows.length === 0) {
return NextResponse.json({ error: 'No organization found' }, { status: 400 });
}
const org = orgRes.rows[0];
// Get existing collaborators to avoid duplicates
const existingRes = await query(
'SELECT name, organization FROM collaborations WHERE org_id = $1',
[org.id],
);
const existingNames = existingRes.rows.map(
(r) => `${r.name} (${r.organization || 'N/A'})`,
);
const prompt = `You are a nonprofit partnerships and advocacy strategy consultant. Suggest 8-10 potential collaborators for this organization:
Organization: ${org.name}
Location: ${org.city}, ${org.state}
Mission: ${org.ai_mission}
Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
Keywords: ${(org.ai_keywords || []).join(', ')}
Summary: ${org.ai_org_summary}
${existingNames.length > 0 ? `Already connected (DO NOT repeat): ${existingNames.join('; ')}` : ''}
Suggest a mix of:
- 2-3 Nonprofits (real organizations working on student debt, education access, consumer protection)
- 2-3 Politicians (real US senators, representatives, or state officials who champion education/debt issues)
- 2 Corporations (companies with relevant CSR programs or education benefits)
- 1-2 Municipalities (city or county governments with student debt programs)
Return ONLY a JSON array (no markdown, no code fences) of objects with:
- collab_type: string (one of: "nonprofit", "politician", "corporation", "municipality")
- name: string (real person or organization contact name)
- title: string (their title/role)
- organization: string (their organization name)
- website_url: string (real website URL)
- email: string (plausible contact email, use info@ or press@ formats)
- district: string (for politicians, their district; for others, null)
- state: string (two-letter state code)
- ai_reason: string (2-3 sentences explaining why this is a valuable collaboration)
- ai_relevance: number (0-1 relevance score)
- ai_talking_points: string[] (3-4 specific talking points for initial outreach)`;
// 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
const result = await callGemini<Record<string, unknown>[]>({ prompt, maxTokens: 8192 });
if (!result.ok) {
console.error('[collabs/discover] gemini failed:', result.reason, result.detail);
return NextResponse.json(
{ error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
{ status: result.status },
);
}
const collabs = result.data;
if (!Array.isArray(collabs)) {
return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
}
const inserted = [];
for (const c of collabs) {
try {
const result = await query(
`INSERT INTO collaborations (org_id, collab_type, name, title, organization, website_url, email, district, state, ai_reason, ai_relevance, ai_talking_points, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'suggested')
RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`,
[
org.id,
c.collab_type || 'nonprofit',
c.name || 'Unknown',
c.title || null,
c.organization || null,
c.website_url || null,
c.email || null,
c.district || null,
c.state || null,
c.ai_reason || null,
c.ai_relevance != null ? c.ai_relevance : 0.5,
c.ai_talking_points || [],
],
);
inserted.push(result.rows[0]);
} catch (insertErr) {
console.error('[collabs/discover] Insert error:', c.name, insertErr);
}
}
// 2026-05-05 (architect P2-3): audit AI call.
auditLog('collaboration.ai_discovered', 'collaboration', null, org.id, user, {
discovered_count: inserted.length,
input_tokens: result.inputTokens,
output_tokens: result.outputTokens,
}, request.headers.get('x-forwarded-for') || undefined);
return NextResponse.json({
discovered: inserted.length,
collaborations: inserted,
});
} catch (err) {
console.error('[collabs/discover]', err);
return NextResponse.json({ error: 'Collaboration discovery failed' }, { status: 500 });
}
}