← back to Norma

app/api/outreach/generate/route.ts

121 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
import { getBrand } from '@/lib/brand';
import { getOrgId } from '@/lib/orgId';

function getGeminiUrl(): string {
  const geminiApiKey = process.env.GEMINI_API_KEY;
  if (!geminiApiKey) {
    throw new Error('[outreach/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/generate
 * AI generates a rekindle/outreach letter for the organization.
 * Body: { target_name, target_org, target_type, context, letter_type }
 * letter_type: 'rekindle' | 'introduction' | 'partnership' | 'thank_you' | 'follow_up'
 */
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 { target_name, target_org, target_type, context, letter_type } = body;

    if (!target_name) {
      return NextResponse.json({ error: 'target_name required' }, { status: 400 });
    }

    // Get org brand for dynamic prompts
    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
    const brand = await getBrand(orgId);
    const orgName = brand.name;
    const orgShort = brand.shortName;
    const focusDesc = brand.focusAreas?.length > 0 ? brand.focusAreas.join(', ') : 'advocacy and community impact';

    // Get recent news for context
    const newsRes = await query<{ headline: string }>(
      'SELECT headline FROM news_items ORDER BY published_at DESC NULLS LAST LIMIT 5'
    );
    const newsContext = newsRes.rows.map(n => n.headline).join('; ');

    // Get partner count for social proof
    const partnerCount = await query<{ count: string }>('SELECT count(*) FROM partners');
    const collabCount = await query<{ count: string }>('SELECT count(*) FROM collaborations');

    const letterPrompts: Record<string, string> = {
      rekindle: `Write a warm, professional rekindle letter from ${orgName} to ${target_name}${target_org ? ' at ' + target_org : ''}. ${orgName} previously had contact with them but it went cold. Reference their original area of interest and explain why NOW is a great time to reconnect. Mention recent developments in ${focusDesc}. Be warm but not pushy. Include a specific call to action (15-min call or coffee meeting).`,
      introduction: `Write a professional introduction letter from ${orgName} to ${target_name}${target_org ? ' at ' + target_org : ''}. This is a first-time outreach. Explain who ${orgName} is, what they do, and why a partnership would be mutually beneficial. Be concise and compelling.`,
      partnership: `Write a formal partnership proposal letter from ${orgName} to ${target_name}${target_org ? ' at ' + target_org : ''}. Outline specific ways ${orgName} and the target could collaborate on ${focusDesc}. Include 2-3 concrete partnership ideas.`,
      thank_you: `Write a gracious thank-you letter from ${orgName} to ${target_name}${target_org ? ' at ' + target_org : ''} for their recent support or meeting. Mention specific outcomes and next steps.`,
      follow_up: `Write a polite follow-up letter from ${orgName} to ${target_name}${target_org ? ' at ' + target_org : ''}. Reference a previous conversation or meeting and gently push toward the next step. Be warm and professional.`,
    };

    const prompt = `${letterPrompts[letter_type] || letterPrompts.rekindle}

Target type: ${target_type || 'organization'}
Additional context: ${context || 'None provided'}
Recent news: ${newsContext}
${orgName} network: ${partnerCount.rows[0]?.count || '0'} active partners, ${collabCount.rows[0]?.count || '0'} identified collaborators

${orgName} details:
- Non-profit focused on ${focusDesc}
${brand.mission ? `- Mission: ${brand.mission}` : ''}
- Works with legislators, organizations, nonprofits, and corporations

Format the letter with:
- Professional greeting
- 2-3 short paragraphs (not walls of text)
- Clear call to action
- Professional sign-off from "The ${orgName} Team"

Return ONLY valid JSON: { "subject": "email subject line", "body": "the full letter text", "tone_notes": "brief notes on the tone and approach used" }`;

    const geminiRes = await fetch(getGeminiUrl(), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: {
          temperature: 0.7,
          maxOutputTokens: 2048,
          responseMimeType: 'application/json',
        },
      }),
    });

    if (!geminiRes.ok) {
      const errText = await geminiRes.text();
      console.error('[outreach/generate] Gemini API error:', geminiRes.status, errText);
      return NextResponse.json({ error: 'AI service temporarily unavailable' }, { 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 {
      console.error('[outreach/generate] Failed to parse AI response:', text.slice(0, 500));
      return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
    }
  } catch (err) {
    console.error('[outreach/generate] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Outreach generation failed' }, { status: 500 });
  }
}