← back to Stayclaim
src/app/api/events/route.ts
96 lines
import { NextResponse } from 'next/server';
import { getArchiveTimeline } from '@/lib/db';
export const dynamic = 'force-dynamic';
/**
* GET /api/events?kind=permit&decade=2020s&city=Beverly+Hills
* Public read-only feed. Tier-D never returned. Cache-friendly.
*/
// Allow-list of valid event kinds (mirrors place_event.kind enum). Anything
// else is rejected with 400 so callers can't probe arbitrary column values
// or trigger expensive scans.
// Mirror PlaceEvent.kind union in src/lib/db.ts. Stale entries here silently
// rejected every legitimate request with 400.
const VALID_KINDS = new Set([
'permit', 'sale', 'remodel', 'demolition', 'designation', 'media', 'occupancy',
]);
const DECADE_RE = /^\d{3}0s$/;
export async function GET(req: Request) {
const url = new URL(req.url);
const kindRaw = url.searchParams.get('kind') ?? undefined;
const decadeRaw = url.searchParams.get('decade') ?? undefined;
const cityRaw = url.searchParams.get('city') ?? undefined;
const limitRaw = Number(url.searchParams.get('limit') ?? 100);
// Validate kind against allow-list.
if (kindRaw !== undefined && !VALID_KINDS.has(kindRaw)) {
return NextResponse.json({ error: 'invalid kind' }, { status: 400 });
}
// Validate decade format.
if (decadeRaw !== undefined && !DECADE_RE.test(decadeRaw)) {
return NextResponse.json({ error: 'invalid decade' }, { status: 400 });
}
// Cap city length and normalize for cache-key collapsing.
if (cityRaw !== undefined && cityRaw.length > 100) {
return NextResponse.json({ error: 'invalid city' }, { status: 400 });
}
const kind = kindRaw;
const decade = decadeRaw;
const city = cityRaw;
// NaN-safe limit clamp 1..500 (default 100).
const limit = Number.isFinite(limitRaw)
? Math.min(500, Math.max(1, Math.floor(limitRaw)))
: 100;
let events;
try {
events = await getArchiveTimeline({ kind, decade, city, limit });
} catch (e) {
console.error('api/events getArchiveTimeline', { kind, decade, city, limit }, e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } }
);
}
// Trim shape — API consumers don't need internal IDs.
// Filter Tier-D first, then slice to limit so the public response holds the
// promised number of non-D rows (instead of "up to limit, minus whatever was D").
const out = events
.filter(e => e.source_tier !== 'D')
.slice(0, limit)
.map(e => ({
kind: e.kind,
summary: e.summary,
valid_from: e.valid_from,
valid_to: e.valid_to,
recorded_at: e.recorded_at,
source_tier: e.source_tier,
source_label: e.source_label,
source_url: e.source_url,
confidence: e.confidence,
address: {
slug: e.listing_slug,
title: e.listing_title,
city: e.listing_city,
},
}));
return NextResponse.json(
{
count: out.length,
filters: { kind, decade, city, limit },
events: out,
},
{
headers: {
'Cache-Control': 'public, max-age=300, s-maxage=900',
Vary: 'Accept-Encoding',
},
}
);
}