Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/app/[lang]/listings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ const PAGE_SIZE = 20;

interface ListingsPageProps {
searchParams: Promise<{
/**
* Free-text title/description search. Matched server-side against
* the GIN(to_tsvector) full-text index. `q` is the conventional
* short URL param for keyword search; `query` is accepted as an
* alias for clarity.
*/
q?: string;
query?: string;
location?: string;
checkIn?: string;
checkOut?: string;
Expand Down Expand Up @@ -87,9 +95,16 @@ async function getFilteredListings(searchParams: ListingsPageProps["searchParams
// Pagination: `page` param is 1-indexed for humans; take/skip are 0-indexed.
const page = Math.max(1, toInt(params.page) ?? 1);

// Title/description full-text search. `q` is the conventional short
// form; `query` is accepted as an alias. Trim is also done in the
// server-action zod schema, but the URL-bar copy is friendlier with
// a leading trim here.
const rawQuery = (params.q ?? params.query ?? "").trim();

// searchListings() runs even without filters now — it's the only path that
// returns both paginated data AND a total count for the pagination UI.
const filters: SearchFilters = {
query: rawQuery || undefined,
location: params.location,
checkIn: params.checkIn,
checkOut: params.checkOut,
Expand Down Expand Up @@ -185,8 +200,12 @@ export default async function ListingsPage({ searchParams, params: pageParams }:
amenityLabels: rentalAmenities as Partial<Record<Amenity, string>>,
};

// Build search summary for display
// Build search summary for display. `q`/`query` get a quoted-keyword
// chip so users can see what they searched for and why a result set
// looks unfamiliar.
const searchSummary = [];
const summaryQuery = (params.q ?? params.query ?? "").trim();
if (summaryQuery) searchSummary.push(`"${summaryQuery}"`);
if (params.location) searchSummary.push(params.location);
if (params.guests) searchSummary.push(
`${params.guests} ${parseInt(params.guests) > 1
Expand Down
51 changes: 51 additions & 0 deletions src/lib/actions/search-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,39 @@ const SEARCH_LISTING_SELECT = {
},
} as const satisfies Prisma.ListingSelect;

/**
* Title/description full-text search via the existing
* `idx_listing_fulltext` GIN(to_tsvector(...)) index.
*
* Returns the IDs of matching published listings; the caller adds an
* `id IN (...)` predicate to its where-clause. Two-step instead of one
* `$queryRaw` for the whole search because the rest of the where-clause
* (price ranges, amenity arrays, date overlap subquery) is much cleaner
* to express through Prisma's typed query builder.
*
* `plainto_tsquery` (not `to_tsquery`) so the user input doesn't need to
* be operator-quoted — "luxury beach" parses as `luxury & beach`, and
* stop words / punctuation are handled by the parser. The 'english'
* dictionary is what the index was built with, so the operator can use
* the index directly.
*/
async function getFullTextMatchingIds(query: string): Promise<number[]> {
try {
const rows = await db.$queryRaw<Array<{ id: number }>>`
SELECT id FROM "Listing"
WHERE "isPublished" = true AND draft = false
AND to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(description, ''))
@@ plainto_tsquery('english', ${query})
`;
return rows.map((r) => r.id);
} catch {
// If pg_trgm / fulltext isn't available (e.g., the migration that
// created idx_listing_fulltext hasn't run), bail gracefully — the
// outer search will return all listings instead of zero.
return [];
}
}

const cachedListingSearch = unstable_cache(
async (
normalized: string
Expand All @@ -273,6 +306,15 @@ const cachedListingSearch = unstable_cache(
const take = Math.min(f.take ?? 20, 50);
const skip = f.skip ?? 0;

// Full-text branch: when query is set, intersect the where-clause
// with the IDs returned by the GIN index. An empty match short-
// circuits the page query — avoids a `WHERE id IN ()` round-trip.
if (f.query) {
const ids = await getFullTextMatchingIds(f.query);
if (ids.length === 0) return [];
where.id = { in: ids };
}

return db.listing.findMany({
where,
select: SEARCH_LISTING_SELECT,
Expand All @@ -291,6 +333,15 @@ const cachedListingCount = unstable_cache(
async (normalized: string): Promise<number> => {
const parsed = JSON.parse(normalized) as ReturnType<typeof listingFilterSchema.parse>;
const where = buildSearchWhere(parsed);

// Same full-text intersection as the page query so the count
// matches what the user actually sees.
if (parsed.query) {
const ids = await getFullTextMatchingIds(parsed.query);
if (ids.length === 0) return 0;
where.id = { in: ids };
}

return db.listing.count({ where });
},
["search-listings-count"],
Expand Down
11 changes: 11 additions & 0 deletions src/lib/schemas/search-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ export interface LocationSuggestion {
// Search filters type for server action. Keep in sync with `listingFilterSchema`
// below — every field here needs a Zod parser or it'll bypass validation.
export interface SearchFilters {
/**
* Free-text query matched against listing title + description via
* Postgres full-text search. The match is index-backed by the
* `idx_listing_fulltext` GIN(to_tsvector(...)) index that lives on
* the Listing table; an empty / undefined query short-circuits the
* full-text branch entirely.
*/
query?: string;
location?: string;
checkIn?: string;
checkOut?: string;
Expand All @@ -142,6 +150,9 @@ export interface SearchFilters {
// The `searchFormSchema` above is form-level (rejects past check-ins etc.);
// this one is query-level (rejects out-of-range numbers, unknown enum values).
export const listingFilterSchema = z.object({
// Title/description search. Capped at 200 chars to keep
// plainto_tsquery cheap and bound the cache key size.
query: z.string().trim().min(1).max(200).optional(),
location: z.string().max(200).optional(),
checkIn: z.string().optional(),
checkOut: z.string().optional(),
Expand Down
47 changes: 46 additions & 1 deletion tests/actions/search-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
$queryRaw: vi.fn(),
// $queryRaw is used by getLocationSuggestions/getPopularLocations
// (SQL groupBy autocomplete, PR #19) AND by getFullTextMatchingIds
// (title/description keyword search via this PR). Each test that
// hits one of those paths sets the resolved value explicitly.
// Default `[]` keeps tests that don't pass `query` from leaking
// stale state into other suites.
$queryRaw: vi.fn().mockResolvedValue([]),
},
}));

Expand Down Expand Up @@ -231,4 +237,43 @@ describe("searchListings", () => {
expect(result.error).toContain("Failed to search");
expect(result.data).toEqual([]);
});

it("intersects findMany with full-text matching IDs when query is set", async () => {
// First call: getFullTextMatchingIds for the page query.
// Second call: getFullTextMatchingIds for the count query.
// Both return the same set because the query is the same.
mockDb.$queryRaw
.mockResolvedValueOnce([{ id: 7 }, { id: 12 }])
.mockResolvedValueOnce([{ id: 7 }, { id: 12 }]);
mockDb.listing.findMany.mockResolvedValue([{ id: 7 }, { id: 12 }] as never);
mockDb.listing.count.mockResolvedValue(2 as never);

const result = await searchListings({ query: "beach view" });

expect(result.success).toBe(true);
const where = mockDb.listing.findMany.mock.calls[0][0]?.where;
expect(where?.id).toEqual({ in: [7, 12] });
expect(result.data).toHaveLength(2);
});

it("short-circuits to empty when full-text matches nothing", async () => {
mockDb.$queryRaw.mockResolvedValue([]);

const result = await searchListings({ query: "zzznever" });

expect(result.success).toBe(true);
expect(result.data).toEqual([]);
// findMany should never be hit — empty IDs short-circuits before the query.
expect(mockDb.listing.findMany).not.toHaveBeenCalled();
});

it("rejects empty-string query at the validation boundary", async () => {
// Zod's `.min(1)` on the query field means an empty string is
// invalid (whitespace also trims to empty). Treats it as "no
// query" rather than "match everything", which would be a footgun.
const result = await searchListings({ query: " " });

expect(result.success).toBe(false);
expect(result.data).toEqual([]);
});
});
Loading