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