← back to Norma
app/api/geo/trends/route.ts
206 lines
/**
* GET /api/geo/trends
*
* Returns time-series aggregated data from cfpb_complaints (by date_received)
* and community_posts (by posted_at), grouped by day.
*
* Query parameters:
* ?range=30d — Time range: 7d, 30d, 90d, 180d, 365d, all (default: 30d)
* ?state=CA — Filter by state abbreviation (optional)
*
* Includes basic anomaly detection: if any day's count exceeds
* mean + 2*stddev, it is flagged as an anomaly.
*
* Requires authentication.
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
const VALID_RANGES: Record<string, number | null> = {
'7d': 7,
'30d': 30,
'90d': 90,
'180d': 180,
'365d': 365,
'all': null,
};
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { searchParams } = new URL(request.url);
const range = searchParams.get('range') || '30d';
const state = searchParams.get('state');
if (!(range in VALID_RANGES)) {
return NextResponse.json(
{ error: `Invalid range. Must be one of: ${Object.keys(VALID_RANGES).join(', ')}` },
{ status: 400 },
);
}
const days = VALID_RANGES[range];
try {
// ── Build CFPB complaints time series ──
const cfpbConditions: string[] = [];
const cfpbValues: unknown[] = [];
let cfpbParamIdx = 1;
if (days !== null) {
cfpbConditions.push(`date_received >= CURRENT_DATE - $${cfpbParamIdx++}::integer`);
cfpbValues.push(days);
}
if (state) {
cfpbConditions.push(`state = $${cfpbParamIdx++}`);
cfpbValues.push(state.toUpperCase());
}
const cfpbWhere = cfpbConditions.length > 0
? `WHERE ${cfpbConditions.join(' AND ')}`
: '';
const { rows: cfpbDaily } = await query<{
day: string;
complaint_count: string;
}>(
`SELECT
date_received AS day,
COUNT(*) AS complaint_count
FROM cfpb_complaints
${cfpbWhere}
GROUP BY date_received
ORDER BY date_received`,
cfpbValues,
);
// ── Build community posts time series ──
const commConditions: string[] = [];
const commValues: unknown[] = [];
let commParamIdx = 1;
if (days !== null) {
commConditions.push(`posted_at >= CURRENT_DATE - $${commParamIdx++}::integer`);
commValues.push(days);
}
// community_posts doesn't have a state column directly, so we skip state filter for it
// unless we can text-match. For now, we return all community posts in the range.
const commWhere = commConditions.length > 0
? `WHERE ${commConditions.join(' AND ')}`
: '';
const { rows: communityDaily } = await query<{
day: string;
post_count: string;
frustrated_count: string;
}>(
`SELECT
DATE(posted_at) AS day,
COUNT(*) AS post_count,
COUNT(*) FILTER (WHERE sentiment = 'frustrated') AS frustrated_count
FROM community_posts
${commWhere}
GROUP BY DATE(posted_at)
ORDER BY DATE(posted_at)`,
commValues,
);
// ── Anomaly detection for CFPB complaints ──
const cfpbCounts = cfpbDaily.map((r) => Number(r.complaint_count));
const cfpbAnomalies = detectAnomalies(cfpbCounts);
const cfpbSeries = cfpbDaily.map((row, i) => ({
date: row.day,
count: Number(row.complaint_count),
is_anomaly: cfpbAnomalies.anomalyIndices.has(i),
}));
// ── Anomaly detection for community posts ──
const commCounts = communityDaily.map((r) => Number(r.post_count));
const commAnomalies = detectAnomalies(commCounts);
const commSeries = communityDaily.map((row, i) => ({
date: row.day,
count: Number(row.post_count),
frustrated_count: Number(row.frustrated_count),
is_anomaly: commAnomalies.anomalyIndices.has(i),
}));
return NextResponse.json({
filters: { range, state },
cfpb_complaints: {
series: cfpbSeries,
stats: {
total_days: cfpbSeries.length,
total_count: cfpbCounts.reduce((a, b) => a + b, 0),
mean: cfpbAnomalies.mean,
stddev: cfpbAnomalies.stddev,
threshold: cfpbAnomalies.threshold,
anomaly_count: cfpbAnomalies.anomalyIndices.size,
},
},
community_posts: {
series: commSeries,
stats: {
total_days: commSeries.length,
total_count: commCounts.reduce((a, b) => a + b, 0),
mean: commAnomalies.mean,
stddev: commAnomalies.stddev,
threshold: commAnomalies.threshold,
anomaly_count: commAnomalies.anomalyIndices.size,
},
},
});
} catch (err) {
console.error('[geo/trends] Error:', (err as Error).message);
return NextResponse.json(
{ error: `Failed to fetch trends: ${(err as Error).message}` },
{ status: 500 },
);
}
}
/* ─── Anomaly detection helper ─────────────────────────────────────────── */
interface AnomalyResult {
mean: number;
stddev: number;
threshold: number;
anomalyIndices: Set<number>;
}
function detectAnomalies(values: number[]): AnomalyResult {
if (values.length === 0) {
return { mean: 0, stddev: 0, threshold: 0, anomalyIndices: new Set() };
}
const sum = values.reduce((a, b) => a + b, 0);
const mean = sum / values.length;
const squaredDiffs = values.map((v) => (v - mean) ** 2);
const variance = squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
const stddev = Math.sqrt(variance);
const threshold = mean + 2 * stddev;
const anomalyIndices = new Set<number>();
for (let i = 0; i < values.length; i++) {
if (values[i] > threshold) {
anomalyIndices.add(i);
}
}
return {
mean: Math.round(mean * 100) / 100,
stddev: Math.round(stddev * 100) / 100,
threshold: Math.round(threshold * 100) / 100,
anomalyIndices,
};
}