← back to Norma

app/api/petitions/topics/route.ts

204 lines

/**
 * Petition Topics API
 * GET /api/petitions/topics — combined topics from pulse_topics + newspaper_frontpages + trending_topics
 *
 * Returns a unified feed of topics the user can select to create a petition from.
 */

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

// PUBLIC route — topic feed shown on /pulse before sign-in. Middleware
// already allow-lists /api/petitions/* for unauthenticated access; the
// previous requireRole gate inside the handler contradicted that and
// 401-spammed every guest visitor.
export async function GET(req: NextRequest) {
  const url = req.nextUrl;
  const source = url.searchParams.get('source'); // pulse | headline | trending
  const category = url.searchParams.get('category');
  const search = url.searchParams.get('q');
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '100'), 300);
  const orgFilterMode = url.searchParams.get('org') !== 'false' && url.searchParams.get('norma') !== 'false'; // default to org-filtered mode

  // Load brand search keywords for topic filtering
  const brand = await getBrand();
  const ORG_KEYWORDS = brand.searchKeywords.length > 0
    ? brand.searchKeywords
    : [
        'student', 'debt', 'loan', 'forgiveness', 'tuition', 'college', 'university',
        'education', 'PSLF', 'borrower', 'repayment', 'default', 'financial aid',
        'Pell Grant', 'federal aid', 'higher education', 'affordability',
        'consumer protection', 'predatory lending', 'credit', 'collection',
      ];

  try {
    const topics: Array<{
      id: string;
      title: string;
      subtitle: string | null;
      category: string | null;
      urgency: string | null;
      score: number | null;
      description: string | null;
      source: string;
      date: string | null;
      matchTopics: string[] | null;
    }> = [];

    // 1. Pulse of America topics
    if (!source || source === 'pulse') {
      const pulseConditions = ['1=1'];
      const pulseValues: unknown[] = [];
      let pIdx = 1;

      if (category) {
        pulseConditions.push(`category = $${pIdx++}`);
        pulseValues.push(category);
      }
      if (search) {
        pulseConditions.push(`(name ILIKE $${pIdx} OR description ILIKE $${pIdx})`);
        pulseValues.push(`%${search}%`);
        pIdx++;
      }

      const { rows: pulseRows } = await query(
        `SELECT id, name, category, urgency, description, created_at
         FROM pulse_topics
         WHERE ${pulseConditions.join(' AND ')}
         ORDER BY
           CASE urgency WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
           name`,
        pulseValues,
      );

      for (const r of pulseRows) {
        topics.push({
          id: r.id,
          title: r.name,
          subtitle: r.urgency ? `${r.urgency} priority` : null,
          category: r.category,
          urgency: r.urgency,
          score: null,
          description: r.description,
          source: 'pulse',
          date: r.created_at,
          matchTopics: null,
        });
      }
    }

    // 2. Newspaper headlines
    if (!source || source === 'headline') {
      const headConditions = ['top_headline IS NOT NULL'];
      const headValues: unknown[] = [];
      let hIdx = 1;

      if (search) {
        headConditions.push(`(top_headline ILIKE $${hIdx} OR newspaper_name ILIKE $${hIdx})`);
        headValues.push(`%${search}%`);
        hIdx++;
      }

      const { rows: headlineRows } = await query(
        `SELECT id, top_headline, newspaper_name, relevance_score, match_topics,
                scraped_date, country
         FROM newspaper_frontpages
         WHERE ${headConditions.join(' AND ')}
         ORDER BY scraped_date DESC, relevance_score DESC NULLS LAST
         LIMIT 60`,
        headValues,
      );

      for (const r of headlineRows) {
        topics.push({
          id: r.id,
          title: r.top_headline,
          subtitle: r.newspaper_name,
          category: null,
          urgency: null,
          score: r.relevance_score ? Number(r.relevance_score) : null,
          description: null,
          source: 'headline',
          date: r.scraped_date,
          matchTopics: r.match_topics,
        });
      }
    }

    // 3. Trending topics
    if (!source || source === 'trending') {
      const trendConditions = ['1=1'];
      const trendValues: unknown[] = [];
      let tIdx = 1;

      if (category) {
        trendConditions.push(`category = $${tIdx++}`);
        trendValues.push(category);
      }
      if (search) {
        trendConditions.push(`topic ILIKE $${tIdx}`);
        trendValues.push(`%${search}%`);
        tIdx++;
      }

      const { rows: trendRows } = await query(
        `SELECT id, topic, category, score, source, url, scraped_at
         FROM trending_topics
         WHERE ${trendConditions.join(' AND ')}
         ORDER BY score DESC NULLS LAST
         LIMIT 80`,
        trendValues,
      );

      for (const r of trendRows) {
        topics.push({
          id: r.id,
          title: r.topic,
          subtitle: r.source,
          category: r.category,
          urgency: null,
          score: r.score ? Number(r.score) : null,
          description: null,
          source: 'trending',
          date: r.scraped_at,
          matchTopics: null,
        });
      }
    }

    // Get category breakdown
    const { rows: categories } = await query(
      `SELECT category, COUNT(*) AS count FROM (
         SELECT category FROM pulse_topics WHERE category IS NOT NULL
         UNION ALL
         SELECT category FROM trending_topics WHERE category IS NOT NULL
       ) combined
       GROUP BY category ORDER BY count DESC`,
    );

    // Org keyword filter: only return topics matching the org's focus
    let filteredTopics = topics;
    if (orgFilterMode) {
      filteredTopics = topics.filter(t => {
        const text = `${t.title} ${t.description || ''} ${t.category || ''}`.toLowerCase();
        return ORG_KEYWORDS.some(kw => text.includes(kw.toLowerCase()));
      });
    }

    return NextResponse.json({
      topics: filteredTopics.slice(0, limit),
      total: filteredTopics.length,
      categories: categories.map((c) => ({ category: c.category, count: Number(c.count) })),
      sources: {
        pulse: filteredTopics.filter((t) => t.source === 'pulse').length,
        headline: filteredTopics.filter((t) => t.source === 'headline').length,
        trending: filteredTopics.filter((t) => t.source === 'trending').length,
      },
    });
  } catch (err) {
    console.error('[topics] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch topics' }, { status: 500 });
  }
}