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