← back to Norma

app/api/petitions/[id]/campaigns/route.ts

231 lines

/**
 * Email Campaign API
 * GET  /api/petitions/[id]/campaigns — list campaigns for petition
 * POST /api/petitions/[id]/campaigns — generate AI-powered legislator emails
 *
 * Norma's unique differentiator: auto-generated, geo-targeted legislator emails
 * using signature data + Gemini AI. No other petition platform does this.
 */

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getTargetLegislators, generateCampaignEmail } from '@/lib/petition-engine';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(req, 'admin', 'staff', 'pulse');
  if (auth instanceof NextResponse) return auth;

  const { id } = await params;
  const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
  const url = req.nextUrl;
  const status = url.searchParams.get('status');
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);

  try {
    // Verify petition belongs to this org
    const { rowCount: petitionExists } = await query(
      `SELECT 1 FROM petitions WHERE id = $1 AND org_id = $2`,
      [id, orgId],
    );
    if (!petitionExists) {
      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
    }

    const conditions = ['ec.petition_id = $1'];
    const values: unknown[] = [id];
    let idx = 2;

    if (status) {
      conditions.push(`ec.status = $${idx++}`);
      values.push(status);
    }

    const { rows: campaigns } = await query(
      `SELECT ec.id, ec.district_code, ec.target_name, ec.target_email, ec.target_title,
              ec.subject, ec.body_html, ec.body_text, ec.geo_state, ec.geo_district,
              ec.constituent_count, ec.status, ec.sent_at, ec.sent_count,
              ec.open_count, ec.click_count, ec.ai_generated, ec.ai_model,
              ec.ai_confidence, ec.talking_points, ec.created_at, ec.updated_at,
              p.name AS politician_name, p.party AS politician_party,
              p.committees AS politician_committees
       FROM email_campaigns ec
       LEFT JOIN politicians p ON p.id = ec.politician_id
       WHERE ${conditions.join(' AND ')}
       ORDER BY ec.constituent_count DESC, ec.created_at DESC
       LIMIT $${idx++}`,
      [...values, limit],
    );

    // Get campaign stats summary
    const { rows: stats } = await query(
      `SELECT
         COUNT(*) AS total,
         COUNT(CASE WHEN status = 'draft' THEN 1 END) AS drafts,
         COUNT(CASE WHEN status = 'approved' THEN 1 END) AS approved,
         COUNT(CASE WHEN status = 'sent' THEN 1 END) AS sent,
         SUM(constituent_count) AS total_constituents,
         SUM(sent_count) AS total_sent,
         SUM(open_count) AS total_opens,
         SUM(click_count) AS total_clicks
       FROM email_campaigns
       WHERE petition_id = $1`,
      [id],
    );

    return NextResponse.json({
      campaigns,
      stats: stats[0] ? {
        total: Number(stats[0].total),
        drafts: Number(stats[0].drafts),
        approved: Number(stats[0].approved),
        sent: Number(stats[0].sent),
        totalConstituents: Number(stats[0].total_constituents || 0),
        totalSent: Number(stats[0].total_sent || 0),
        totalOpens: Number(stats[0].total_opens || 0),
        totalClicks: Number(stats[0].total_clicks || 0),
      } : null,
    });
  } catch (err) {
    console.error('[campaigns] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch campaigns' }, { status: 500 });
  }
}

export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(req, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await params;
  const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;

  try {
    const body = await req.json();
    const { action } = body;

    // Get petition data (verify org ownership)
    const { rows: petitionRows } = await query(
      `SELECT id, title, description, category FROM petitions WHERE id = $1 AND org_id = $2`,
      [id, orgId],
    );
    if (!petitionRows.length) {
      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
    }
    const petition = petitionRows[0];

    if (action === 'generate') {
      // Auto-generate email campaigns for top legislators
      const topCount = body.topCount || 5;
      const legislators = await getTargetLegislators(id, topCount);

      if (!legislators.length) {
        return NextResponse.json({
          error: 'No signature data available. Collect signatures first to identify target legislators.',
        }, { status: 400 });
      }

      const generated = [];

      for (const leg of legislators) {
        if (!leg.legislatorName) continue;

        // Generate AI email
        const email = await generateCampaignEmail(
          { title: petition.title, description: petition.description, category: petition.category },
          {
            name: leg.legislatorName,
            title: leg.politicianTitle,
            party: leg.legislatorParty,
            state: leg.state,
            district: leg.districtCode,
            committees: leg.committees,
            positionStudentDebt: leg.positionStudentDebt,
          },
          leg.signatureCount,
        );

        // Insert campaign
        const { rows: campRows } = await query(
          `INSERT INTO email_campaigns
             (petition_id, politician_id, district_code, target_name, target_email,
              target_title, subject, body_html, body_text, geo_state, geo_district,
              constituent_count, status, ai_generated, ai_model, ai_confidence, talking_points)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'draft', true, $13, $14, $15)
           ON CONFLICT DO NOTHING
           RETURNING id, target_name, district_code, subject, constituent_count, ai_confidence`,
          [
            id, leg.politicianId, leg.districtCode, leg.legislatorName,
            leg.politicianEmail, leg.politicianTitle || 'Representative',
            email.subject, email.bodyHtml, email.bodyText,
            leg.state, leg.districtCode, leg.signatureCount,
            email.aiModel, email.aiConfidence, email.talkingPoints,
          ],
        );

        if (campRows.length) {
          generated.push(campRows[0]);
        }
      }

      // Enable email campaigns on the petition
      await query(
        `UPDATE petitions SET email_campaign_enabled = true WHERE id = $1`,
        [id],
      );

      return NextResponse.json({
        message: `Generated ${generated.length} email campaign drafts`,
        campaigns: generated,
        totalLegislatorsAnalyzed: legislators.length,
      });
    }

    if (action === 'approve') {
      // Approve a draft campaign for sending
      const { campaignId } = body;
      if (!campaignId) {
        return NextResponse.json({ error: 'campaignId required' }, { status: 400 });
      }

      const { rowCount } = await query(
        `UPDATE email_campaigns SET status = 'approved', updated_at = NOW()
         WHERE id = $1 AND petition_id = $2 AND status = 'draft'`,
        [campaignId, id],
      );

      return NextResponse.json({
        message: rowCount ? 'Campaign approved' : 'Campaign not found or already processed',
        approved: (rowCount ?? 0) > 0,
      });
    }

    if (action === 'approve-all') {
      // Approve all draft campaigns for this petition
      const { rowCount } = await query(
        `UPDATE email_campaigns SET status = 'approved', updated_at = NOW()
         WHERE petition_id = $1 AND status = 'draft'`,
        [id],
      );

      return NextResponse.json({
        message: `Approved ${rowCount} campaigns`,
        approvedCount: rowCount,
      });
    }

    return NextResponse.json({
      error: 'Unknown action. Use "generate", "approve", or "approve-all".',
    }, { status: 400 });
  } catch (err) {
    console.error('[campaigns] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to process campaign action' }, { status: 500 });
  }
}