← back to Govarbitrage

src/app/api/agents/search/route.ts

69 lines

import { NextRequest, NextResponse } from "next/server";
import { searchCommercialAgents, type AgentKind } from "@/lib/places";
import { rateLimit, clientIp, sessionOrIpKey } from "@/lib/rate-limit";
import { getCurrentUser } from "@/lib/current-user";

export const dynamic = "force-dynamic";

// Rate-limit bucket key. The upstream (Google Places) is a paid API, so one
// session must not be able to run up the bill. Key on the VERIFIED session
// subject (unforgeable) — NOT on X-Forwarded-For, which the client controls and
// can rotate to reset the bucket. This route is login-gated by middleware, so a
// user is normally present; fall back to the (spoofable) IP only as a last resort.
async function clientKey(req: NextRequest): Promise<string> {
  const user = await getCurrentUser();
  return sessionOrIpKey("agents", user?.sub, clientIp(req));
}

// Lightweight input validation so user-supplied location can't pad the Places
// textQuery with junk (cost/relevance noise) or a malformed ZIP.
const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
const LOC_RE = /^[a-zA-Z0-9\s,.'-]{1,80}$/;

export async function GET(req: NextRequest) {
  const rl = rateLimit(await clientKey(req), 20, 60_000);
  if (!rl.allowed) {
    return NextResponse.json(
      { error: "Too many searches — wait a moment." },
      { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } },
    );
  }

  const sp = req.nextUrl.searchParams;
  const city = sp.get("city")?.trim() || undefined;
  const state = sp.get("state")?.trim() || undefined;
  const zip = sp.get("zip")?.trim() || undefined;
  const typeParam = (sp.get("type") ?? "both").toLowerCase();
  const kinds: AgentKind[] =
    typeParam === "buyer" ? ["buyer"] : typeParam === "leasing" ? ["leasing"] : ["buyer", "leasing"];

  if (!city && !state && !zip) {
    return NextResponse.json({ error: "Provide a city, state, or ZIP." }, { status: 400 });
  }
  if (zip && !ZIP_RE.test(zip)) {
    return NextResponse.json({ error: "Invalid ZIP — use 5 digits (or ZIP+4)." }, { status: 400 });
  }
  if (city && !LOC_RE.test(city)) {
    return NextResponse.json({ error: "Invalid city." }, { status: 400 });
  }
  if (state && !LOC_RE.test(state)) {
    return NextResponse.json({ error: "Invalid state." }, { status: 400 });
  }

  try {
    const data = await searchCommercialAgents({ city, state, zip, kinds });
    return NextResponse.json(data);
  } catch (e) {
    // Don't leak the raw upstream (Google) error to the client — it can reveal
    // key/quota details. Log server-side; return a generic message. Preserve the
    // 503 "not configured" signal so ops can tell a missing key from a bad call.
    const raw = e instanceof Error ? e.message : "Search failed";
    console.error("[agents/search] upstream error:", raw);
    const notConfigured = /not configured/.test(raw);
    return NextResponse.json(
      { error: notConfigured ? "Agent search is temporarily unavailable." : "Search failed — try again." },
      { status: notConfigured ? 503 : 502 },
    );
  }
}