← back to Homesonspec
packages/search/src/index.ts
307 lines
import { prisma, Prisma } from "@homesonspec/database";
import { bboxAround, type BBox } from "@homesonspec/shared";
/**
* Search v1 — two steps:
* A) resolve free-text location (city / zip / community) → center + bbox
* via pg_trgm similarity against Community.searchText
* B) structured filtered page via Prisma findMany inside the bbox
* Organic order only — there is no paid placement anywhere in v1.
* All filters are objective (Fair Housing: no subjective neighborhood
* dimensions exist in the schema).
*/
export interface SearchParams {
q?: string;
textQuery?: string; // set when q doesn't resolve to a location — free-text filter fallback
bbox?: BBox; // explicit map-move bbox skips location resolution
priceMin?: number;
priceMax?: number;
bedsMin?: number;
bathsMin?: number;
sqftMin?: number;
sqftMax?: number;
homeTypes?: string[];
builderSlugs?: string[];
communitySlug?: string;
statuses?: string[]; // construction statuses
storiesEq?: number;
garageMin?: number;
hasIncentives?: boolean;
ageRestricted?: boolean;
hoaMax?: number;
schoolDistrict?: string;
state?: string; // single-state (e.g. location-derived)
states?: string[]; // multi-select state facet (Amazon-style rail)
city?: string;
moveInByMonths?: number; // move-in timeframe: ready or completing within N months
sort?: "newest" | "price_asc" | "price_desc" | "sqft_desc" | "closest";
page?: number;
pageSize?: number;
}
export interface ResolvedLocation {
label: string;
lat: number;
lon: number;
bbox: BBox;
}
const DEFAULT_RADIUS_MILES = 30;
export async function resolveLocation(q: string): Promise<ResolvedLocation | null> {
const query = q.trim();
if (!query) return null;
const rows = await prisma.$queryRaw<
{ id: string; name: string; city: string; state: string; lat: number | null; lon: number | null; sim: number }[]
>(Prisma.sql`
SELECT id, name, city, state, lat::float8 AS lat, lon::float8 AS lon,
similarity("searchText", ${query}) AS sim
FROM "Community"
WHERE "searchText" % ${query} OR zip = ${query} OR lower(city) = lower(${query})
ORDER BY (zip = ${query}) DESC, (lower(city) = lower(${query})) DESC, sim DESC
LIMIT 1
`);
const hit = rows[0];
if (!hit || hit.lat === null || hit.lon === null) return null;
return {
label: `${hit.city}, ${hit.state}`,
lat: hit.lat,
lon: hit.lon,
bbox: bboxAround(hit.lat, hit.lon, DEFAULT_RADIUS_MILES),
};
}
const STATE_NAME_TO_CODE: Record<string, string> = {
alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA", colorado: "CO",
connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA", hawaii: "HI", idaho: "ID",
illinois: "IL", indiana: "IN", iowa: "IA", kansas: "KS", kentucky: "KY", louisiana: "LA",
maine: "ME", maryland: "MD", massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS",
missouri: "MO", montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", ohio: "OH",
oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT", vermont: "VT", virginia: "VA",
washington: "WA", "west virginia": "WV", wisconsin: "WI", wyoming: "WY",
};
/** A query that didn't resolve to a location → match it against city/state/community/builder. */
function textQueryClause(q: string): Prisma.InventoryHomeWhereInput {
const t = q.trim();
const stateCode = STATE_NAME_TO_CODE[t.toLowerCase()] ?? (/^[A-Za-z]{2}$/.test(t) ? t.toUpperCase() : null);
const or: Prisma.InventoryHomeWhereInput[] = [
{ city: { contains: t, mode: "insensitive" } },
{ community: { name: { contains: t, mode: "insensitive" } } },
{ builder: { name: { contains: t, mode: "insensitive" } } },
...(stateCode ? [{ state: stateCode }] : []),
];
return { AND: [{ OR: or }] };
}
export function buildWhere(params: SearchParams, bbox?: BBox): Prisma.InventoryHomeWhereInput {
const now = new Date();
const moveInCutoff =
params.moveInByMonths !== undefined
? new Date(now.getFullYear(), now.getMonth() + params.moveInByMonths, now.getDate())
: undefined;
return {
status: "PUBLISHED",
isDemo: false, // HARD RULE: demonstration/synthetic inventory NEVER surfaces on the live site
freshness: { not: "INACTIVE" as const }, // Stage-3: deduped/retired records never surface (defense-in-depth over status)
...(params.textQuery ? textQueryClause(params.textQuery) : {}),
...(bbox
? {
lat: { gte: bbox.minLat, lte: bbox.maxLat },
lon: { gte: bbox.minLon, lte: bbox.maxLon },
}
: {}),
...(params.priceMin !== undefined || params.priceMax !== undefined
? { price: { ...(params.priceMin !== undefined ? { gte: params.priceMin } : {}), ...(params.priceMax !== undefined ? { lte: params.priceMax } : {}) } }
: {}),
...(params.bedsMin !== undefined ? { beds: { gte: params.bedsMin } } : {}),
...(params.bathsMin !== undefined ? { bathsTotal: { gte: params.bathsMin } } : {}),
...(params.sqftMin !== undefined || params.sqftMax !== undefined
? { sqft: { ...(params.sqftMin !== undefined ? { gte: params.sqftMin } : {}), ...(params.sqftMax !== undefined ? { lte: params.sqftMax } : {}) } }
: {}),
...(params.homeTypes?.length ? { homeType: { in: params.homeTypes as never[] } } : {}),
...(params.builderSlugs?.length ? { builder: { slug: { in: params.builderSlugs } } } : {}),
...(params.communitySlug ? { community: { slug: params.communitySlug } } : {}),
...(params.statuses?.length ? { constructionStatus: { in: params.statuses as never[] } } : {}),
...(params.storiesEq !== undefined ? { stories: params.storiesEq } : {}),
...(params.garageMin !== undefined ? { garageSpaces: { gte: params.garageMin } } : {}),
...(params.states?.length
? { state: { in: params.states } }
: params.state
? { state: params.state }
: {}),
...(params.city ? { city: { equals: params.city, mode: "insensitive" } } : {}),
...(moveInCutoff
? {
OR: [
{ constructionStatus: "MOVE_IN_READY" },
{ estCompletionDate: { lte: moveInCutoff } },
],
}
: {}),
...(params.hasIncentives
? {
OR: [
{ incentives: { some: {} } },
{ community: { incentives: { some: { OR: [{ expiresAt: { gte: now } }, { evergreenLabel: { not: null } }] } } } },
],
}
: {}),
...(params.ageRestricted !== undefined ? { community: { ageRestricted: params.ageRestricted } } : {}),
...(params.hoaMax !== undefined ? { community: { hoaFeeMonthly: { lte: params.hoaMax } } } : {}),
...(params.schoolDistrict ? { community: { schoolDistrict: params.schoolDistrict } } : {}),
};
}
const HOME_INCLUDE = {
community: { select: { slug: true, name: true, schoolDistrict: true, hoaFeeMonthly: true, ageRestricted: true } },
builder: { select: { slug: true, name: true } },
floorPlan: { select: { id: true, name: true } },
} satisfies Prisma.InventoryHomeInclude;
export type SearchHome = Prisma.InventoryHomeGetPayload<{ include: typeof HOME_INCLUDE }>;
export interface SearchResult {
homes: SearchHome[];
total: number;
page: number;
pageSize: number;
location: ResolvedLocation | null;
}
export async function runSearch(params: SearchParams): Promise<SearchResult> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(60, Math.max(1, params.pageSize ?? 24));
let location: ResolvedLocation | null = null;
let bbox = params.bbox;
if (!bbox && params.q) {
location = await resolveLocation(params.q);
bbox = location?.bbox;
// Query didn't resolve to a place → fall back to free-text so we never
// silently drop the query and return the entire national catalog.
if (!bbox) params = { ...params, textQuery: params.q };
}
const where = buildWhere(params, bbox);
const orderBy: Prisma.InventoryHomeOrderByWithRelationInput[] =
params.sort === "price_asc"
? [{ price: { sort: "asc", nulls: "last" } }]
: params.sort === "price_desc"
? [{ price: { sort: "desc", nulls: "last" } }]
: params.sort === "sqft_desc"
? [{ sqft: { sort: "desc", nulls: "last" } }]
: [{ publishedAt: "desc" }]; // newest — the organic default
// sort=closest is the one raw-SQL path (haversine); fall back to newest
// when there is no center point to measure from.
if (params.sort === "closest" && (location || params.bbox)) {
const centerLat = location?.lat ?? (params.bbox!.minLat + params.bbox!.maxLat) / 2;
const centerLon = location?.lon ?? (params.bbox!.minLon + params.bbox!.maxLon) / 2;
const [homesRaw, total] = await Promise.all([
prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT id FROM "InventoryHome"
WHERE status = 'PUBLISHED' AND "isDemo" = false
AND lat BETWEEN ${bbox!.minLat} AND ${bbox!.maxLat}
AND lon BETWEEN ${bbox!.minLon} AND ${bbox!.maxLon}
ORDER BY 2 * 3958.8 * asin(sqrt(
power(sin(radians((lat::float8 - ${centerLat}) / 2)), 2) +
cos(radians(${centerLat})) * cos(radians(lat::float8)) *
power(sin(radians((lon::float8 - ${centerLon}) / 2)), 2)
))
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
`),
prisma.inventoryHome.count({ where }),
]);
const ids = homesRaw.map((h) => h.id);
const unordered = await prisma.inventoryHome.findMany({ where: { id: { in: ids } }, include: HOME_INCLUDE });
const byId = new Map(unordered.map((h) => [h.id, h]));
return { homes: ids.map((id) => byId.get(id)!).filter(Boolean), total, page, pageSize, location };
}
const [homes, total] = await Promise.all([
prisma.inventoryHome.findMany({
where,
orderBy,
include: HOME_INCLUDE,
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.inventoryHome.count({ where }),
]);
return { homes, total, page, pageSize, location };
}
/**
* Facet counts with the self-dimension excluded (the corkwallcovering
* filterProducts(spec, skip) pattern): each facet's counts are computed
* with every OTHER filter applied but not its own.
*/
export async function runFacets(params: SearchParams) {
let bbox = params.bbox;
if (!bbox && params.q) {
bbox = (await resolveLocation(params.q))?.bbox;
if (!bbox) params = { ...params, textQuery: params.q };
}
const without = (key: keyof SearchParams): Prisma.InventoryHomeWhereInput =>
buildWhere({ ...params, [key]: undefined }, bbox);
const [byStatus, byBuilder, byBeds, byType, byState] = await Promise.all([
prisma.inventoryHome.groupBy({
by: ["constructionStatus"],
where: without("statuses"),
_count: { _all: true },
}),
prisma.inventoryHome.groupBy({
by: ["builderId"],
where: without("builderSlugs"),
_count: { _all: true },
}),
prisma.inventoryHome.groupBy({
by: ["beds"],
where: without("bedsMin"),
_count: { _all: true },
}),
prisma.inventoryHome.groupBy({
by: ["homeType"],
where: without("homeTypes"),
_count: { _all: true },
}),
prisma.inventoryHome.groupBy({
by: ["state"],
where: without("states"),
_count: { _all: true },
}),
]);
const builders = await prisma.builder.findMany({
where: { id: { in: byBuilder.map((b) => b.builderId) } },
select: { id: true, slug: true, name: true },
});
const builderById = new Map(builders.map((b) => [b.id, b]));
return {
constructionStatus: byStatus.map((s) => ({ value: s.constructionStatus, count: s._count._all })),
builder: byBuilder.map((b) => ({
value: builderById.get(b.builderId)?.slug ?? b.builderId,
label: builderById.get(b.builderId)?.name ?? b.builderId,
count: b._count._all,
})),
beds: byBeds
.filter((b) => b.beds !== null)
.sort((a, b) => (a.beds ?? 0) - (b.beds ?? 0))
.map((b) => ({ value: b.beds, count: b._count._all })),
homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })),
state: byState
.map((s) => ({ value: s.state, count: s._count._all }))
.sort((a, b) => b.count - a.count),
};
}