Skip to main content

Acceptance Spec — Realtor Full-Board Listing Search (P0 MVP)

  • Status: Proposed (MVP) — 2026-07-02
  • Priority: P0 — the foundation of SellingToolz/AdvanceWebsites parity. Every listing feature depends on it. See WISEAI_REALTOR_BACKEND_REPLACEMENT_REQUIREMENTS_2026-07-02.md §3.
  • Product: WiseAI Realtor Pro Website (/s/[slug])
  • Gate: ships behind the CREA technology-provider registration + authorized-board scoping (FOUNDER_ACTIONS.md 2026-07-02). Build inert; do not display board-scale data on a public site until the provider registration is complete and the feed is legitimate.

1. Why

Our template currently loads a tenant's listings into the browser and filters the array client-side (RealEstateSiteTemplate.tsx) — fine for Beckett's ~11 office listings, impossible for the thousands a full-board CREA feed returns (their live site searches London & St. Thomas + Woodstock-Ingersoll-Tillsonburg = ~3,878 active). To match SellingToolz's "CREA Full Board Listing Search," we need server-side search + pagination over a board-scaled dataset. This is the one build that gates listing parity.

2. Scope

In: (a) a feed path that syncs board-scoped active listings at scale; (b) a server-side search API with filters + sorting + pagination; (c) a public MLS search form + paginated results (grid + map) that calls the API. Out: VOW/sold data (separate track), saved-search alerts (consumer-auth track), TRREB, any non-active statuses.

3. Do NOT reinvent — extend these (verified present)

  • Feed: src/lib/real-estate/ddf/{client,map,sync,reso-types}.ts. sync.ts:buildFilter() already supports a raw OData $filter override + office/agent scope — extend to board + postal scoping (National Shared Pool feed). Keep the manual-safe diff (only touch source='ddf' rows).
  • Storage: local_business_listings (schema confirmed 2026-07-02: business_id, status, address, neighbourhood, city, list_price, beds, baths, property_type, sqft, year_built, mls_number, latitude, longitude, photos, source, ddf_id, is_published, sort_order, listed_at, listing_agent_name). Reuse; add indexes (§6).
  • Reader: src/lib/real-estate/server/listings.ts (getRenderListingsForBusiness) — keep for the homepage featured carousel; add a NEW paginated search function beside it (don't overload the render reader).
  • Types: REListing in src/lib/real-estate/types.ts.
  • Public pages: the /s/[slug]/listing/[mls] detail page + maps + ListingFilters component already exist — reuse; the new results page feeds them.

4. Data / feed

  • Extend the per-tenant DDF config (metadata.ddf) to carry board scope (crea_board_id[], e.g. London & St. Thomas + Woodstock) and/or postal codes (e.g. N4S,N4T,N0J,N0P,N5C,N0B), mirroring the SellingToolz CreaFeed config. buildFilter() composes the OData filter from these.
  • Sync writes source='ddf' rows into local_business_listings with business_id = tenant. Decision to flag (not MVP-blocking): per-tenant copies (simple, matches current model, duplicates across tenants sharing a board) vs. a shared board-keyed pool (storage-efficient, licence-isolation risk). MVP = per-tenant; log the row count so scale is visible.
  • Only status = active-equivalent + is_published/authorized rows are searchable. Honor DDF display/attribution rules.

5. Search API (new)

GET /api/real-estate/listings/search — server-side, tenant-scoped, paginated.

  • Params: slug (or business_id), city, priceMin, priceMax, bedsMin, bathsMin, type (property_type), keyword (address/neighbourhood/city), sort (newest|price_asc|price_desc), page (1-based), pageSize (default 24, max 50).
  • Behavior: builds a filtered Supabase query with .range() pagination + count: 'exact' for the total; returns { results: REListing[], total, page, pageSize }. Never returns more than pageSize rows. Match the SellingToolz MLS form fields exactly: City/Province, Price Range, Bedrooms, Bathrooms, Type.
  • Guards: rate-limit per IP (reuse the pattern from the audit-hardened places-nearby); input-validate + clamp all numeric params; tenant-scope every query (never cross-tenant); fail closed to empty results on error (never 500 the page).
  • No client-side full-dataset load — the browser only ever holds one page.

6. Performance

  • Indexes on local_business_listings: (business_id, status, is_published), (business_id, city), (business_id, list_price), (business_id, beds), (business_id, property_type), (business_id, listed_at DESC). Migration in churchwiseai-web/migrations/.
  • Server-side sort + .range(); count: 'exact' only when needed for pagination UI.
  • Consider a unstable_cache/revalidateTag layer keyed by (tenant, filter, page) for hot queries; invalidate on sync.

7. Public UI

  • Dedicated results surface (e.g. /s/[slug]/search or the Listings page) — NOT the homepage. The homepage keeps its featured/scroll carousel (small, getRenderListingsForBusiness); full-board search lives on its own paginated page.
  • MLS search form: City/Province, Price Range (min/max), Bedrooms, Bathrooms, Type — submits to the API (query params in the URL so results are shareable/back-button-safe; never put PII in the query, only search filters).
  • Results: paginated grid (reuse listing card) + map toggle (reuse existing Leaflet map); "N properties found," sort control, pagination (or infinite scroll) — one page of results at a time.
  • Empty/loading/error states honest; reduced-motion safe; mobile-first.
  • Hydration-safe (no Date.now()/toLocale* without timezone/client-gate — the ESLint guard from PR #1097/#1099 applies).

8. Acceptance criteria

  1. A tenant with thousands of synced source='ddf' rows returns only one page (≤ pageSize) per request; the browser never loads the full set (verify network payload size + DOM node count).
  2. Each filter (city, price min/max, beds, baths, type, keyword) narrows results server-side; combining filters ANDs correctly; total count reflects the filter.
  3. Sorting (newest / price asc / price desc) is applied in the DB, not the client.
  4. Pagination: page 2 returns the next slice; total pages = ceil(total/pageSize).
  5. Tenant isolation: a request for tenant A never returns tenant B's rows.
  6. Rate-limit trips on abuse; invalid/oversized params are clamped, never error.
  7. Homepage featured carousel is unaffected (still the small curated set).
  8. Inert until the tenant's feed is authorized (CREA registration) + board-scoped; no board-scale data on a public site before then.

9. Verification (deployed URL)

  • Seed the TeamMoelker/test tenant with a large synthetic source='ddf' set (hundreds+), then Playwright on the deployed /s/[slug]/search: assert (a) network response is one page not the whole set, (b) filters change results + total, (c) sort order changes, (d) page 2 differs from page 1, (e) tenant isolation. Evidence-or-nothing (Rule #20).
  • Confirm DB EXPLAIN uses the new indexes (no seq scan on the hot filters).
  • Host-aliased: verify on the real Pro Website host, not a bare preview.

10. See also

  • knowledge/products/wiseai-realtor/do-not-reinvent.md (reuse map) · vow-sold-data-track.md (the sold/VOW sibling track) · WISEAI_REALTOR_BACKEND_REPLACEMENT_REQUIREMENTS_2026-07-02.md (parent build brief) · FOUNDER_ACTIONS.md 2026-07-02 (CREA registration gate).