← back to Stayclaim

src/app/api/search/route.ts

58 lines

import { NextResponse } from 'next/server';
import { pool } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit';

/**
 * GET /api/search?q=...
 * Returns up to 6 listing matches + 4 entity matches.
 * Uses Postgres tsvector for listings (search_doc) and a plain ILIKE
 * for entity display_name (small table; index isn't worth it yet).
 */
export async function GET(req: Request) {
  const rl = await rateLimit('search');
  if (!rl.ok) {
    return NextResponse.json(
      { listings: [], entities: [], error: 'rate_limited' },
      { status: 429, headers: { 'Retry-After': String(rl.retryAfter ?? 60) } },
    );
  }

  const url = new URL(req.url);
  const q = (url.searchParams.get('q') ?? '').trim();
  if (q.length < 2 || q.length > 100) return NextResponse.json({ listings: [], entities: [] });

  // Escape LIKE metacharacters so '%', '_', '\' are matched literally.
  const likePattern = `%${q.replace(/[\\%_]/g, '\\$&')}%`;

  const [listingsResult, entitiesResult] = await Promise.allSettled([
    pool.query(
      `SELECT slug, title, city, state, address_line1, hero_image,
              ts_rank(search_doc, websearch_to_tsquery('english', $1)) AS rank
       FROM listing
       WHERE is_public = true
         AND (search_doc @@ websearch_to_tsquery('english', $1)
              OR title ILIKE $2
              OR address_line1 ILIKE $2)
       ORDER BY rank DESC NULLS LAST, ingested_at DESC
       LIMIT 6`,
      [q, likePattern]
    ),
    pool.query(
      `SELECT slug, display_name, kind, birth_year, death_year
       FROM entity
       WHERE display_name ILIKE $1
       ORDER BY display_name
       LIMIT 4`,
      [likePattern]
    ),
  ]);

  if (listingsResult.status === 'rejected') console.error('search.listings failed', listingsResult.reason);
  if (entitiesResult.status === 'rejected') console.error('search.entities failed', entitiesResult.reason);

  const listings = listingsResult.status === 'fulfilled' ? listingsResult.value.rows : [];
  const entities = entitiesResult.status === 'fulfilled' ? entitiesResult.value.rows : [];

  return NextResponse.json({ listings, entities });
}