← back to Norma
app/api/alerts/route.ts
279 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
/**
* Unified alert item returned to the client.
*/
interface AlertItem {
id: string;
type: 'news' | 'grant' | 'petition' | 'congress' | 'donation' | 'community' | 'organization';
title: string;
summary: string;
date: string;
source: string;
}
const VALID_CATEGORIES = [
'news_media',
'grants',
'petitions',
'congress',
'donations',
'community',
'organizations',
] as const;
type Category = (typeof VALID_CATEGORIES)[number];
/**
* GET /api/alerts?categories=news_media,grants,petitions&limit=20&keywords=student+debt,loan+forgiveness
*
* Aggregates recent items from each enabled category into a unified alert feed.
* When `keywords` is provided, text-based categories (news, grants, petitions, community)
* are filtered to only return items matching those keywords — this gives each org
* a different, relevant feed.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
const { searchParams } = new URL(request.url);
// Parse categories — default to all if omitted
const categoriesParam = searchParams.get('categories');
const requestedCategories: Category[] = categoriesParam
? (categoriesParam.split(',').filter((c) => VALID_CATEGORIES.includes(c as Category)) as Category[])
: [...VALID_CATEGORIES];
// Org-specific keywords for filtering (comma-separated)
const keywordsParam = searchParams.get('keywords');
const keywords: string[] = keywordsParam
? keywordsParam.split(',').map((k) => k.trim()).filter(Boolean)
: [];
// Per-category limit (how many recent items to pull from each table)
let perCategory = parseInt(searchParams.get('per') || '5', 10);
if (isNaN(perCategory) || perCategory < 1) perCategory = 5;
if (perCategory > 20) perCategory = 20;
// Overall limit on the merged result
let limit = parseInt(searchParams.get('limit') || '20', 10);
if (isNaN(limit) || limit < 1) limit = 20;
if (limit > 100) limit = 100;
// Fetch each category concurrently; failed queries return empty arrays
const fetchers: Promise<AlertItem[]>[] = requestedCategories.map((cat) =>
fetchCategory(cat, perCategory, keywords, orgId),
);
const results = await Promise.all(fetchers);
const merged: AlertItem[] = results
.flat()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, limit);
return NextResponse.json({ alerts: merged, total: merged.length });
} catch (err) {
console.error('[api/alerts] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch alerts' }, { status: 500 });
}
}
/* ─── Per-category fetchers ────────────────────────────────────────────────── */
async function fetchCategory(category: Category, limit: number, keywords: string[], orgId: string | null): Promise<AlertItem[]> {
try {
switch (category) {
case 'news_media':
return await fetchNews(limit, keywords, orgId);
case 'grants':
return await fetchGrants(limit, keywords, orgId);
case 'petitions':
return await fetchPetitions(limit, keywords, orgId);
case 'congress':
return await fetchCongress(limit);
case 'donations':
return await fetchDonations(limit, orgId);
case 'community':
return await fetchCommunity(limit, keywords);
case 'organizations':
return await fetchOrganizations(limit);
default:
return [];
}
} catch (err) {
console.warn(`[api/alerts] Failed to fetch ${category}:`, (err as Error).message);
return [];
}
}
/** Build a WHERE clause fragment for keyword text search */
function keywordFilter(keywords: string[], columns: string[], paramOffset: number): { clause: string; params: string[]; nextIdx: number } {
if (keywords.length === 0) return { clause: '', params: [], nextIdx: paramOffset };
// Build: (col1 || ' ' || col2) ILIKE ANY(ARRAY[$2, $3, ...])
const colExpr = columns.map(c => `COALESCE(${c},'')`).join(" || ' ' || ");
const placeholders = keywords.map((_, i) => `$${paramOffset + i}`);
const patterns = keywords.map((k) => `%${k}%`);
return {
clause: `AND (${colExpr}) ILIKE ANY(ARRAY[${placeholders.join(',')}])`,
params: patterns,
nextIdx: paramOffset + keywords.length,
};
}
function truncate(text: string | null | undefined, max = 120): string {
if (!text) return '';
if (text.length <= max) return text;
return text.slice(0, max).trimEnd() + '...';
}
async function fetchNews(limit: number, keywords: string[] = [], orgId: string | null = null): Promise<AlertItem[]> {
const orgClause = orgId ? `AND org_id = $2` : '';
const orgParams = orgId ? [orgId] : [];
const kw = keywordFilter(keywords, ['headline', 'summary'], orgId ? 3 : 2);
const result = await query(
`SELECT id, headline, summary, outlet, published_at
FROM news_items
WHERE 1=1 ${orgClause} ${kw.clause}
ORDER BY published_at DESC NULLS LAST
LIMIT $1`,
[limit, ...orgParams, ...kw.params],
);
return result.rows.map((r) => ({
id: r.id,
type: 'news' as const,
title: r.headline,
summary: truncate(r.summary),
date: r.published_at?.toISOString?.() ?? r.published_at ?? new Date().toISOString(),
source: r.outlet || 'News',
}));
}
async function fetchGrants(limit: number, keywords: string[] = [], orgId: string | null = null): Promise<AlertItem[]> {
const orgClause = orgId ? `AND org_id = $2` : '';
const orgParams = orgId ? [orgId] : [];
const kw = keywordFilter(keywords, ['title', 'description'], orgId ? 3 : 2);
const result = await query(
`SELECT id, title, description, deadline, created_at
FROM grants
WHERE 1=1 ${orgClause} ${kw.clause}
ORDER BY created_at DESC
LIMIT $1`,
[limit, ...orgParams, ...kw.params],
);
return result.rows.map((r) => ({
id: r.id,
type: 'grant' as const,
title: r.title,
summary: truncate(r.description),
date: r.created_at?.toISOString?.() ?? r.created_at ?? new Date().toISOString(),
source: 'Grants',
}));
}
async function fetchPetitions(limit: number, keywords: string[] = [], orgId: string | null = null): Promise<AlertItem[]> {
const orgClause = orgId ? `AND org_id = $2` : '';
const orgParams = orgId ? [orgId] : [];
const kw = keywordFilter(keywords, ['title', 'description'], orgId ? 3 : 2);
const result = await query(
`SELECT id, title, description, signature_count, created_at
FROM petitions
WHERE 1=1 ${orgClause} ${kw.clause}
ORDER BY created_at DESC
LIMIT $1`,
[limit, ...orgParams, ...kw.params],
);
return result.rows.map((r) => ({
id: r.id,
type: 'petition' as const,
title: r.title,
summary: truncate(r.description) || (r.signature_count ? `${r.signature_count.toLocaleString()} signatures` : ''),
date: r.created_at?.toISOString?.() ?? r.created_at ?? new Date().toISOString(),
source: 'Petitions',
}));
}
async function fetchCongress(limit: number): Promise<AlertItem[]> {
const result = await query(
`SELECT id, representative_name, representative_party, state_abbr, district_number, updated_at
FROM congressional_districts
WHERE representative_name IS NOT NULL
ORDER BY updated_at DESC NULLS LAST
LIMIT $1`,
[limit],
);
return result.rows.map((r) => ({
id: r.id,
type: 'congress' as const,
title: `${r.representative_name} (${r.representative_party || '?'}-${r.state_abbr})`,
summary: `District ${r.district_number}, ${r.state_abbr}`,
date: r.updated_at?.toISOString?.() ?? r.updated_at ?? new Date().toISOString(),
source: 'Congress',
}));
}
async function fetchDonations(limit: number, orgId: string | null = null): Promise<AlertItem[]> {
const orgClause = orgId ? `AND org_id = $1` : '';
const orgParams = orgId ? [orgId] : [];
const limitIdx = orgId ? 2 : 1;
const result = await query(
`SELECT id, amount, source, campaign_name, donated_at
FROM donations
WHERE 1=1 ${orgClause}
ORDER BY donated_at DESC
LIMIT $${limitIdx}`,
[...orgParams, limit],
);
return result.rows.map((r) => ({
id: r.id,
type: 'donation' as const,
title: `$${Number(r.amount).toLocaleString()} donation${r.campaign_name ? ` — ${r.campaign_name}` : ''}`,
summary: `Source: ${r.source}`,
date: r.donated_at?.toISOString?.() ?? r.donated_at ?? new Date().toISOString(),
source: 'Donations',
}));
}
async function fetchCommunity(limit: number, keywords: string[] = []): Promise<AlertItem[]> {
const kw = keywordFilter(keywords, ['title', 'body'], 2);
const result = await query(
`SELECT id, title, body, platform, posted_at, created_at
FROM community_posts
WHERE 1=1 ${kw.clause}
ORDER BY COALESCE(posted_at, created_at) DESC
LIMIT $1`,
[limit, ...kw.params],
);
return result.rows.map((r) => ({
id: r.id,
type: 'community' as const,
title: r.title || 'Community post',
summary: truncate(r.body),
date: (r.posted_at ?? r.created_at)?.toISOString?.() ?? new Date().toISOString(),
source: r.platform || 'Community',
}));
}
async function fetchOrganizations(limit: number): Promise<AlertItem[]> {
const result = await query(
`SELECT id, name, mission, city, state, created_at
FROM organizations
ORDER BY created_at DESC
LIMIT $1`,
[limit],
);
return result.rows.map((r) => ({
id: r.id,
type: 'organization' as const,
title: r.name,
summary: truncate(r.mission) || [r.city, r.state].filter(Boolean).join(', ') || '',
date: r.created_at?.toISOString?.() ?? r.created_at ?? new Date().toISOString(),
source: 'Organizations',
}));
}