← 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 });
  }
}