← back to Norma

app/api/search/public/route.ts

131 lines

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

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/search/public?q=jillian+berman&limit=20

   PUBLIC global search — no auth required, only returns entities that are
   safe for unauthenticated end-users (Pulse tier) to see:

     · petitions     (the public petition feed)
     · politicians   (public-record figures)
     · organizations (public partner orgs)
     · topics        (public pulse_topics — the issue radar)
     · articles      (linkable news coverage)

   Intentionally EXCLUDES: journalists, contacts, grants, foundations — those
   are staff/admin tooling and should never appear in a public search index.

   This route is the public counterpart to /api/search (which requires
   admin/staff role + applies org scoping). Pulse uses it.
   ═══════════════════════════════════════════════════════════════════════════ */

interface PublicResult {
  id: string;
  type: 'petition' | 'politician' | 'organization' | 'topic' | 'article';
  title: string;
  subtitle: string;
  meta?: string;
  url?: string;
}

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get('q')?.trim();
  const limit = Math.min(parseInt(searchParams.get('limit') || '20', 10), 40);

  if (!q || q.length < 2) {
    return NextResponse.json({ results: [], query: q, counts: {}, total: 0 });
  }
  const pattern = `%${q}%`;

  try {
    const [petitions, politicians, organizations, topics, articles] = await Promise.all([
      // Petitions — public petition feed (no org_id filter; the Pulse feed shows all orgs' petitions)
      query<{ id: string; title: string; category: string; url: string }>(
        `SELECT id, title, COALESCE(category, '') as category, COALESCE(url, '') as url
         FROM petitions
         WHERE title ILIKE $1 OR description ILIKE $1 OR category ILIKE $1
         ORDER BY created_at DESC NULLS LAST
         LIMIT $2`,
        [pattern, limit]
      ),

      // Politicians — public record figures
      query<{ id: string; name: string; party: string; state: string; title: string }>(
        `SELECT id, name, COALESCE(party, '') as party, COALESCE(state, '') as state, COALESCE(title, '') as title
         FROM politicians
         WHERE name ILIKE $1 OR state ILIKE $1 OR title ILIKE $1
         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
         LIMIT $2`,
        [pattern, limit]
      ),

      // Organizations
      query<{ id: string; name: string; category: string; state: string }>(
        `SELECT id, name, COALESCE(category, '') as category, COALESCE(state, '') as state
         FROM organizations
         WHERE name ILIKE $1 OR category ILIKE $1 OR description ILIKE $1
         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
         LIMIT $2`,
        [pattern, limit]
      ),

      // Topics — public issue radar
      query<{ id: string; name: string; urgency: string; category: string }>(
        `SELECT id, name, COALESCE(urgency, '') as urgency, COALESCE(category, '') as category
         FROM pulse_topics
         WHERE name ILIKE $1 OR category ILIKE $1 OR description ILIKE $1
         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
         LIMIT $2`,
        [pattern, limit]
      ),

      // Articles — linkable coverage
      query<{ id: string; title: string; outlet: string; published_date: string; url: string }>(
        `SELECT ja.id, ja.title, ja.outlet, ja.published_date::text,
                COALESCE(ja.url, '') as url
         FROM journalist_articles ja
         WHERE ja.title ILIKE $1 OR ja.outlet ILIKE $1 OR ja.summary ILIKE $1
         ORDER BY ja.published_date DESC NULLS LAST
         LIMIT $2`,
        [pattern, limit]
      ),
    ]);

    const results: PublicResult[] = [];

    for (const r of petitions.rows) {
      results.push({ id: r.id, type: 'petition', title: r.title, subtitle: r.category, url: r.url });
    }
    for (const r of politicians.rows) {
      results.push({ id: r.id, type: 'politician', title: r.name, subtitle: r.title || `${r.party}-${r.state}`, meta: r.state });
    }
    for (const r of organizations.rows) {
      results.push({ id: r.id, type: 'organization', title: r.name, subtitle: r.category, meta: r.state });
    }
    for (const r of topics.rows) {
      results.push({ id: r.id, type: 'topic', title: r.name, subtitle: r.category, meta: r.urgency });
    }
    for (const r of articles.rows) {
      results.push({ id: r.id, type: 'article', title: r.title, subtitle: r.outlet, meta: r.published_date, url: r.url });
    }

    return NextResponse.json({
      results,
      query: q,
      counts: {
        petitions: petitions.rows.length,
        politicians: politicians.rows.length,
        organizations: organizations.rows.length,
        topics: topics.rows.length,
        articles: articles.rows.length,
      },
      total: results.length,
    });
  } catch (err) {
    console.error('[search/public] error:', (err as Error).message);
    return NextResponse.json({ error: 'Search failed' }, { status: 500 });
  }
}