← back to Norma

app/api/petitions/[id]/route.ts

184 lines

import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';

type RouteContext = { params: Promise<{ id: string }> };

/**
 * GET /api/petitions/[id]
 * Return a single petition by ID.
 */
export async function GET(request: NextRequest, context: RouteContext) {
  const auth = requireRole(request, 'admin', 'staff', 'pulse');
  if (auth instanceof NextResponse) return auth;

  const { id } = await context.params;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const result = await query(
      `SELECT * FROM petitions WHERE id = $1 AND org_id = $2`,
      [id, orgId]
    );

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

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

/**
 * PATCH /api/petitions/[id]
 * Update petition fields.
 * When setting is_featured=true, all other petitions are set to is_featured=false
 * (only one featured petition at a time).
 */
export async function PATCH(request: NextRequest, context: RouteContext) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await context.params;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const body = await request.json();
    const allowedFields = [
      'title', 'url', 'description', 'status', 'signature_count',
      'signature_goal', 'target', 'category', 'tags', 'ai_suggestion',
      'ai_urgency', 'is_featured', 'platform', 'last_checked_at',
      'allow_signatures', 'email_campaign_enabled',
    ];

    const setClauses: string[] = [];
    const values: unknown[] = [];
    let paramIndex = 1;

    for (const field of allowedFields) {
      if (field in body) {
        setClauses.push(`${field} = $${paramIndex}`);
        values.push(body[field]);
        paramIndex++;
      }
    }

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

    // If setting is_featured=true, use a transaction to unfeature all others first
    if (body.is_featured === true) {
      const client = await getClient();
      try {
        await client.query('BEGIN');

        // Unfeature all other petitions within this org
        await client.query(
          `UPDATE petitions SET is_featured = false WHERE is_featured = true AND id != $1 AND org_id = $2`,
          [id, orgId]
        );

        // Apply the update
        values.push(id);
        values.push(orgId);
        const result = await client.query(
          `UPDATE petitions SET ${setClauses.join(', ')} WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} RETURNING *`,
          values
        );

        if (result.rowCount === 0) {
          await client.query('ROLLBACK');
          return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
        }

        await client.query('COMMIT');

        const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
        await auditLog(
          'petition.updated',
          'petition',
          id,
          { fields: Object.keys(body).filter((k) => allowedFields.includes(k)), is_featured: true },
          ip
        );

        return NextResponse.json({ petition: result.rows[0] });
      } catch (err) {
        await client.query('ROLLBACK');
        throw err;
      } finally {
        client.release();
      }
    }

    // Standard (non-featured) update
    values.push(id);
    values.push(orgId);
    const result = await query(
      `UPDATE petitions SET ${setClauses.join(', ')} WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} RETURNING *`,
      values
    );

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

    const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
    await auditLog(
      'petition.updated',
      'petition',
      id,
      { fields: Object.keys(body).filter((k) => allowedFields.includes(k)) },
      ip
    );

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

/**
 * DELETE /api/petitions/[id]
 * Delete a petition. Drafts referencing it will have petition_id set to NULL (ON DELETE SET NULL).
 */
export async function DELETE(request: NextRequest, context: RouteContext) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await context.params;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const result = await query(
      `DELETE FROM petitions WHERE id = $1 AND org_id = $2 RETURNING id, title, url`,
      [id, orgId]
    );

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

    const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
    await auditLog(
      'petition.deleted',
      'petition',
      id,
      { title: result.rows[0].title, url: result.rows[0].url },
      ip
    );

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