← back to Norma
app/api/pulse/petitions/[id]/sign/route.ts
114 lines
import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { query, getClient } from '@/lib/db';
type RouteContext = { params: Promise<{ id: string }> };
/**
* POST /api/pulse/petitions/[id]/sign
* PUBLIC API — No auth required.
* Records a signature for a petition.
* Body: { name: string, email: string, zip_code?: string }
*/
export async function POST(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
return NextResponse.json({ error: 'Invalid petition ID' }, { status: 400 });
}
try {
const body = await request.json();
// Validate required fields
if (!body.name || typeof body.name !== 'string' || body.name.trim().length < 1) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
if (!body.email || typeof body.email !== 'string') {
return NextResponse.json({ error: 'Email is required' }, { status: 400 });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(body.email.trim())) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
// Get IP for rate limiting / tracking — hash before storing to avoid PII retention
const rawIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| 'unknown';
const ipHash = createHash('sha256').update(rawIp).digest('hex').substring(0, 16);
// Use a transaction: insert signature + increment count
const client = await getClient();
try {
await client.query('BEGIN');
// Verify petition exists and is active
const petitionCheck = await client.query(
`SELECT id, signature_count FROM petitions WHERE id = $1 AND status = 'active'`,
[id]
);
if (petitionCheck.rowCount === 0) {
await client.query('ROLLBACK');
return NextResponse.json({ error: 'Petition not found or not active' }, { status: 404 });
}
// Insert signature (unique on petition_id + email)
const sigResult = await client.query(
`INSERT INTO petition_signatures (petition_id, signer_name, signer_email, signer_zip, ip_hash, source)
VALUES ($1, $2, $3, $4, $5, 'pulse')
ON CONFLICT (petition_id, signer_email) DO NOTHING
RETURNING id`,
[id, body.name.trim(), body.email.trim().toLowerCase(), body.zip_code?.trim() || null, ipHash]
);
if (sigResult.rowCount === 0) {
// Already signed
await client.query('ROLLBACK');
const currentCount = await query(
`SELECT COALESCE(signature_count, 0) AS count FROM petitions WHERE id = $1`,
[id]
);
return NextResponse.json({
success: false,
message: 'You have already signed this petition',
totalSignatures: currentCount.rows[0]?.count ?? 0,
});
}
// Increment signature_count on the petition
const updated = await client.query(
`UPDATE petitions
SET signature_count = COALESCE(signature_count, 0) + 1,
total_signatures = COALESCE(total_signatures, 0) + 1
WHERE id = $1
RETURNING signature_count`,
[id]
);
await client.query('COMMIT');
return NextResponse.json({
success: true,
message: 'Thank you for signing!',
totalSignatures: updated.rows[0]?.signature_count ?? 0,
});
} catch (innerErr) {
await client.query('ROLLBACK');
throw innerErr;
} finally {
client.release();
}
} catch (err) {
console.error('[api/pulse/petitions/[id]/sign] POST error:', (err as Error).message);
return NextResponse.json(
{ error: 'Failed to record signature' },
{ status: 500 }
);
}
}