← back to Norma

app/api/petitions/trending/route.ts

141 lines

/**
 * Trending Petitions API
 * GET /api/petitions/trending — cross-platform trending petition feed
 *
 * Aggregates from: UK Parliament, OpenPetition EU, Decidim, GovTrack, MoveOn
 * Supports filtering by platform, category, climber status, min signatures
 */

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getBrand } from '@/lib/brand';

export async function GET(req: NextRequest) {
  const auth = requireRole(req, 'admin', 'staff', 'pulse');
  if (auth instanceof NextResponse) return auth;

  const url = req.nextUrl;
  const platform = url.searchParams.get('platform');
  const category = url.searchParams.get('category');
  const isClimber = url.searchParams.get('is_climber');
  const minSignatures = parseInt(url.searchParams.get('min_signatures') || '0');
  const search = url.searchParams.get('q');
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '30'), 100);
  const offset = parseInt(url.searchParams.get('offset') || '0');
  const sortBy = url.searchParams.get('sort') || 'signatures'; // signatures, growth, recent
  const orgFilterMode = url.searchParams.get('org') === 'true' || url.searchParams.get('norma') === 'true';

  // Load org-specific focus categories from brand data
  const brand = await getBrand();
  const ORG_CATEGORIES = brand.focusAreas.length > 0
    ? brand.focusAreas
    : [
        'education', 'Education', 'debt_cancellation', 'consumer_protection',
        'repayment', 'collections', 'healthcare', 'Healthcare', 'Health and Well-Being',
        'government', 'Government and Politics', 'social_security', 'housing',
        'poverty', 'labor', 'Child and Family Well-Being', 'public_services',
      ];

  try {
    const conditions = [];
    const values: unknown[] = [];
    let idx = 1;

    // Server-side org filter: only show categories matching org focus areas
    if (orgFilterMode && !platform && !category) {
      // Build dynamic ILIKE clauses from brand search keywords
      const searchKeywords = brand.searchKeywords.length > 0
        ? brand.searchKeywords.slice(0, 6)
        : ['student', 'debt', 'loan', 'education', 'tuition', 'forgive'];
      const ilikeClauses = searchKeywords.map(kw => `title ILIKE '%${kw.replace(/'/g, "''")}%'`).join(' OR ');
      conditions.push(`(category = ANY($${idx++}) OR ${ilikeClauses})`);
      values.push(ORG_CATEGORIES);
    }

    if (platform) {
      conditions.push(`platform = $${idx++}`);
      values.push(platform);
    }
    if (category) {
      conditions.push(`category = $${idx++}`);
      values.push(category);
    }
    if (isClimber === 'true') {
      conditions.push(`is_climber = true`);
    }
    if (minSignatures > 0) {
      conditions.push(`signature_count >= $${idx++}`);
      values.push(minSignatures);
    }
    if (search) {
      conditions.push(`(title ILIKE $${idx} OR description ILIKE $${idx})`);
      values.push(`%${search}%`);
      idx++;
    }

    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';

    // Sort order
    let orderBy = 'signature_count DESC NULLS LAST';
    if (sortBy === 'growth') orderBy = 'growth_rate DESC NULLS LAST';
    if (sortBy === 'recent') orderBy = 'scraped_at DESC';

    const { rows: petitions } = await query(
      `SELECT id, platform, petition_id, title, description, target,
              signature_count, signature_goal, growth_rate, growth_pct,
              rank, category, url, creator, created_date,
              is_climber, scraped_at
       FROM trending_petitions
       ${where}
       ORDER BY ${orderBy}
       LIMIT $${idx++} OFFSET $${idx++}`,
      [...values, limit, offset],
    );

    // Total count
    const { rows: countRows } = await query(
      `SELECT COUNT(*) AS total FROM trending_petitions ${where}`,
      values,
    );

    // Platform breakdown
    const { rows: platforms } = await query(
      `SELECT platform, COUNT(*) AS count, SUM(signature_count) AS total_signatures,
              COUNT(CASE WHEN is_climber THEN 1 END) AS climbers
       FROM trending_petitions
       GROUP BY platform
       ORDER BY total_signatures DESC NULLS LAST`,
    );

    // Category breakdown
    const { rows: categories } = await query(
      `SELECT category, COUNT(*) AS count
       FROM trending_petitions
       WHERE category IS NOT NULL
       GROUP BY category
       ORDER BY count DESC`,
    );

    return NextResponse.json({
      petitions,
      total: Number(countRows[0]?.total || 0),
      platforms: platforms.map((p) => ({
        platform: p.platform,
        count: Number(p.count),
        totalSignatures: Number(p.total_signatures || 0),
        climbers: Number(p.climbers),
      })),
      categories: categories.map((c) => ({
        category: c.category,
        count: Number(c.count),
      })),
      limit,
      offset,
    });
  } catch (err) {
    console.error('[trending] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch trending petitions' }, { status: 500 });
  }
}