← back to Freddy
app/api/contacts/generate-letter/route.ts
124 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 || '');
const DISCLAIMER = `\n\n---\nFreddy provides introduction and facilitation services only. Freddy does not serve as fiscal agent, fiduciary, or guarantor of any grant, donation, or contractual agreement between parties.`;
export async function POST(request: NextRequest) {
const user = verifyAuth(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json();
const { contact_id, letter_type } = body;
if (!contact_id || !letter_type) {
return NextResponse.json({ error: 'contact_id and letter_type are required' }, { status: 400 });
}
const validTypes = ['nonprofit_pitch', 'granter_pitch', 'introduction', 'follow_up'];
if (!validTypes.includes(letter_type)) {
return NextResponse.json({ error: 'Invalid letter_type' }, { status: 400 });
}
// Fetch contact
const contactRes = await query('SELECT * FROM contacts WHERE id = $1', [contact_id]);
if (contactRes.rows.length === 0) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
}
const contact = contactRes.rows[0];
// Fetch context
const [causesRes, orgsRes, grantersRes, matchesRes] = await Promise.all([
query(`SELECT title, urgency_score, total_votes, category FROM causes WHERE status = 'active' ORDER BY urgency_score DESC LIMIT 5`),
query(`SELECT name, mission, trust_score, total_votes, category FROM organizations WHERE status IN ('verified', 'featured') ORDER BY total_votes DESC LIMIT 5`),
query(`SELECT name, funding_areas, min_grant, max_grant FROM granters WHERE status = 'active' LIMIT 5`),
query(`SELECT m.match_score, c.title as cause_title, o.name as org_name, g.name as granter_name
FROM matches m
JOIN causes c ON c.id = m.cause_id
JOIN organizations o ON o.id = m.org_id
LEFT JOIN granters g ON g.id = m.granter_id
ORDER BY m.match_score DESC LIMIT 5`),
]);
const typeDescriptions: Record<string, string> = {
nonprofit_pitch: `This is a pitch letter TO a non-profit organization leader. The pitch explains that Freddy has identified grant opportunities that match their mission. Emphasize the democratic validation (public votes), the specific grant matches we've found, and how our facilitation can help them secure funding.
Fee structure to mention: Introduction Fee of $750 (one-time), and a 5% success fee only if the grant is awarded. No upfront costs to explore matches.`,
granter_pitch: `This is a pitch letter TO a granter/foundation. The pitch shows that Freddy has vetted, publicly-supported non-profits ready for funding. Emphasize the democratic validation (citizens voted on these causes), the trust scores and impact metrics of our registered organizations, and how our matching system ensures their funding reaches the right organizations.
Fee structure to mention: No cost to the granter. Freddy's fees are covered by the non-profit side.`,
introduction: `This is a formal introduction letter connecting a granter with a non-profit (or vice versa). Professional, warm, and concise. Reference the specific match scores and why this introduction was generated.`,
follow_up: `This is a follow-up letter to a previous outreach. Reference the initial contact, ask about their interest, and provide any new relevant matches or updates.`,
};
const prompt = `You are writing a professional fundraising outreach letter on behalf of Freddy, The Fundraising Coordinator -- a democratic funding marketplace that connects citizens, non-profits, and grant-making organizations.
LETTER TYPE: ${letter_type}
${typeDescriptions[letter_type]}
RECIPIENT:
- Name: ${contact.name}
- Title: ${contact.title || 'N/A'}
- Organization: ${contact.organization || 'N/A'}
- Type: ${contact.contact_type}
${contact.ai_pitch ? `- AI-suggested approach: ${contact.ai_pitch}` : ''}
PLATFORM CONTEXT:
Top Causes (voted by citizens): ${JSON.stringify(causesRes.rows)}
Top Organizations: ${JSON.stringify(orgsRes.rows)}
Active Granters: ${JSON.stringify(grantersRes.rows)}
Recent Matches: ${JSON.stringify(matchesRes.rows)}
CRITICAL REQUIREMENTS:
1. Professional but warm tone
2. Reference specific data from our platform (votes, scores, matches)
3. Be specific about why THIS person/org is a good fit
4. End with a clear call-to-action (meeting, call, or email response)
5. Keep to 250-400 words
6. DO NOT include subject line or headers -- just the letter body
7. Sign off as "The Freddy Team"
Write the letter now:`;
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: 2048 },
}),
});
if (!geminiRes.ok) {
console.error('[contacts/generate-letter] Gemini error');
return NextResponse.json({ error: 'AI service error' }, { status: 502 });
}
const geminiData = await geminiRes.json();
let letterText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Append the legal disclaimer
letterText = letterText.trim() + DISCLAIMER;
await auditLog('letter.generated', 'contact', contact_id, {
user,
letter_type,
contact_name: contact.name,
});
return NextResponse.json({ letter: letterText, contact_id, letter_type });
} catch (err) {
console.error('[contacts/generate-letter] Error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to generate letter' }, { status: 500 });
}
}