← back to Norma
app/api/petitions/route.ts
208 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';
/**
* GET /api/petitions
* List petitions with optional filters:
* ?status= — filter by status (active, closed, paused)
* ?platform= — filter by platform (moveon, actionnetwork, change_org, custom)
* ?category= — filter by category
* ?is_featured= — filter by featured flag (true/false)
* ?search= — full-text search on title/description
* Ordered by is_featured DESC, ai_urgency DESC, created_at DESC.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff', 'pulse');
if (auth instanceof NextResponse) return auth;
try {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const platform = searchParams.get('platform');
const category = searchParams.get('category');
const search = searchParams.get('search');
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (orgId) {
conditions.push(`org_id = $${paramIndex}`);
values.push(orgId);
paramIndex++;
}
if (status) {
conditions.push(`status = $${paramIndex}`);
values.push(status);
paramIndex++;
}
if (platform) {
conditions.push(`platform = $${paramIndex}`);
values.push(platform);
paramIndex++;
}
if (category) {
conditions.push(`category = $${paramIndex}`);
values.push(category);
paramIndex++;
}
const isFeatured = searchParams.get('is_featured');
if (isFeatured !== null && isFeatured !== undefined && isFeatured !== '') {
conditions.push(`is_featured = $${paramIndex}`);
values.push(isFeatured === 'true');
paramIndex++;
}
if (search) {
conditions.push(
`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
);
values.push(`%${search}%`);
paramIndex++;
}
let limit = parseInt(searchParams.get('limit') || '50', 10);
if (isNaN(limit) || limit < 1) limit = 50;
if (limit > 200) limit = 200;
let offset = parseInt(searchParams.get('offset') || '0', 10);
if (isNaN(offset) || offset < 0) offset = 0;
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await query(
`SELECT COUNT(*)::int AS total FROM petitions ${whereClause}`,
values
);
const total = countResult.rows[0]?.total ?? 0;
const result = await query(
`SELECT * FROM petitions
${whereClause}
ORDER BY is_featured DESC, ai_urgency DESC NULLS LAST, created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, limit, offset]
);
return NextResponse.json({ petitions: result.rows, total, limit, offset });
} catch (err) {
console.error('[api/petitions] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch petitions' }, { status: 500 });
}
}
/**
* POST /api/petitions
* Create a new petition.
* Body: { title, description?, target?, category?, tags?, signature_count?,
* signature_goal?, ai_suggestion?, ai_urgency?, status?,
* url?, platform? }
*
* url + platform are optional — if omitted, defaults to 'custom' platform
* and auto-generates a petition-site URL after insert.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const body = await request.json();
if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
return NextResponse.json({ error: 'title is required' }, { status: 400 });
}
const validPlatforms = ['moveon', 'actionnetwork', 'change_org', 'custom'];
const platform = validPlatforms.includes(body.platform) ? body.platform : 'custom';
// Accept any category string — normalize to lowercase
const category = body.category
? String(body.category).toLowerCase().trim()
: 'other';
// Parse tags: accept either an array or a comma-separated string
let tags: string[] = [];
if (Array.isArray(body.tags)) {
tags = body.tags.map((t: string) => String(t).trim()).filter(Boolean);
} else if (typeof body.tags === 'string' && body.tags.trim()) {
tags = body.tags
.split(',')
.map((t: string) => t.trim())
.filter(Boolean);
}
const status = body.status === 'draft' ? 'draft' : 'active';
const url = body.url?.trim() || 'pending'; // placeholder, updated after insert
// Support 'body' field as petition content (stored in both body and description)
const petitionBody = body.body || body.description || null;
const talkingPoints = Array.isArray(body.talking_points)
? body.talking_points.map((t: string) => String(t).trim()).filter(Boolean)
: [];
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const result = await query(
`INSERT INTO petitions
(title, url, platform, description, body, target, category, tags,
signature_count, signature_goal, ai_suggestion, ai_urgency, status,
allow_signatures, talking_points, org_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, true, $14, $15)
RETURNING *`,
[
body.title.trim(),
url,
platform,
petitionBody,
petitionBody,
body.target || null,
category,
tags,
body.signature_count ? Number(body.signature_count) : 0,
body.signature_goal ? Number(body.signature_goal) : 10000,
body.ai_suggestion || null,
body.ai_urgency ? Number(body.ai_urgency) : null,
status,
talkingPoints,
orgId || null,
]
);
const petition = result.rows[0];
// Auto-generate petition-site URL if none was provided
if (!body.url || body.url === 'pending') {
const petitionUrl = `http://45.61.58.125:7401/petition/${petition.id}`;
await query(`UPDATE petitions SET url = $1 WHERE id = $2`, [petitionUrl, petition.id]);
petition.url = petitionUrl;
}
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
undefined;
await auditLog(
'petition.created',
'petition',
petition.id,
{ title: petition.title, platform: petition.platform },
ip
);
return NextResponse.json({ petition }, { status: 201 });
} catch (err) {
console.error('[api/petitions] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create petition' }, { status: 500 });
}
}