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