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