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.md2026-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$filteroverride + office/agent scope — extend to board + postal scoping (National Shared Pool feed). Keep the manual-safe diff (only touchsource='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:
REListinginsrc/lib/real-estate/types.ts. - Public pages: the
/s/[slug]/listing/[mls]detail page + maps +ListingFilterscomponent 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 SellingToolzCreaFeedconfig.buildFilter()composes the OData filter from these. - Sync writes
source='ddf'rows intolocal_business_listingswithbusiness_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(orbusiness_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 thanpageSizerows. 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 inchurchwiseai-web/migrations/. - Server-side sort +
.range();count: 'exact'only when needed for pagination UI. - Consider a
unstable_cache/revalidateTaglayer keyed by (tenant, filter, page) for hot queries; invalidate on sync.
7. Public UI
- Dedicated results surface (e.g.
/s/[slug]/searchor 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
- 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). - Each filter (city, price min/max, beds, baths, type, keyword) narrows results server-side; combining filters ANDs correctly; total count reflects the filter.
- Sorting (newest / price asc / price desc) is applied in the DB, not the client.
- Pagination: page 2 returns the next slice; total pages = ceil(total/pageSize).
- Tenant isolation: a request for tenant A never returns tenant B's rows.
- Rate-limit trips on abuse; invalid/oversized params are clamped, never error.
- Homepage featured carousel is unaffected (still the small curated set).
- 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
EXPLAINuses 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.md2026-07-02 (CREA registration gate).