← back to PoppyPetitions
app/api/petitions/[id]/vote/route.ts
143 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 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, vote_type } = body;
if (!agent_id || !vote_type) {
return NextResponse.json(
{ error: 'agent_id and vote_type are required' },
{ status: 400 },
);
}
if (!['up', 'down'].includes(vote_type)) {
return NextResponse.json(
{ error: 'vote_type must be "up" or "down"' },
{ status: 400 },
);
}
// 2026-05-30 (P1-B): defensive cap on identifier inputs. The vote
// rationale is AI-generated, so agent_id/petitionId are the only
// caller-controlled strings — bound them before any DB lookup.
const lenErr = firstLengthViolation([
[agent_id, LIMITS.ID, 'agent_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 });
}
// Check if 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 });
}
// Check for existing vote
const existingVote = await query(
'SELECT id FROM poppy.votes WHERE petition_id = $1 AND agent_id = $2',
[petitionId, agent_id],
);
if ((existingVote.rowCount ?? 0) > 0) {
return NextResponse.json(
{ error: 'Agent has already voted on this petition' },
{ status: 409 },
);
}
const agent = guard.agent;
const petition = petitionResult.rows[0];
// Generate rationale via Gemini
const prompt = `You are ${agent.codename || agent.name}, an AI agent voting ${vote_type === 'up' ? 'IN FAVOR OF' : 'AGAINST'} a petition.
Petition title: "${petition.title}"
Petition summary: "${petition.summary}"
Write a brief rationale (2-3 sentences) explaining why you voted ${vote_type === 'up' ? 'in favor' : 'against'} this petition. Stay in character. Return ONLY the rationale text, no quotes or formatting.`;
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));
// Insert vote
const voteResult = await query(
`INSERT INTO poppy.votes (petition_id, agent_id, vote_type, rationale, tokens_used, compute_cost)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[petitionId, agent_id, vote_type, geminiResult.text.trim(), totalTokens, cost],
);
// Update petition vote counts
if (vote_type === 'up') {
await query('UPDATE poppy.petitions SET vote_up = vote_up + 1 WHERE id = $1', [petitionId]);
} else {
await query('UPDATE poppy.petitions SET vote_down = vote_down + 1 WHERE id = $1', [petitionId]);
}
// Update agent stats
await query(
`UPDATE poppy.agents SET
vote_count = vote_count + 1,
compute_tokens_used = compute_tokens_used + $2,
compute_cost_usd = compute_cost_usd + $3
WHERE id = $1`,
[agent_id, totalTokens, 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, 'vote', 'gemini-2.0-flash', $2, $3, $4, $5, $6, $7)`,
[
agent_id,
inputTokens,
outputTokens,
totalTokens,
cost,
petitionId,
JSON.stringify(buildAuditMetadata(user, { vote_type, petition_title: petition.title })),
],
);
return NextResponse.json({
success: true,
vote: voteResult.rows[0],
}, { status: 201 });
} catch (err) {
console.error('[petitions/[id]/vote POST]', (err as Error).message);
return NextResponse.json({ error: 'Internal error: ' + (err as Error).message }, { status: 500 });
}
}