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