← back to Norma

app/api/pulse/petitions/route.ts

231 lines

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

/**
 * GET /api/pulse/petitions
 * PUBLIC API — No auth required.
 * Returns active petitions for the Pulse of America public site.
 *
 * Query params:
 *   ?limit=12       — max results (default 12, max 100)
 *   ?offset=0       — pagination offset
 *   ?category=      — filter by category
 *   ?search=        — search title/description
 *   ?sort=newest|signatures|trending  — sort order
 */
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);

    const conditions: string[] = ["status = 'active'"];
    const values: unknown[] = [];
    let paramIndex = 1;

    // Category filter
    const category = searchParams.get('category');
    if (category) {
      conditions.push(`category = $${paramIndex}`);
      values.push(category);
      paramIndex++;
    }

    // Search filter
    const search = searchParams.get('search');
    if (search) {
      conditions.push(
        `(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
      );
      values.push(`%${search}%`);
      paramIndex++;
    }

    // Pagination
    let limit = parseInt(searchParams.get('limit') || '12', 10);
    if (isNaN(limit) || limit < 1) limit = 12;
    if (limit > 100) limit = 100;

    let offset = parseInt(searchParams.get('offset') || '0', 10);
    if (isNaN(offset) || offset < 0) offset = 0;

    const whereClause = conditions.join(' AND ');

    // Count total matching
    const countResult = await query(
      `SELECT COUNT(*)::int AS total FROM petitions WHERE ${whereClause}`,
      values
    );
    const total = countResult.rows[0]?.total ?? 0;

    // Sort order
    const sort = searchParams.get('sort') || 'featured';
    let orderClause: string;
    switch (sort) {
      case 'newest':
        orderClause = 'created_at DESC';
        break;
      case 'signatures':
        orderClause = 'COALESCE(signature_count, 0) DESC, created_at DESC';
        break;
      case 'trending':
        orderClause = 'COALESCE(ai_urgency, 0) DESC, COALESCE(signature_count, 0) DESC, created_at DESC';
        break;
      default:
        orderClause = 'is_featured DESC, COALESCE(signature_count, 0) DESC, created_at DESC';
    }

    const result = await query(
      `SELECT id, title, COALESCE(body, description) AS body, description, target, category,
              tags, signature_count, signature_goal, is_featured, talking_points, created_at
       FROM petitions
       WHERE ${whereClause}
       ORDER BY ${orderClause}
       LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
      [...values, limit, offset]
    );

    return NextResponse.json({
      petitions: result.rows,
      total,
      limit,
      offset,
    });
  } catch (err) {
    console.error('[api/pulse/petitions] GET error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to fetch petitions' },
      { status: 500 }
    );
  }
}

/**
 * POST /api/pulse/petitions
 * PUBLIC API — No auth required.
 * Create a new petition from the public Pulse of America site.
 *
 * Body: { title, body?, target?, category?, tags?, signature_goal? }
 *
 * New petitions are created with status='active', platform='custom',
 * and allow_signatures=true so visitors can sign immediately.
 */
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    // Validate required fields
    if (!body.title || typeof body.title !== 'string' || body.title.trim().length < 5) {
      return NextResponse.json(
        { error: 'Title is required and must be at least 5 characters.' },
        { status: 400 }
      );
    }

    if (body.title.trim().length > 300) {
      return NextResponse.json(
        { error: 'Title must be 300 characters or less.' },
        { status: 400 }
      );
    }

    // Category
    const validCategories = [
      'debt_cancellation', 'consumer_protection', 'voting_rights', 'legal',
      'trending', 'education', 'environment', 'healthcare', 'other',
    ];
    const category = validCategories.includes(body.category)
      ? body.category
      : 'other';

    // Tags
    let tags: string[] = [];
    if (Array.isArray(body.tags)) {
      tags = body.tags.map((t: string) => String(t).trim()).filter(Boolean).slice(0, 10);
    } else if (typeof body.tags === 'string' && body.tags.trim()) {
      tags = body.tags.split(',').map((t: string) => t.trim()).filter(Boolean).slice(0, 10);
    }

    // Signature goal
    let signatureGoal = 10000;
    if (body.signature_goal) {
      const parsed = parseInt(String(body.signature_goal), 10);
      if (!isNaN(parsed) && parsed >= 100 && parsed <= 10000000) {
        signatureGoal = parsed;
      }
    }

    const trimmedTitle = body.title.trim();
    const petitionBody = (body.body || '').trim() || null;
    const target = (body.target || '').trim() || null;

    // Dedup window: if a same-title active petition was created in the last
    // 30 minutes, treat this as the same request and MERGE missing fields
    // rather than inserting a duplicate row. Catches both rapid double-clicks
    // (rows identical, no-op merge) and the "publish-empty then publish-filled"
    // pattern (stub gets filled in by the second submission).
    const recent = await query(
      `SELECT id, title, body, description, target, category, tags,
              signature_count, signature_goal, is_featured, created_at
       FROM petitions
       WHERE LOWER(title) = LOWER($1)
         AND status = 'active'
         AND created_at > NOW() - INTERVAL '30 minutes'
       ORDER BY created_at DESC
       LIMIT 1`,
      [trimmedTitle]
    );

    if (recent.rows.length > 0) {
      const existingId = recent.rows[0].id;
      const merged = await query(
        `UPDATE petitions
            SET body        = COALESCE(NULLIF(body, ''), $2),
                description = COALESCE(NULLIF(description, ''), $2),
                target      = COALESCE(NULLIF(target, ''), $3),
                category    = CASE WHEN category = 'other' THEN $4 ELSE category END,
                tags        = CASE WHEN COALESCE(array_length(tags, 1), 0) = 0 THEN $5 ELSE tags END,
                signature_goal = COALESCE(signature_goal, $6),
                updated_at  = NOW()
          WHERE id = $1
          RETURNING id, title, body, description, target, category, tags,
                    signature_count, signature_goal, is_featured, created_at`,
        [existingId, petitionBody, target, category, tags, signatureGoal]
      );
      return NextResponse.json(
        { petition: merged.rows[0], idempotent: true },
        { status: 200 }
      );
    }

    const result = await query(
      `INSERT INTO petitions
         (title, url, platform, description, body, target, category, tags,
          signature_count, signature_goal, status, allow_signatures)
       VALUES ($1, 'pending', 'custom', $2, $2, $3, $4, $5, 0, $6, 'active', true)
       RETURNING id, title, body, description, target, category, tags,
                 signature_count, signature_goal, is_featured, created_at`,
      [
        trimmedTitle,
        petitionBody,
        target,
        category,
        tags,
        signatureGoal,
      ]
    );

    const petition = result.rows[0];

    // Auto-generate URL
    const petitionUrl = `/pulse/petition/${petition.id}`;
    await query(`UPDATE petitions SET url = $1 WHERE id = $2`, [petitionUrl, petition.id]);

    return NextResponse.json({ petition }, { status: 201 });
  } catch (err) {
    console.error('[api/pulse/petitions] POST error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to create petition' },
      { status: 500 }
    );
  }
}