← back to Grant
app/api/outreach/generate/route.ts
126 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';
import { sanitizeProposalBody } from '@/lib/sanitize';
type OutreachShape = { subject?: string; body_html?: string; body_text?: string };
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 body = await request.json();
// 2026-05-04 (code-reviewer P1-2): cap user-controlled fields interpolated
// into the Gemini prompt — prompt-injection + cost-amplification surface.
const MAX_FIELD = 500;
const MAX_CONTEXT = 2000;
const target_name = String(body.target_name || '').slice(0, MAX_FIELD);
const target_org = String(body.target_org || '').slice(0, MAX_FIELD);
const target_type = String(body.target_type || '').slice(0, 50);
const template_type = String(body.template_type || '').slice(0, 50);
const context = String(body.context || '').slice(0, MAX_CONTEXT);
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_org_summary, city, state, staff_names 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];
const typeLabels: Record<string, string> = {
meeting_request: 'Meeting Request',
one_pager: 'One-Page Organization Summary',
thank_you: 'Thank You Letter',
follow_up: 'Follow-Up Email',
introduction: 'Introduction Email',
donation_ask: 'Donation Request',
};
const targetLabels: Record<string, string> = {
official: 'Government Official / Elected Representative',
corporation: 'Corporate CSR / Partnerships Contact',
nonprofit: 'Nonprofit Partner',
donor: 'Individual Donor',
general: 'General Contact',
};
const prompt = `You are a professional nonprofit communications writer. Generate a ${typeLabels[template_type] || 'Introduction Email'} for outreach to a ${targetLabels[target_type] || 'general contact'}.
SENDER ORGANIZATION:
- Name: ${org.name}
- Location: ${org.city}, ${org.state}
- Mission: ${org.ai_mission}
- Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
- Summary: ${org.ai_org_summary}
- Key Staff: ${(org.staff_names || []).join(', ')}
RECIPIENT:
- Name: ${target_name || 'N/A'}
- Organization: ${target_org || 'N/A'}
- Type: ${targetLabels[target_type] || 'General'}
${context ? `ADDITIONAL CONTEXT: ${context}` : ''}
Return ONLY a JSON object (no markdown, no code fences) with:
- subject: string (compelling email subject line)
- body_html: string (professional HTML-formatted email with <p>, <strong>, <ul>, <li> tags. Appropriate tone for the recipient type. Include specific talking points about student debt advocacy and how collaboration benefits both parties.)
- body_text: string (plain text version)`;
// 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
const result = await callGemini<OutreachShape>({ prompt, maxTokens: 4096 });
if (!result.ok) {
console.error('[outreach/generate] 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 generated = result.data;
// 2026-05-30 (audit P1-5): sanitize AI-generated HTML before storage —
// Gemini output is untrusted input once it lands in the DB.
const cleanHtml = sanitizeProposalBody(generated.body_html || '');
// Save as a template
const title = `${typeLabels[template_type] || 'Email'} - ${target_name || target_org || 'General'}`;
const saveRes = await query(
`INSERT INTO outreach_templates (org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
RETURNING id, org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated, version, created_at, updated_at`,
[
org.id,
template_type || 'introduction',
target_type || 'general',
title,
generated.subject || 'Outreach from ' + org.name,
cleanHtml,
generated.body_text || '',
],
);
// 2026-05-05 (architect P2-3): audit AI call.
auditLog('outreach.ai_generated', 'outreach_template', saveRes.rows[0].id, org.id, user, {
template_type, target_type,
input_tokens: result.inputTokens,
output_tokens: result.outputTokens,
}, request.headers.get('x-forwarded-for') || undefined);
return NextResponse.json({
subject: generated.subject,
body_html: cleanHtml,
body_text: generated.body_text,
template: saveRes.rows[0],
});
} catch (err) {
console.error('[outreach/generate]', err);
return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
}
}