← back to Norma
app/api/globe/country/[code]/route.ts
180 lines
/**
* GET /api/globe/country/[code]
* Returns full detail for a single country: profile, organizations,
* recent statements, and related topics.
*
* Response:
* CountryDetail (see CountryDetailPanel types)
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { COUNTRY_CENTROIDS } from '@/components/globe/globe-utils';
// Basic country name lookup
const COUNTRY_NAMES: Record<string, string> = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', BR: 'Brazil', IN: 'India',
CN: 'China', MX: 'Mexico', KR: 'South Korea', ZA: 'South Africa',
NG: 'Nigeria', SE: 'Sweden', NO: 'Norway', NL: 'Netherlands', IT: 'Italy',
ES: 'Spain', NZ: 'New Zealand', AR: 'Argentina', RU: 'Russia', UA: 'Ukraine',
PL: 'Poland', TR: 'Turkey', EG: 'Egypt', KE: 'Kenya', SA: 'Saudi Arabia',
AE: 'UAE', IL: 'Israel', PK: 'Pakistan', BD: 'Bangladesh', PH: 'Philippines',
ID: 'Indonesia', MY: 'Malaysia', SG: 'Singapore', TH: 'Thailand', VN: 'Vietnam',
PT: 'Portugal', GR: 'Greece', BE: 'Belgium', CH: 'Switzerland', AT: 'Austria',
CZ: 'Czech Republic', RO: 'Romania', HU: 'Hungary', DK: 'Denmark', FI: 'Finland',
IE: 'Ireland', CL: 'Chile', CO: 'Colombia', PE: 'Peru', ET: 'Ethiopia',
TZ: 'Tanzania', GH: 'Ghana', MA: 'Morocco', DZ: 'Algeria', TN: 'Tunisia',
};
const COUNTRY_REGIONS: Record<string, string> = {
US: 'North America', CA: 'North America', MX: 'North America',
GB: 'Europe', DE: 'Europe', FR: 'Europe', IT: 'Europe', ES: 'Europe',
NL: 'Europe', BE: 'Europe', CH: 'Europe', AT: 'Europe', SE: 'Europe',
NO: 'Europe', DK: 'Europe', FI: 'Europe', PL: 'Europe', CZ: 'Europe',
RO: 'Europe', HU: 'Europe', GR: 'Europe', PT: 'Europe', IE: 'Europe',
UA: 'Europe', RU: 'Europe/Asia',
CN: 'Asia', JP: 'Asia', KR: 'Asia', IN: 'Asia', ID: 'Asia',
PH: 'Asia', VN: 'Asia', TH: 'Asia', MY: 'Asia', SG: 'Asia',
BD: 'Asia', PK: 'Asia', TR: 'Asia',
BR: 'South America', AR: 'South America', CL: 'South America',
CO: 'South America', PE: 'South America',
AU: 'Oceania', NZ: 'Oceania',
NG: 'Africa', ZA: 'Africa', KE: 'Africa', EG: 'Africa',
GH: 'Africa', ET: 'Africa', TZ: 'Africa', MA: 'Africa',
DZ: 'Africa', TN: 'Africa',
SA: 'Middle East', AE: 'Middle East', IL: 'Middle East',
};
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ code: string }> },
) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { code } = await params;
const countryCode = code.toUpperCase().trim();
if (!/^[A-Z]{2,3}$/.test(countryCode)) {
return NextResponse.json({ error: 'Invalid country code' }, { status: 400 });
}
try {
const centroid = COUNTRY_CENTROIDS[countryCode] || [0, 0];
// Base profile
let profile = {
code: countryCode,
name: COUNTRY_NAMES[countryCode] || countryCode,
region: COUNTRY_REGIONS[countryCode] || null,
lat: centroid[0],
lng: centroid[1],
article_count: 0,
org_count: 0,
sentiment_avg: null as number | null,
todays_headlines: 0,
};
// Try country_profiles table
await query(
`SELECT country_name, region, latitude, longitude, article_count, org_count, sentiment_avg
FROM country_profiles
WHERE country_code = $1
LIMIT 1`,
[countryCode],
).then(res => {
if (res.rows.length > 0) {
const r = res.rows[0];
profile = {
...profile,
name: r.country_name || profile.name,
region: r.region || profile.region,
lat: typeof r.latitude === 'number' ? r.latitude : profile.lat,
lng: typeof r.longitude === 'number' ? r.longitude : profile.lng,
article_count: Number(r.article_count) || 0,
org_count: Number(r.org_count) || 0,
sentiment_avg: r.sentiment_avg != null ? Number(r.sentiment_avg) : null,
};
}
}).catch(() => { /* table may not exist */ });
// Fetch today's headlines count
await query(
`SELECT COUNT(*)::int AS cnt FROM newspaper_frontpages
WHERE country = $1 AND scraped_date = CURRENT_DATE`,
[countryCode],
).then(res => {
profile.todays_headlines = Number(res.rows[0]?.cnt) || 0;
}).catch(() => { /* table may not exist */ });
// Fetch matching organizations
const orgsResult = await query(
`SELECT id, name, type, state, country, website
FROM organizations
WHERE UPPER(TRIM(country)) = $1
OR UPPER(TRIM(state)) = $1
ORDER BY name ASC
LIMIT 20`,
[countryCode],
).catch(() => ({ rows: [] }));
// Fetch recent statements from this country
const stmtsResult = await query(
`SELECT
ns.id,
o.name AS organization_name,
ns.title,
ns.topic,
ns.published_at
FROM nonprofit_statements ns
JOIN organizations o ON o.id = ns.organization_id
WHERE UPPER(TRIM(ns.country)) = $1
OR UPPER(TRIM(o.country)) = $1
ORDER BY ns.published_at DESC NULLS LAST
LIMIT 8`,
[countryCode],
).catch(() => ({ rows: [] }));
// Fetch related topics from Pulse API (via DB if available)
let topics: Array<{ title: string; urgency: string; article_count: number }> = [];
await query(
`SELECT title, urgency, article_count
FROM pulse_topics
WHERE geo_states @> ARRAY[$1]::text[]
OR geo_states @> ARRAY[$2]::text[]
ORDER BY urgency ASC, article_count DESC
LIMIT 6`,
[countryCode, COUNTRY_NAMES[countryCode] || countryCode],
).then(res => {
topics = res.rows.map(r => ({
title: String(r.title),
urgency: String(r.urgency),
article_count: Number(r.article_count) || 0,
}));
}).catch(() => { /* pulse_topics table may not exist */ });
return NextResponse.json({
...profile,
organizations: orgsResult.rows.map(r => ({
id: String(r.id),
name: String(r.name),
type: r.type ? String(r.type) : null,
state: r.state ? String(r.state) : null,
})),
statements: stmtsResult.rows.map(r => ({
id: String(r.id),
organization_name: String(r.organization_name),
title: r.title ? String(r.title) : null,
topic: r.topic ? String(r.topic) : null,
published_at: r.published_at ? String(r.published_at) : null,
})),
topics,
});
} catch (err) {
console.error('[api/globe/country/[code]] error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch country detail' }, { status: 500 });
}
}