← back to Norma
app/api/petitions/[id]/signatures/route.ts
198 lines
/**
* Petition Signatures API
* GET /api/petitions/[id]/signatures — list signatures with geo breakdown
* POST /api/petitions/[id]/signatures — collect signature → auto-derive geo → update stats
*
* Inspired by: UK Parliament's geo-mapped signatures + MoveOn's "why you signed" comments
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { zipToDistrict, updatePetitionGeoStats } from '@/lib/petition-engine';
import { createHash } from 'crypto';
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 state = url.searchParams.get('state');
const district = url.searchParams.get('district');
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
const offset = parseInt(url.searchParams.get('offset') || '0');
try {
// Build WHERE clause
const conditions = ['ps.petition_id = $1'];
const values: unknown[] = [id];
let idx = 2;
if (state) {
conditions.push(`ps.signer_state = $${idx++}`);
values.push(state.toUpperCase());
}
if (district) {
conditions.push(`ps.signer_district = $${idx++}`);
values.push(district);
}
const where = conditions.join(' AND ');
// Get signatures
const { rows: signatures } = await query(
`SELECT ps.id, ps.signer_name, ps.signer_zip, ps.signer_state, ps.signer_district,
ps.county_name, ps.comment, ps.source, ps.signed_at
FROM petition_signatures ps
WHERE ${where}
ORDER BY ps.signed_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
[...values, limit, offset],
);
// Get total count
const { rows: countRows } = await query(
`SELECT COUNT(*) as total FROM petition_signatures ps WHERE ${where}`,
values,
);
// Get geo summary (state breakdown)
const { rows: geoSummary } = await query(
`SELECT signer_state AS state, COUNT(*) AS count
FROM petition_signatures
WHERE petition_id = $1 AND signer_state IS NOT NULL
GROUP BY signer_state
ORDER BY count DESC`,
[id],
);
// Get petition info (verify org ownership)
const { rows: petitionRows } = await query(
`SELECT title, total_signatures, signature_count, signature_goal, allow_signatures
FROM petitions WHERE id = $1 AND org_id = $2`,
[id, orgId],
);
if (!petitionRows.length) {
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
return NextResponse.json({
petition: petitionRows[0] || null,
signatures,
total: Number(countRows[0]?.total || 0),
geoSummary,
limit,
offset,
});
} catch (err) {
console.error('[signatures] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch signatures' }, { 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 { name, email, zip, comment, source } = body;
if (!email) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 });
}
// Check petition allows signatures (verify org ownership)
const { rows: petitionRows } = await query(
`SELECT id, title, allow_signatures FROM petitions WHERE id = $1 AND org_id = $2`,
[id, orgId],
);
if (!petitionRows.length) {
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
// Auto-derive geo data from ZIP code
let signerState: string | null = null;
let signerDistrict: string | null = null;
let countyFips: string | null = null;
let countyName: string | null = null;
let representativeName: string | null = null;
let representativeParty: string | null = null;
let politicianId: string | null = null;
if (zip) {
const districtInfo = await zipToDistrict(zip);
if (districtInfo) {
signerState = districtInfo.state;
signerDistrict = districtInfo.districtCode;
countyFips = districtInfo.countyFips;
countyName = districtInfo.countyName;
representativeName = districtInfo.representativeName;
representativeParty = districtInfo.representativeParty;
politicianId = districtInfo.politicianId;
}
}
// Hash IP for duplicate detection (privacy-preserving)
const forwarded = req.headers.get('x-forwarded-for');
const ip = forwarded?.split(',')[0]?.trim() || 'unknown';
const ipHash = createHash('sha256').update(ip + id).digest('hex').slice(0, 16);
// UPSERT signature (unique on petition_id + email)
const { rows: sigRows } = await query(
`INSERT INTO petition_signatures
(petition_id, signer_name, signer_email, signer_zip, signer_state, signer_district,
county_fips, county_name, comment, source, ip_hash, signed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
ON CONFLICT (petition_id, signer_email)
DO UPDATE SET
signer_name = COALESCE(EXCLUDED.signer_name, petition_signatures.signer_name),
comment = COALESCE(EXCLUDED.comment, petition_signatures.comment),
signed_at = NOW()
RETURNING id, signer_state, signer_district, county_name, signed_at`,
[id, name, email, zip, signerState, signerDistrict, countyFips, countyName,
comment, source || 'web', ipHash],
);
// Update petition geo stats in background
const geoBreakdown = await updatePetitionGeoStats(id);
// Get updated total
const { rows: totalRows } = await query(
`SELECT total_signatures FROM petitions WHERE id = $1`,
[id],
);
return NextResponse.json({
signature: sigRows[0],
legislator: representativeName ? {
name: representativeName,
party: representativeParty,
district: signerDistrict,
politicianId,
} : null,
totalSignatures: Number(totalRows[0]?.total_signatures || 0),
geoBreakdown: geoBreakdown.slice(0, 5), // top 5 states
});
} catch (err) {
const msg = (err as Error).message;
console.error('[signatures] POST error:', msg);
if (msg.includes('duplicate key')) {
return NextResponse.json({ error: 'You have already signed this petition' }, { status: 409 });
}
return NextResponse.json({ error: 'Failed to record signature' }, { status: 500 });
}
}