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