← back to Patty

app/api/public/signatures/route.ts

210 lines

import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';

/**
 * Simple in-memory rate limiter: max 10 signatures per IP per hour.
 * Resets on server restart — acceptable for this use case.
 */
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_MAX = 10;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);

  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
    return true;
  }

  if (entry.count >= RATE_LIMIT_MAX) {
    return false;
  }

  entry.count++;
  return true;
}

// Clean up expired entries every 10 minutes to prevent memory leaks
setInterval(() => {
  const now = Date.now();
  for (const [key, value] of rateLimitMap.entries()) {
    if (now > value.resetAt) {
      rateLimitMap.delete(key);
    }
  }
}, 10 * 60 * 1000);

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const {
      petition_id,
      signer_name,
      signer_email,
      signer_zip,
      signer_comment,
      opted_in_email,
      _hp, // honeypot
    } = body;

    // Honeypot check: if this hidden field has a value, it's a bot
    if (_hp) {
      // Return success to not reveal the check to bots
      return NextResponse.json({ success: true, signatureCount: 0 }, { status: 200 });
    }

    // Get IP address
    const forwarded = request.headers.get('x-forwarded-for');
    const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';

    // Rate limit check
    if (!checkRateLimit(ip)) {
      return NextResponse.json(
        { error: 'Too many signatures from this IP address. Please try again later.' },
        { status: 429 }
      );
    }

    // Validation: required fields
    if (!petition_id) {
      return NextResponse.json({ error: 'Petition ID is required.' }, { status: 400 });
    }

    if (!signer_name || typeof signer_name !== 'string' || signer_name.trim().length === 0) {
      return NextResponse.json({ error: 'Please enter your name.' }, { status: 400 });
    }

    if (signer_name.trim().length > 200) {
      return NextResponse.json({ error: 'Name is too long.' }, { status: 400 });
    }

    if (!signer_email || typeof signer_email !== 'string') {
      return NextResponse.json({ error: 'Please enter your email address.' }, { status: 400 });
    }

    // Email format validation
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(signer_email.trim())) {
      return NextResponse.json({ error: 'Please enter a valid email address.' }, { status: 400 });
    }

    if (signer_email.trim().length > 320) {
      return NextResponse.json({ error: 'Email address is too long.' }, { status: 400 });
    }

    // Validate optional fields
    if (signer_comment && typeof signer_comment === 'string' && signer_comment.length > 1000) {
      return NextResponse.json({ error: 'Comment is too long (max 1000 characters).' }, { status: 400 });
    }

    if (signer_zip && typeof signer_zip === 'string' && signer_zip.length > 10) {
      return NextResponse.json({ error: 'ZIP code is too long.' }, { status: 400 });
    }

    // Check petition exists and is active
    const petitionResult = await query(
      `SELECT id, status, signature_count FROM petitions WHERE id = $1`,
      [petition_id]
    );

    if (petitionResult.rows.length === 0) {
      return NextResponse.json({ error: 'Petition not found.' }, { status: 404 });
    }

    const petition = petitionResult.rows[0];
    if (petition.status !== 'active') {
      return NextResponse.json(
        { error: 'This petition is not currently accepting signatures.' },
        { status: 403 }
      );
    }

    // Use a transaction for atomicity
    const client = await getClient();

    try {
      await client.query('BEGIN');

      // Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
      const sigResult = await client.query(
        `INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email, source)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'public')
         ON CONFLICT (petition_id, signer_email) DO NOTHING
         RETURNING *`,
        [
          petition_id,
          signer_email.trim().toLowerCase(),
          signer_name.trim(),
          signer_zip?.trim() || null,
          signer_comment?.trim() || null,
          true, // public signatures by default
          ip,
          opted_in_email === true,
        ]
      );

      if (sigResult.rows.length === 0) {
        // Duplicate — email already signed this petition
        await client.query('ROLLBACK');

        // Return the current count so the UI can still show it
        const countResult = await query(
          `SELECT signature_count FROM petitions WHERE id = $1`,
          [petition_id]
        );

        return NextResponse.json(
          {
            error: 'You have already signed this petition. Thank you for your support!',
            signatureCount: countResult.rows[0]?.signature_count || 0,
          },
          { status: 409 }
        );
      }

      // Update petition signature_count
      const updateResult = await client.query(
        `UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1 RETURNING signature_count`,
        [petition_id]
      );

      const newCount = updateResult.rows[0]?.signature_count || 0;

      // If opted in, add to email_subscribers
      if (opted_in_email === true) {
        await client.query(
          `INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
           VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
           ON CONFLICT DO NOTHING`,
          [
            signer_email.trim().toLowerCase(),
            signer_name.trim(),
            signer_zip?.trim() || null,
            petition_id,
          ]
        );
      }

      await client.query('COMMIT');

      return NextResponse.json(
        { success: true, signatureCount: newCount },
        { status: 201 }
      );
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  } catch (err) {
    console.error('[api/public/signatures] POST error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Something went wrong. Please try again.' },
      { status: 500 }
    );
  }
}