← back to Norma
app/api/geo/state-heat/route.ts
122 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
/* ─── State abbreviation → full name mapping ──────────────────────────────── */
const STATE_NAMES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
type Metric = 'pulse_activity' | 'cfpb_complaints' | 'sentiment' | 'petition_count';
const VALID_METRICS: Metric[] = ['pulse_activity', 'cfpb_complaints', 'sentiment', 'petition_count'];
/**
* GET /api/geo/state-heat?metric=pulse_activity|cfpb_complaints|sentiment|petition_count
*
* Returns state-level aggregated values for the requested metric, plus min/max
* for color scale calculations on the frontend.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { searchParams } = new URL(request.url);
const metric = (searchParams.get('metric') || 'pulse_activity') as Metric;
if (!VALID_METRICS.includes(metric)) {
return NextResponse.json(
{ error: `Invalid metric. Must be one of: ${VALID_METRICS.join(', ')}` },
{ status: 400 },
);
}
let states: { state: string; value: number; label: string }[] = [];
switch (metric) {
case 'pulse_activity': {
const result = await query<{ state: string; value: number }>(
`SELECT state, ROUND(SUM(heat_score)::numeric, 1) AS value
FROM pulse_geo_heat
GROUP BY state
ORDER BY value DESC`,
);
states = result.rows.map((r) => ({
state: r.state,
value: Number(r.value),
label: STATE_NAMES[r.state] || r.state,
}));
break;
}
case 'cfpb_complaints': {
const result = await query<{ state: string; value: number }>(
`SELECT state, COUNT(*)::int AS value
FROM cfpb_complaints
WHERE state IS NOT NULL AND state != ''
GROUP BY state
ORDER BY value DESC`,
);
states = result.rows.map((r) => ({
state: r.state,
value: Number(r.value),
label: STATE_NAMES[r.state] || r.state,
}));
break;
}
case 'sentiment': {
const result = await query<{ state: string; value: number }>(
`SELECT state, ROUND(AVG(sentiment_avg)::numeric, 3) AS value
FROM pulse_geo_heat
GROUP BY state
ORDER BY value DESC`,
);
states = result.rows.map((r) => ({
state: r.state,
value: Number(r.value),
label: STATE_NAMES[r.state] || r.state,
}));
break;
}
case 'petition_count': {
// Use article_count sum from pulse_geo_heat as proxy for petition activity
const result = await query<{ state: string; value: number }>(
`SELECT state, SUM(article_count)::int AS value
FROM pulse_geo_heat
GROUP BY state
ORDER BY value DESC`,
);
states = result.rows.map((r) => ({
state: r.state,
value: Number(r.value),
label: STATE_NAMES[r.state] || r.state,
}));
break;
}
}
// Calculate min / max for color-scale
const values = states.map((s) => s.value);
const min = values.length > 0 ? Math.min(...values) : 0;
const max = values.length > 0 ? Math.max(...values) : 0;
return NextResponse.json({ states, min, max, metric });
} catch (err) {
console.error('[state-heat] Error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch state heat data' }, { status: 500 });
}
}