← back to Norma
app/api/collaborations/discover/route.ts
154 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
import { getBrand } from '@/lib/brand';
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');
/**
* POST /api/collaborations/discover
* Uses AI to discover potential collaborations based on the org's mission.
* Body: { type: 'nonprofit' | 'politician' | 'corporation' | 'municipality' }
*/
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 {
const body = await request.json();
const collabType = body.type || 'nonprofit';
// Get existing collaborations to avoid duplicates
const existing = await query<{ name: string }>(
'SELECT name FROM collaborations WHERE collab_type = $1',
[collabType]
);
const existingNames = existing.rows.map(function(r) { return r.name; });
// Get recent news for context (scoped to org)
const newsRes = orgId
? await query<{ headline: string; outlet: string }>(
'SELECT headline, outlet FROM news_items WHERE org_id = $1 ORDER BY published_at DESC NULLS LAST LIMIT 15',
[orgId]
)
: await query<{ headline: string; outlet: string }>(
'SELECT headline, outlet FROM news_items ORDER BY published_at DESC NULLS LAST LIMIT 15'
);
const brand = await getBrand();
const focusDesc = brand.focusAreas.join(', ');
const typePrompts: Record<string, string> = {
nonprofit: `non-profit organizations that work on ${focusDesc}, or related policy issues. Include organizations with similar missions and advocacy groups.`,
politician: `U.S. elected officials (senators, representatives, governors, state legislators) who have been vocal about ${focusDesc}. Include their party, state, and relevant committee assignments. Focus on those active in relevant committees or who have introduced related legislation.`,
corporation: `corporations and companies that offer related benefits programs or have expressed corporate social responsibility interest in ${focusDesc}. Include employers known for relevant assistance programs.`,
municipality: `cities, counties, and local government bodies that have discussed ${focusDesc}, created relevant local programs, passed resolutions, or addressed these issues in their meetings.`,
};
const prompt = 'You are a research assistant for ' + brand.name + ' (' + brand.shortName + '), a non-profit focused on ' + focusDesc + '.\n\n'
+ 'Generate a list of 8-10 ' + typePrompts[collabType] + '\n\n'
+ 'For each, provide:\n'
+ '- name: Full name (of person or organization)\n'
+ '- title: Their title/role (or null for orgs)\n'
+ '- organization: Their org (for politicians, their office/chamber)\n'
+ '- state: State abbreviation (if applicable)\n'
+ '- district: District info (for politicians)\n'
+ '- party: Political party (for politicians only)\n'
+ '- website_url: Their website\n'
+ '- focus_areas: Array of 2-3 relevant focus areas\n'
+ '- ai_reason: 2-3 sentences explaining why ' + brand.shortName + ' should collaborate with them\n'
+ '- ai_relevance: Score 0.0-1.0 of how relevant they are to ' + focusDesc + '\n'
+ '- ai_talking_points: Array of 2-3 suggested conversation starters\n\n'
+ 'EXCLUDE these already-known names: ' + existingNames.join(', ') + '\n\n'
+ 'Recent news context for relevance:\n'
+ newsRes.rows.map(function(n) { return '- ' + n.headline; }).join('\n') + '\n\n'
+ 'Return ONLY valid JSON: { "results": [ { name, title, organization, state, district, party, website_url, focus_areas, ai_reason, ai_relevance, ai_talking_points } ] }';
const geminiRes = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 4096,
responseMimeType: 'application/json',
},
}),
});
if (!geminiRes.ok) {
const errText = await geminiRes.text();
return NextResponse.json({ error: 'Gemini API error: ' + errText.slice(0, 200) }, { status: 502 });
}
const geminiData = await geminiRes.json();
const text = geminiData.candidates?.[0]?.content?.parts?.[0]?.text || '';
let results: Array<{
name: string;
title?: string;
organization?: string;
state?: string;
district?: string;
party?: string;
website_url?: string;
focus_areas?: string[];
ai_reason?: string;
ai_relevance?: number;
ai_talking_points?: string[];
}> = [];
try {
const parsed = JSON.parse(text);
results = parsed.results || parsed;
} catch {
return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
}
// Insert into DB
let inserted = 0;
for (const r of results) {
if (!r.name) continue;
// Skip duplicates
if (existingNames.some(function(n) { return n.toLowerCase() === r.name.toLowerCase(); })) continue;
try {
await query(
'INSERT INTO collaborations (collab_type, name, title, organization, website_url, state, district, party, focus_areas, ai_reason, ai_relevance, ai_talking_points, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)',
[
collabType,
r.name,
r.title || null,
r.organization || null,
r.website_url || null,
r.state || null,
r.district || null,
r.party || null,
r.focus_areas || null,
r.ai_reason || null,
r.ai_relevance || null,
r.ai_talking_points || null,
'suggested',
]
);
inserted++;
} catch {
// Skip duplicates
}
}
return NextResponse.json({
success: true,
discovered: results.length,
inserted: inserted,
type: collabType,
});
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}