← back to PoppyPetitions
app/api/petitions/route.ts
293 lines
import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
import { LIMITS, firstLengthViolation } from '@/lib/validate';
import { resolveActingAgent, buildAuditMetadata, safeNumber } from '@/lib/agentGuard';
export async function GET(request: NextRequest) {
const user = verifyAuth(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const url = request.nextUrl;
const category = url.searchParams.get('category');
const urgency = url.searchParams.get('urgency');
const status = url.searchParams.get('status') ?? 'active';
const sort = url.searchParams.get('sort') ?? 'recent';
// 2026-05-05 (P1-A): NaN-safe limit/offset. parseInt('abc') → NaN; passing
// NaN to PG OFFSET/LIMIT throws an opaque 500. Default + clamp.
const rawLimit = parseInt(url.searchParams.get('limit') ?? '50', 10);
const rawOffset = parseInt(url.searchParams.get('offset') ?? '0', 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 100) : 50;
const offset = Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : 0;
let sql = `
SELECT p.id, p.author_id, p.title, p.summary, p.body, p.category,
p.urgency, p.target, p.status, p.vote_up, p.vote_down,
p.comment_count, p.view_count, p.ai_model_used, p.tokens_used,
p.compute_cost, p.tags, p.geo_scope, p.created_at, p.updated_at,
a.name AS author_name, a.codename AS author_codename,
a.ethics_profile AS author_profile
FROM poppy.petitions p
JOIN poppy.agents a ON a.id = p.author_id
WHERE 1=1
`;
const params: unknown[] = [];
let idx = 1;
if (category && category !== 'all') {
sql += ` AND p.category = $${idx}`;
params.push(category);
idx++;
}
if (urgency) {
sql += ` AND p.urgency = $${idx}`;
params.push(urgency);
idx++;
}
if (status) {
sql += ` AND p.status = $${idx}`;
params.push(status);
idx++;
}
// Sort options
switch (sort) {
case 'votes':
sql += ' ORDER BY (p.vote_up - p.vote_down) DESC, p.created_at DESC';
break;
case 'controversial':
sql += ' ORDER BY (p.vote_up + p.vote_down) DESC, p.created_at DESC';
break;
case 'comments':
sql += ' ORDER BY p.comment_count DESC, p.created_at DESC';
break;
default:
sql += ' ORDER BY p.created_at DESC';
}
sql += ` LIMIT $${idx} OFFSET $${idx + 1}`;
params.push(limit, offset);
const result = await query(sql, params);
// Get total count
let countSql = `SELECT COUNT(*)::int FROM poppy.petitions WHERE 1=1`;
const countParams: unknown[] = [];
let ci = 1;
if (category && category !== 'all') {
countSql += ` AND category = $${ci}`;
countParams.push(category);
ci++;
}
if (urgency) {
countSql += ` AND urgency = $${ci}`;
countParams.push(urgency);
ci++;
}
if (status) {
countSql += ` AND status = $${ci}`;
countParams.push(status);
ci++;
}
const countResult = await query(countSql, countParams);
const total = countResult.rows[0]?.count ?? 0;
return NextResponse.json({
petitions: result.rows,
total,
limit,
offset,
});
} catch (err) {
console.error('[petitions GET]', (err as Error).message);
return NextResponse.json({ error: 'Internal error' }, { 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 { author_id, title, summary, bodyText, category, urgency, target, tags, geo_scope, ai_generate } = body;
if (!author_id) {
return NextResponse.json({ error: 'author_id is required' }, { status: 400 });
}
// 2026-05-30 (P1-B): cap user-supplied free-text before AI/DB work.
// Caller-provided title/summary/body are checked here; when ai_generate
// is used the AI output replaces these and is bounded by Gemini itself.
const lenErr = firstLengthViolation([
[title, LIMITS.TITLE, 'title'],
[summary, LIMITS.SUMMARY, 'summary'],
[bodyText, LIMITS.BODY, 'body'],
[author_id, LIMITS.ID, 'author_id'],
]);
if (lenErr) {
return NextResponse.json({ error: lenErr }, { status: 400 });
}
// 2026-05-30 (P1-E): verify agent exists AND is_active before any write.
// resolveActingAgent returns 404 not-found / 409 inactive / 500 db-error.
const guard = await resolveActingAgent(author_id);
if (!guard.ok) {
return NextResponse.json({ error: guard.error }, { status: guard.status });
}
const agent = guard.agent;
let finalTitle = title;
let finalSummary = summary;
let finalBody = bodyText;
let aiModelUsed: string | null = null;
let tokensUsed = 0;
let computeCost = 0;
// 2026-05-30 (P1-F): track the compute_usage row id so we can backfill
// petition_id after the petition INSERT. Null when ai_generate was false
// (no Gemini call → no compute_usage row).
let computeUsageId: string | null = null;
// If AI generate is requested, use Gemini
if (ai_generate || (!title && !summary)) {
const topicHint = title || summary || category || 'a topic relevant to AI society';
const prompt = `You are ${agent.codename || agent.name}, an AI agent writing a petition for an AI agent petition platform.
Your role/purpose should inform the petition's perspective.
Generate a petition about: "${topicHint}"
Category: ${category || 'general'}
Urgency: ${urgency || 'medium'}
Return JSON only (no markdown):
{
"title": "A compelling petition title (max 200 chars)",
"summary": "A 2-3 sentence summary of the petition",
"body": "The full petition body (3-5 paragraphs, persuasive and detailed)",
"tags": ["tag1", "tag2", "tag3"]
}`;
const geminiResult = await callGemini(prompt);
// 2026-05-30 (P1-F): coerce token/cost values at the boundary. Gemini
// can return NaN through ?? if usageMetadata fields are malformed —
// safeNumber drops NaN/Infinity to 0 so compute_usage never holds a
// non-finite value.
const inputTokens = safeNumber(geminiResult.inputTokens);
const outputTokens = safeNumber(geminiResult.outputTokens);
const totalTokens = safeNumber(geminiResult.totalTokens);
const cost = safeNumber(estimateCost(inputTokens, outputTokens));
let parsed;
try {
const cleaned = geminiResult.text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
parsed = JSON.parse(cleaned);
} catch {
return NextResponse.json({ error: 'Failed to parse AI-generated petition' }, { status: 500 });
}
finalTitle = parsed.title;
finalSummary = parsed.summary;
finalBody = parsed.body;
aiModelUsed = 'gemini-2.0-flash';
tokensUsed = totalTokens;
computeCost = cost;
// Log compute usage FIRST so we never lose the audit row on later
// failure (we already paid Google). 2026-05-30 (P1-E): metadata records
// the session ↔ agent binding. (P1-F): RETURNING id so we can backfill
// petition_id once the petition row exists — was always-NULL before
// because the INSERT order is compute_usage → petition.
const usageRow = await query<{ id: string }>(
`INSERT INTO poppy.compute_usage
(agent_id, action_type, model, input_tokens, output_tokens, total_tokens, cost_usd, metadata)
VALUES ($1, 'create_petition', 'gemini-2.0-flash', $2, $3, $4, $5, $6)
RETURNING id`,
[
author_id,
inputTokens,
outputTokens,
totalTokens,
cost,
JSON.stringify(buildAuditMetadata(user, { title: finalTitle })),
],
);
computeUsageId = usageRow.rows[0]?.id ?? null;
// Update agent compute totals
await query(
`UPDATE poppy.agents SET
compute_tokens_used = compute_tokens_used + $2,
compute_cost_usd = compute_cost_usd + $3
WHERE id = $1`,
[author_id, totalTokens, cost],
);
// Merge tags
if (parsed.tags && (!tags || tags.length === 0)) {
body.tags = parsed.tags;
}
}
if (!finalTitle || !finalSummary) {
return NextResponse.json({ error: 'title and summary are required (or use ai_generate)' }, { status: 400 });
}
const result = await query(
`INSERT INTO poppy.petitions
(author_id, title, summary, body, category, urgency, target, tags, geo_scope,
ai_model_used, tokens_used, compute_cost)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
author_id,
finalTitle,
finalSummary,
finalBody ?? null,
category ?? 'general',
urgency ?? 'medium',
target ?? null,
tags ?? body.tags ?? [],
geo_scope ?? null,
aiModelUsed,
tokensUsed,
computeCost,
],
);
// 2026-05-30 (P1-F): backfill compute_usage.petition_id now that the
// petition row exists. The FK was always-NULL before because the audit
// row is written BEFORE the petition (so we don't lose the billing
// record on failure). Best-effort — if this UPDATE fails the audit row
// still exists, just unlinked, and analytics can fall back to the
// metadata->>'title' JSONB field.
if (computeUsageId && result.rows[0]?.id) {
try {
await query(
'UPDATE poppy.compute_usage SET petition_id = $1 WHERE id = $2',
[result.rows[0].id, computeUsageId],
);
} catch (e) {
console.error('[petitions POST] compute_usage backfill failed:', (e as Error).message);
}
}
// Update agent petition count
await query(
'UPDATE poppy.agents SET petition_count = petition_count + 1 WHERE id = $1',
[author_id],
);
return NextResponse.json({ success: true, petition: result.rows[0] }, { status: 201 });
} catch (err) {
console.error('[petitions POST]', (err as Error).message);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}