← 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',
      },
    }
  );
}