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