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