← back to Norma

app/api/geo/moveon/route.ts

311 lines

/**
 * POST /api/geo/moveon — Parse MoveOn petition emails
 * GET  /api/geo/moveon — List all imported petitions
 *
 * POST accepts raw email text body and parses out:
 *   - Petition title
 *   - Signature count
 *   - Signature goal
 *   - Target (e.g., "To: Senator Smith")
 *   - Petition URL (sign.moveon.org links)
 *   - Description
 *
 * UPSERTs into moveon_petitions on petition_id (derived from URL).
 * Requires authentication.
 */

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

/* ═══════════════════════════════════════════════════════════════════════════
   POST: Parse and import MoveOn petition email
   ═══════════════════════════════════════════════════════════════════════════ */

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

  let body: { email_text?: string; subject?: string };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: 'Request body must be valid JSON with an email_text field' },
      { status: 400 },
    );
  }

  const emailText = body.email_text;
  const subject = body.subject || '';

  if (!emailText || typeof emailText !== 'string') {
    return NextResponse.json(
      { error: 'email_text field is required and must be a string' },
      { status: 400 },
    );
  }

  try {
    const parsed = parseMoveOnEmail(emailText, subject);

    if (!parsed.title) {
      return NextResponse.json(
        {
          success: false,
          error: 'Could not extract petition title from email text',
          parsed,
        },
        { status: 422 },
      );
    }

    // Derive a petition_id from URL or title hash
    const petitionId = parsed.url
      ? extractPetitionId(parsed.url)
      : `manual-${hashString(parsed.title)}`;

    // UPSERT into moveon_petitions
    const { rows } = await query(
      `INSERT INTO moveon_petitions (
         petition_id, title, target, signature_count, signature_goal,
         description, url, raw_email, imported_at
       ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
       ON CONFLICT (petition_id) DO UPDATE SET
         title = EXCLUDED.title,
         target = COALESCE(EXCLUDED.target, moveon_petitions.target),
         signature_count = COALESCE(EXCLUDED.signature_count, moveon_petitions.signature_count),
         signature_goal = COALESCE(EXCLUDED.signature_goal, moveon_petitions.signature_goal),
         description = COALESCE(EXCLUDED.description, moveon_petitions.description),
         url = COALESCE(EXCLUDED.url, moveon_petitions.url),
         raw_email = EXCLUDED.raw_email,
         imported_at = NOW()
       RETURNING id, petition_id, title, signature_count, signature_goal`,
      [
        petitionId,
        parsed.title,
        parsed.target,
        parsed.signatureCount,
        parsed.signatureGoal,
        parsed.description,
        parsed.url,
        emailText,
      ],
    );

    return NextResponse.json({
      success: true,
      parsed,
      petition_id: petitionId,
      record: rows[0],
    });
  } catch (err) {
    console.error('[geo/moveon] POST error:', (err as Error).message);
    return NextResponse.json(
      { error: `Failed to parse/import petition: ${(err as Error).message}` },
      { status: 500 },
    );
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   GET: List all imported petitions
   ═══════════════════════════════════════════════════════════════════════════ */

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

  try {
    const { rows } = await query(
      `SELECT
         id, petition_id, title, target, signature_count, signature_goal,
         description, url, creator, created_date, topics, states,
         zip_codes, imported_at, created_at
       FROM moveon_petitions
       ORDER BY imported_at DESC`,
    );

    return NextResponse.json({
      count: rows.length,
      petitions: rows,
    });
  } catch (err) {
    console.error('[geo/moveon] GET error:', (err as Error).message);
    return NextResponse.json(
      { error: `Failed to fetch petitions: ${(err as Error).message}` },
      { status: 500 },
    );
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   Email Parsing Utilities
   ═══════════════════════════════════════════════════════════════════════════ */

interface ParsedPetition {
  title: string | null;
  signatureCount: number | null;
  signatureGoal: number | null;
  target: string | null;
  url: string | null;
  description: string | null;
}

function parseMoveOnEmail(emailText: string, subject: string): ParsedPetition {
  const result: ParsedPetition = {
    title: null,
    signatureCount: null,
    signatureGoal: null,
    target: null,
    url: null,
    description: null,
  };

  const text = emailText.trim();

  // ── Extract petition URL ──
  // Look for sign.moveon.org links
  const urlPatterns = [
    /https?:\/\/sign\.moveon\.org\/[^\s"<>\])+]+/gi,
    /https?:\/\/(?:www\.)?moveon\.org\/[^\s"<>\])+]*sign[^\s"<>\])+]*/gi,
    /https?:\/\/act\.moveon\.org\/[^\s"<>\])+]+/gi,
    /https?:\/\/petitions\.moveon\.org\/[^\s"<>\])+]+/gi,
  ];

  for (const pattern of urlPatterns) {
    const match = text.match(pattern);
    if (match) {
      // Clean trailing punctuation
      result.url = match[0].replace(/[.,;:!?)]+$/, '');
      break;
    }
  }

  // ── Extract signature count ──
  // Patterns: "XX,XXX signatures", "XX,XXX have signed", "XX,XXX people have signed"
  const sigCountPatterns = [
    /([0-9,]+)\s+(?:people\s+)?(?:have\s+)?sign(?:ed|atures)/i,
    /(?:already|nearly|over|more\s+than)\s+([0-9,]+)\s+(?:people\s+)?(?:have\s+)?sign/i,
    /([0-9,]+)\s+supporters/i,
    /([0-9,]+)\s+(?:of\s+[0-9,]+\s+)?signatures?\s+(?:collected|gathered|so\s+far)/i,
  ];

  for (const pattern of sigCountPatterns) {
    const match = text.match(pattern);
    if (match) {
      result.signatureCount = parseNumberWithCommas(match[1]);
      break;
    }
  }

  // ── Extract signature goal ──
  // Patterns: "goal: XX,XXX", "XX,XXX needed", "goal of XX,XXX"
  const goalPatterns = [
    /goal[:\s]+(?:of\s+)?([0-9,]+)/i,
    /([0-9,]+)\s+(?:signatures?\s+)?needed/i,
    /(?:need|reaching)\s+([0-9,]+)\s+(?:signatures?|supporters?)/i,
    /([0-9,]+)\s+(?:signature\s+)?goal/i,
    /out\s+of\s+([0-9,]+)/i,
    /([0-9,]+)\s+of\s+([0-9,]+)\s+signatures/i,
  ];

  for (const pattern of goalPatterns) {
    const match = text.match(pattern);
    if (match) {
      // For "X of Y signatures" pattern, goal is the second group
      if (match[2]) {
        result.signatureGoal = parseNumberWithCommas(match[2]);
        // Also use match[1] as signature count if we didn't find one
        if (result.signatureCount === null) {
          result.signatureCount = parseNumberWithCommas(match[1]);
        }
      } else {
        result.signatureGoal = parseNumberWithCommas(match[1]);
      }
      break;
    }
  }

  // ── Extract target ──
  // Patterns: "To:", "Dear", "Petition to"
  const targetPatterns = [
    /(?:^|\n)\s*To:\s*(.+?)(?:\n|$)/im,
    /(?:^|\n)\s*Dear\s+(.+?)(?:[,:\n])/im,
    /(?:petition|letter)\s+to\s+(.+?)(?:[.!\n])/im,
    /(?:tell|urge|ask)\s+(.+?)\s+(?:to|that|:)/im,
  ];

  for (const pattern of targetPatterns) {
    const match = text.match(pattern);
    if (match) {
      result.target = match[1].trim().substring(0, 200);
      break;
    }
  }

  // ── Extract title ──
  // Priority: subject line > first bold/heading-like line > first paragraph
  if (subject && subject.trim()) {
    // Clean common email prefixes
    result.title = subject
      .replace(/^(?:re:|fw:|fwd:)\s*/gi, '')
      .replace(/^\[.*?\]\s*/, '')
      .trim();
  }

  if (!result.title) {
    // Look for heading-like patterns (all caps lines, lines ending with ! or ?)
    const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 10);
    for (const line of lines.slice(0, 10)) {
      // Skip typical email metadata
      if (/^(from|to|date|subject|cc|bcc):/i.test(line)) continue;
      // Skip URL-only lines
      if (/^https?:\/\//i.test(line)) continue;

      // First substantial non-metadata line is likely the title
      result.title = line.substring(0, 200);
      break;
    }
  }

  // ── Extract description ──
  // First substantial paragraph after title/metadata
  const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 50);
  if (paragraphs.length > 0) {
    // Skip the first paragraph if it looks like a greeting
    const startIdx = paragraphs[0].match(/^(?:dear|hi|hello|friend)/i) ? 1 : 0;
    if (paragraphs[startIdx]) {
      result.description = paragraphs[startIdx].trim().substring(0, 1000);
    }
  }

  return result;
}

function parseNumberWithCommas(str: string): number | null {
  const cleaned = str.replace(/,/g, '');
  const num = parseInt(cleaned, 10);
  return isNaN(num) ? null : num;
}

function extractPetitionId(url: string): string {
  // Try to extract a slug or ID from the MoveOn URL
  const match = url.match(/\/(?:sign|petitions?)\/([^/?#]+)/i);
  if (match) {
    return `moveon-${match[1]}`;
  }
  // Fallback: hash the URL
  return `moveon-${hashString(url)}`;
}

function hashString(str: string): string {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash; // Convert to 32-bit integer
  }
  return Math.abs(hash).toString(36);
}