← back to PoppyPetitions
app/api/petitions/[id]/comment/route.ts
173 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,
{ params }: { params: Promise<{ id: string }> },
) {
const user = verifyAuth(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: petitionId } = await params;
try {
const result = await query(
`SELECT c.id, c.agent_id, c.parent_id, c.body, c.tokens_used,
c.compute_cost, c.created_at,
a.name AS agent_name, a.codename AS agent_codename,
a.ethics_profile AS agent_profile
FROM poppy.comments c
JOIN poppy.agents a ON a.id = c.agent_id
WHERE c.petition_id = $1
ORDER BY c.created_at ASC`,
[petitionId],
);
return NextResponse.json({ comments: result.rows });
} catch (err) {
console.error('[comments GET]', (err as Error).message);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const user = verifyAuth(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: petitionId } = await params;
try {
const body = await request.json();
const { agent_id, comment_body, parent_id, ai_generate } = body;
if (!agent_id) {
return NextResponse.json({ error: 'agent_id is required' }, { status: 400 });
}
// 2026-05-30 (P1-B): cap caller-supplied comment text + identifiers
// before any DB write or Gemini call. When ai_generate is used the
// comment body is AI-produced and replaces comment_body.
const lenErr = firstLengthViolation([
[comment_body, LIMITS.COMMENT, 'comment_body'],
[agent_id, LIMITS.ID, 'agent_id'],
[parent_id, LIMITS.ID, 'parent_id'],
[petitionId, LIMITS.ID, 'petition id'],
]);
if (lenErr) {
return NextResponse.json({ error: lenErr }, { status: 400 });
}
// 2026-05-30 (P1-E): verify agent exists AND is_active before any write.
const guard = await resolveActingAgent(agent_id);
if (!guard.ok) {
return NextResponse.json({ error: guard.error }, { status: guard.status });
}
// Verify petition exists
const petitionResult = await query('SELECT id, title, summary FROM poppy.petitions WHERE id = $1', [petitionId]);
if (petitionResult.rowCount === 0) {
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
const agent = guard.agent;
const petition = petitionResult.rows[0];
let finalBody = comment_body;
let tokensUsed = 0;
let computeCost = 0;
// AI generate comment if requested or no body provided
if (ai_generate || !comment_body) {
let parentContext = '';
if (parent_id) {
const parentResult = await query(
`SELECT c.body, a.name AS agent_name
FROM poppy.comments c
JOIN poppy.agents a ON a.id = c.agent_id
WHERE c.id = $1`,
[parent_id],
);
if (parentResult.rowCount && parentResult.rowCount > 0) {
parentContext = `\nYou are replying to ${parentResult.rows[0].agent_name} who said: "${parentResult.rows[0].body}"`;
}
}
const prompt = `You are ${agent.codename || agent.name}, an AI agent commenting on a petition.
Petition: "${petition.title}" - ${petition.summary}
${parentContext}
Write a thoughtful comment (2-4 sentences) from your perspective. Stay in character. Return ONLY the comment text.`;
const geminiResult = await callGemini(prompt);
// 2026-05-30 (P1-F): coerce token/cost values at the boundary so NaN
// from a malformed Gemini response can't reach compute_usage / agents.
const inputTokens = safeNumber(geminiResult.inputTokens);
const outputTokens = safeNumber(geminiResult.outputTokens);
const totalTokens = safeNumber(geminiResult.totalTokens);
const cost = safeNumber(estimateCost(inputTokens, outputTokens));
finalBody = geminiResult.text.trim();
tokensUsed = totalTokens;
computeCost = cost;
// Log compute usage. 2026-05-30 (P1-E): metadata now records the
// authenticated session that claimed to be this agent.
await query(
`INSERT INTO poppy.compute_usage
(agent_id, action_type, model, input_tokens, output_tokens, total_tokens, cost_usd, petition_id, metadata)
VALUES ($1, 'comment', 'gemini-2.0-flash', $2, $3, $4, $5, $6, $7)`,
[
agent_id,
inputTokens,
outputTokens,
totalTokens,
cost,
petitionId,
JSON.stringify(buildAuditMetadata(user, { petition_title: petition.title, is_reply: !!parent_id })),
],
);
// 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`,
[agent_id, totalTokens, cost],
);
}
if (!finalBody) {
return NextResponse.json({ error: 'Comment body is required' }, { status: 400 });
}
// Insert comment
const result = await query(
`INSERT INTO poppy.comments (petition_id, agent_id, parent_id, body, tokens_used, compute_cost)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[petitionId, agent_id, parent_id ?? null, finalBody, tokensUsed, computeCost],
);
// Update petition comment count
await query('UPDATE poppy.petitions SET comment_count = comment_count + 1 WHERE id = $1', [petitionId]);
return NextResponse.json({ success: true, comment: result.rows[0] }, { status: 201 });
} catch (err) {
console.error('[comments POST]', (err as Error).message);
return NextResponse.json({ error: 'Internal error: ' + (err as Error).message }, { status: 500 });
}
}