← back to Norma

app/api/agents/advocacy/generate/route.ts

163 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';

function getGeminiUrl(): string {
  const geminiApiKey = process.env.GEMINI_API_KEY;
  if (!geminiApiKey) {
    throw new Error('[agents/advocacy/generate] GEMINI_API_KEY env var is required');
  }
  return `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`;
}

export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await request.json();
    const { org_id, campaign_name, issue, zip, target_ids } = body;

    if (!org_id || !campaign_name || !issue) {
      return NextResponse.json(
        { error: 'org_id, campaign_name, and issue are required' },
        { status: 400 },
      );
    }

    // Fetch organization
    const orgResult = await query(
      'SELECT name, mission, key_issues, focus_populations, city, state FROM organizations WHERE id = $1',
      [org_id],
    );
    if (orgResult.rowCount === 0) {
      return NextResponse.json({ error: 'Organization not found' }, { status: 404 });
    }
    const org = orgResult.rows[0];

    // Fetch target politicians
    let targetDetails = '';
    if (target_ids && target_ids.length > 0) {
      const polResult = await query(
        `SELECT name, title, party, state, district, phone, email, website
         FROM politicians WHERE id = ANY($1::uuid[])`,
        [target_ids],
      );
      targetDetails = polResult.rows
        .map(
          (p: any) =>
            `${p.title || ''} ${p.name} (${p.party || '?'}-${p.state || '?'})${p.district ? ` Dist. ${p.district}` : ''}`,
        )
        .join('; ');
    }

    // Fetch congress members for ZIP if provided
    let zipMembers = '';
    if (zip) {
      const districtResult = await query(
        `SELECT p.name, p.title, p.party, p.state, p.district, p.phone, p.email
         FROM politicians p
         JOIN congressional_districts cd ON cd.id = p.district_id
         JOIN district_zips dz ON dz.district_id = cd.id
         WHERE dz.zip = $1
         ORDER BY dz.overlap_pct DESC NULLS LAST
         LIMIT 5`,
        [zip],
      );
      if (districtResult.rowCount && districtResult.rowCount > 0) {
        zipMembers = districtResult.rows
          .map(
            (p: any) =>
              `${p.title || ''} ${p.name} (${p.party || '?'}-${p.state || '?'})${p.district ? ` Dist. ${p.district}` : ''}`,
          )
          .join('; ');
      }
    }

    const prompt = `You are an expert advocacy campaign strategist for nonprofit organizations. Generate a complete advocacy toolkit.

Organization: ${org.name}
Mission: ${org.mission || 'Not specified'}
Key Issues: ${(org.key_issues || []).join(', ') || 'Not specified'}
Focus Populations: ${(org.focus_populations || []).join(', ') || 'Not specified'}
Location: ${[org.city, org.state].filter(Boolean).join(', ') || 'National'}

Campaign Name: ${campaign_name}
Core Issue: ${issue}
${targetDetails ? `Target Representatives: ${targetDetails}` : ''}
${zipMembers ? `Local Representatives (ZIP ${zip}): ${zipMembers}` : ''}

Generate a JSON response with these exact fields:
{
  "talking_points": ["Array of 6-8 concise talking points with data-backed arguments"],
  "email_template": {
    "subject": "Email subject line",
    "body": "Full email body text (3-4 paragraphs, professional, includes specific ask)"
  },
  "social_media_kit": {
    "twitter": ["Array of 3 tweet-length posts (under 280 chars each)"],
    "facebook": "A longer Facebook post (2-3 paragraphs)",
    "instagram": "Instagram caption with hashtags",
    "linkedin": "Professional LinkedIn post (2-3 paragraphs)"
  },
  "call_script": "A phone call script for contacting representatives (informal but informed)",
  "key_facts": ["Array of 4-5 key statistics or facts about this issue"]
}

Return ONLY valid JSON, no markdown formatting.`;

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

    if (!geminiResponse.ok) {
      const errText = await geminiResponse.text();
      console.error('[agents/advocacy/generate] Gemini error:', errText);
      return NextResponse.json({ error: 'AI generation failed' }, { status: 502 });
    }

    const geminiData = await geminiResponse.json();
    const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    // Parse JSON from response
    const jsonMatch = rawText.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
    let parsed;
    try {
      parsed = JSON.parse(jsonMatch);
    } catch {
      parsed = {
        talking_points: [rawText.slice(0, 200)],
        email_template: { subject: `Advocacy: ${campaign_name}`, body: rawText },
        social_media_kit: {
          twitter: [`Support ${campaign_name}! #advocacy`],
          facebook: rawText.slice(0, 500),
          instagram: `Support ${campaign_name}! #advocacy #change`,
          linkedin: rawText.slice(0, 500),
        },
        call_script: 'Please call your representative about this important issue.',
        key_facts: [],
      };
    }

    return NextResponse.json({
      ...parsed,
      org_name: org.name,
      campaign_name,
      issue,
      zip: zip || null,
      target_details: targetDetails || null,
    });
  } catch (err) {
    console.error('[agents/advocacy/generate] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}