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