← back to Norma
app/api/outreach-pipeline/generate/route.ts
166 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { checkRateLimit } from '@/lib/rate-limit';
import { requireRole } from '@/lib/require-role';
import { getBrand } from '@/lib/brand';
function getGeminiUrl(): string {
const geminiApiKey = process.env.GEMINI_API_KEY;
if (!geminiApiKey) {
throw new Error('[outreach-pipeline/generate] GEMINI_API_KEY env var is required');
}
return `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`;
}
/**
* POST /api/outreach-pipeline/generate
* AI generates a letter from a template for a specific pipeline entry.
* Body: { pipeline_id, template_id }
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { limited, resetIn } = checkRateLimit(request);
if (limited) {
return NextResponse.json(
{ error: 'Rate limit exceeded', retryAfter: Math.ceil(resetIn / 1000) },
{ status: 429 },
);
}
try {
const body = await request.json();
const { pipeline_id, template_id } = body;
if (!pipeline_id || !template_id) {
return NextResponse.json(
{ error: 'pipeline_id and template_id are required' },
{ status: 400 }
);
}
// Fetch pipeline entry
const pipelineRes = await query(
'SELECT * FROM outreach_pipeline WHERE id = $1',
[pipeline_id]
);
if (pipelineRes.rows.length === 0) {
return NextResponse.json({ error: 'Pipeline entry not found' }, { status: 404 });
}
const entry = pipelineRes.rows[0];
// Fetch template
const templateRes = await query(
'SELECT * FROM outreach_templates WHERE id = $1',
[template_id]
);
if (templateRes.rows.length === 0) {
return NextResponse.json({ error: 'Template not found' }, { status: 404 });
}
const template = templateRes.rows[0];
// Build context for AI
const staffContacts = Array.isArray(entry.staff_contacts)
? entry.staff_contacts
: JSON.parse(entry.staff_contacts || '[]');
const schedulerName = staffContacts.find(
(c: Record<string, string>) => c.role === 'scheduler'
)?.name || staffContacts[0]?.name || 'Office Staff';
const staffEmail = staffContacts[0]?.email || '';
const brand = await getBrand();
const prompt = `You are an expert nonprofit communications writer for ${brand.shortName} (${brand.name}), a 501(c)(3) nonprofit.
I have a template and a pipeline contact. Fill in the template variables with appropriate, professional content based on the contact details. Where data is not available, use sensible, professional placeholders that the user can easily find and replace.
TEMPLATE TYPE: ${template.template_type}
TEMPLATE NAME: ${template.name}
TEMPLATE BODY:
${template.body}
TEMPLATE VARIABLES TO FILL: ${(template.variables || []).join(', ')}
CONTACT DETAILS:
- Office: ${entry.office_name}
- Official: ${entry.official_name || 'Unknown'}
- Target Type: ${entry.target_type}
- District: ${entry.district || 'N/A'}
- State: ${entry.state || 'N/A'}
- City: ${entry.city || 'N/A'}
- Level: ${entry.level || 'N/A'}
- Party: ${entry.party || 'N/A'}
- Committees: ${(entry.committees || []).join(', ') || 'N/A'}
- Issue Areas: ${(entry.issue_areas || []).join(', ') || brand.focusAreas.join(', ')}
- Scheduler/Staff Name: ${schedulerName}
- Staff Email: ${staffEmail}
- Stage: ${entry.stage}
- Channel: ${entry.channel || 'email'}
- Notes: ${entry.notes || 'None'}
${brand.shortName} DETAILS:
- Organization: ${brand.name} (${brand.shortName})
- Type: 501(c)(3) nonprofit
- Mission: ${brand.mission}
- Focus: ${brand.focusAreas.join(', ')}
INSTRUCTIONS:
1. Fill in ALL template variables with appropriate content
2. Keep the tone professional, nonpartisan, and issue-focused (NOT candidate-focused)
3. For any data you don't have, use clear bracket placeholders like [INSERT DATE] that are easy to spot
4. Make the content specific to the district/state/office where possible
5. Suggest 3 realistic meeting time options for the next 2 weeks
Return ONLY valid JSON:
{
"subject": "the email subject line with variables filled in",
"body": "the complete letter with all variables filled in",
"variables_filled": { "VariableName": "value used" },
"notes": "brief notes about what was filled in and what needs manual review"
}`;
const geminiRes = await fetch(getGeminiUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.6,
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 || '';
try {
const letter = JSON.parse(text);
return NextResponse.json({ success: true, letter });
} catch {
return NextResponse.json(
{ error: 'Failed to parse AI response' },
{ status: 500 }
);
}
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 500 }
);
}
}