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