← back to Patty

app/api/petitions/route.ts

233 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';
import { sanitizePetitionBody, bodyHtmlTooLarge } from '@/lib/sanitize';

export async function GET(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const url = new URL(request.url);
  const status = url.searchParams.get('status');
  const search = url.searchParams.get('search');

  let sql = `SELECT * FROM petitions`;
  const params: unknown[] = [];
  const conditions: string[] = [];

  if (status && status !== 'all') {
    params.push(status);
    conditions.push(`status = $${params.length}`);
  }

  if (search) {
    params.push(`%${search}%`);
    conditions.push(`(title ILIKE $${params.length} OR target ILIKE $${params.length})`);
  }

  if (conditions.length > 0) {
    sql += ` WHERE ` + conditions.join(' AND ');
  }

  sql += ` ORDER BY created_at DESC`;

  try {
    const result = await query(sql, params);

    // Get status counts
    const countsResult = await query(
      `SELECT status, COUNT(*)::int as count FROM petitions GROUP BY status`
    );
    const statusCounts: Record<string, number> = { draft: 0, active: 0, paused: 0, closed: 0, delivered: 0 };
    for (const row of countsResult.rows) {
      statusCounts[row.status] = row.count;
    }
    statusCounts.all = Object.values(statusCounts).reduce((a, b) => a + b, 0);

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

export async function POST(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const body = await request.json();
    const { title, summary, body_html, body_text, target, target_emails, category, tags: rawTags, signature_goal, org_type, status } = body;

    // Normalize tags: accept string ("a, b, c"), array, or null
    let tags: string[] | null = null;
    if (Array.isArray(rawTags)) {
      tags = rawTags;
    } else if (typeof rawTags === 'string' && rawTags.trim()) {
      tags = rawTags.split(',').map((t: string) => t.trim()).filter(Boolean);
    }

    if (!title || !body_html) {
      return NextResponse.json({ error: 'Title and body_html are required' }, { status: 400 });
    }

    // 2026-05-05 (P1 architect-review): boundary sanitize at storage. Render
    // ALSO sanitizes (defense-in-depth) but the database is the trust boundary.
    if (bodyHtmlTooLarge(body_html)) {
      return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
    }
    const cleanBodyHtml = sanitizePetitionBody(body_html);

    // Generate slug from title
    let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
    // Check for duplicate slug
    const existing = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
    if (existing.rows.length > 0) {
      slug = `${slug}-${Date.now().toString(36)}`;
    }

    // Get user_id from DB
    const userResult = await query(`SELECT id FROM users LIMIT 1`);
    const userId = userResult.rows[0]?.id;
    if (!userId) {
      return NextResponse.json({ error: 'No user found' }, { status: 500 });
    }

    const result = await query(
      `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, target_emails, category, tags, signature_goal, org_type, status)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
       RETURNING *`,
      [
        userId,
        title,
        slug,
        summary || null,
        cleanBodyHtml,
        body_text || null,
        target || null,
        target_emails || null,
        category || null,
        tags || null,
        signature_goal || 100,
        org_type || null,
        status || 'draft',
      ]
    );

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

export async function PATCH(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const body = await request.json();
    const { id, ...updates } = body;

    if (!id) {
      return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
    }

    const allowedFields = ['title', 'summary', 'body_html', 'body_text', 'target', 'target_emails', 'category', 'tags', 'signature_goal', 'org_type', 'status', 'image_url', 'compliance_flags'];
    const setClauses: string[] = [];
    const params: unknown[] = [];

    for (const [key, value] of Object.entries(updates)) {
      if (allowedFields.includes(key)) {
        let normalizedValue = value;
        // Normalize tags: text[] column needs a real array, not a comma string
        if (key === 'tags') {
          if (Array.isArray(value)) {
            normalizedValue = value.map((t: string) => String(t).trim()).filter(Boolean);
          } else if (typeof value === 'string' && value.trim()) {
            normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
          } else {
            normalizedValue = null;
          }
        }
        // Normalize target_emails similarly
        if (key === 'target_emails') {
          if (Array.isArray(value)) {
            normalizedValue = value;
          } else if (typeof value === 'string' && value.trim()) {
            normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
          } else {
            normalizedValue = null;
          }
        }
        // 2026-05-05 (P1): boundary sanitize body_html on PATCH too.
        if (key === 'body_html') {
          if (bodyHtmlTooLarge(value)) {
            return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
          }
          normalizedValue = sanitizePetitionBody(value as string);
        }
        params.push(normalizedValue);
        setClauses.push(`${key} = $${params.length}`);
      }
    }

    if (setClauses.length === 0) {
      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
    }

    // When status changes to "active", auto-generate public URL
    if (updates.status === 'active') {
      // Get the petition's slug first
      const petitionResult = await query(`SELECT slug FROM petitions WHERE id = $1`, [id]);
      if (petitionResult.rows.length > 0) {
        const slug = petitionResult.rows[0].slug;
        const publicUrl = `http://45.61.58.125:7460/petitions/${slug}`;
        params.push(publicUrl);
        setClauses.push(`external_url = $${params.length}`);
      }
    }

    params.push(id);
    const sql = `UPDATE petitions SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;

    const result = await query(sql, params);
    if (result.rows.length === 0) {
      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
    }

    return NextResponse.json({ row: result.rows[0] });
  } catch (err) {
    console.error('[api/petitions] PATCH error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to update petition' }, { status: 500 });
  }
}

export async function DELETE(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const url = new URL(request.url);
    const id = url.searchParams.get('id');

    if (!id) {
      return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
    }

    const result = await query(
      `UPDATE petitions SET status = 'closed' WHERE id = $1 RETURNING *`,
      [id]
    );

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

    return NextResponse.json({ row: result.rows[0] });
  } catch (err) {
    console.error('[api/petitions] DELETE error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to close petition' }, { status: 500 });
  }
}