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