// ----------------------------------------------------------------------------- // Home-Ka — US real-estate aggregator (Groupe KA) // Author: Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + robust API client (timeout, typed errors) for the Home-Ka // FastAPI backend (homeka/web.py) — public search/map/detail + admin API. // ----------------------------------------------------------------------------- /** details: free-form dictionary of source characteristics (label → value). */ export interface ListingDetails { price_from?: boolean; cover_thumb?: string; photo_captions?: string[]; [key: string]: unknown; } /** A LISTING = one publication of a property by one source. */ export interface Listing { uid: string; source: string; external_id: string; property_id?: number | null; url: string; title: string; street_address: string; unit?: string | null; city: string; state: string; // 2-letter USPS zip_code: string; county: string; property_type: string; property_subtype?: string | null; list_price: number | null; price_label: string; bedrooms: number | null; bathrooms: number | null; // can be 2.5 bathrooms_full?: number | null; bathrooms_half?: number | null; living_area_sqft: number | null; lot_size_sqft: number | null; year_built: number | null; apn?: string | null; mls_id: string; mls_name?: string | null; status: string; // active | pending | sold | withdrawn | coming-soon listed_at?: string | null; days_on_market?: number | null; brokerage_name: string; office_name?: string | null; agent_name: string; agent_phone: string; agent_email?: string | null; description: string; features: string[]; details: ListingDetails; images: string[]; lat: number | null; lng: number | null; price_history?: { ts: number; price: number | null }[]; duplicates?: DuplicateListing[]; // same property published elsewhere property?: Property | null; // the physical PROPERTY behind this listing first_seen?: number; last_seen?: number; updated_at?: number; active?: number; published?: number; } export interface DuplicateListing { uid: string; source: string; url: string; brokerage_name: string; agent_name: string; price_label: string; } /** One row of a property's listing history (properties → listings). */ export interface PropertyListingRef { uid: string; source: string; status: string; list_price: number | null; first_seen: number | null; last_seen: number | null; active: number; } /** The physical PROPERTY — Home-Ka separates PROPERTY from LISTING. */ export interface Property { id: number; apn?: string | null; street_address?: string | null; unit?: string | null; city?: string | null; state?: string | null; zip_code?: string | null; county?: string | null; property_type?: string | null; year_built?: number | null; living_area_sqft?: number | null; lot_size_sqft?: number | null; lat?: number | null; lng?: number | null; details?: Record; first_seen?: number | null; last_seen?: number | null; listing_history?: PropertyListingRef[]; } export interface Facets { states: { state: string; n: number }[]; cities: { city: string; n: number }[]; property_types: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; connector_type: string | null; states: string[]; enabled: number; active_listings: number; last_sync: number | null; } export interface SyncLogRow { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string; } export interface Stats { total: number; sources: number; cities: number; states: number; avg_price: number | null; min_price: number | null; max_price: number | null; properties: number; by_state: { state: string; n: number; avg_price: number | null }[]; by_type: { property_type: string; n: number; avg_price: number | null }[]; quality?: Record; recent_syncs?: SyncLogRow[]; } // --- Source display names ----------------------------------------------------- const SOURCE_NAMES: Record = {}; export function registerSourceNames(sources: Source[]) { for (const s of sources) SOURCE_NAMES[s.id] = s.name; } export function sourceName(id: string): string { if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } async function get(path: string, headers?: Record): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 25000); try { const res = await fetch(path, { signal: ctrl.signal, headers }); if (!res.ok) throw new Error(`API ${res.status} — ${path}`); return (await res.json()) as T; } finally { clearTimeout(timer); } } async function post( path: string, body?: unknown, headers?: Record, ): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 60000); try { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", ...(headers ?? {}) }, body: body === undefined ? undefined : JSON.stringify(body), signal: ctrl.signal, }); if (!res.ok) throw new Error(`API ${res.status} — ${path}`); return (await res.json()) as T; } finally { clearTimeout(timer); } } export interface ListingFilters { q?: string; city?: string; state?: string; zip_code?: string; county?: string; property_type?: string; status?: string; source?: string; price_min?: string; price_max?: string; beds_min?: string; baths_min?: string; sqft_min?: string; sort?: string; // recent | price_asc | price_desc } export function listingParams(f: ListingFilters): URLSearchParams { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return params; } export function fetchListings(f: ListingFilters, limit = 60, offset = 0) { const params = listingParams(f); params.set("limit", String(limit)); params.set("offset", String(offset)); return get<{ total: number; count: number; listings: Listing[] }>( `/api/listings?${params}`); } export const fetchListing = (uid: string) => get(`/api/listings/${encodeURIComponent(uid)}`); export const fetchFacets = (state?: string) => get(`/api/facets${state ? `?state=${encodeURIComponent(state)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); // --- Formatting (US) ---------------------------------------------------------- export const fmtPrice = (p: number | null | undefined, label?: string) => p != null ? "$" + Math.round(p).toLocaleString("en-US") : label || "Price on request"; export const fmtArea = (a: number | null | undefined): string | null => a != null ? `${Math.round(a).toLocaleString("en-US")} sq ft` : null; export const fmtDate = (ts: number): string => new Date(ts * 1000).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", }); /** 2.5 → "2.5", 2 → "2" — bathrooms can be half-counts. */ export const fmtBaths = (b: number | null | undefined): string => b == null ? "—" : (Number.isInteger(b) ? String(b) : b.toFixed(1)); /** "Austin, TX 78704" from the parts that exist. */ export const fmtCityLine = ( l: { city?: string | null; state?: string | null; zip_code?: string | null }, ): string => [ [l.city, l.state].filter(Boolean).join(", "), l.zip_code ?? "", ].filter(Boolean).join(" "); export const STATUS_LABELS: Record = { "active": "For sale", "pending": "Pending", "sold": "Sold", "withdrawn": "Withdrawn", "coming-soon": "Coming soon", }; // ============================================================================= // Admin API — /api/admin/* (optional X-Admin-Token header, stored locally) // ============================================================================= export const ADMIN_TOKEN_KEY = "homeka_admin_token"; export function getAdminToken(): string { try { return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; } catch { return ""; } } export function setAdminToken(token: string) { try { if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token); else localStorage.removeItem(ADMIN_TOKEN_KEY); } catch { /* storage unavailable */ } } function adminHeaders(): Record { const t = getAdminToken(); return t ? { "X-Admin-Token": t } : {}; } export interface AdminOverview { active_listings: number; properties: number; enabled_sources: number; errors_24h: number; brokerages_by_status: { partnership_status: string; n: number }[]; last_successful_sync: number | null; } export interface AdminSource { id: string; name: string; connector_type: string | null; enabled: number; authority: number | null; states: string[]; notes: string; brokerage_id: number | null; config: Record; active_listings: number; published: number; no_geo: number; last_sync: number | null; last_ok: number | null; last_message: string | null; last_found: number | null; last_added: number | null; last_removed: number | null; errors_7d: number; freshness_hours: number | null; } export interface ConnectorFamily { family: string; class: string; module: string; doc: string; instances: number; } export interface CustomConnector { source_id: string; class: string; module: string; } export interface Brokerage { id: number; name: string; website: string | null; states: string[]; cities: string[]; estimated_agents: number | null; estimated_listings: number | null; mls_affiliations: string[]; idx_present: number | null; idx_provider: string | null; reso_detected: number | null; possible_feed_type: string | null; contact_page: string | null; partnership_contact: string | null; technical_contact: string | null; feed_probability_score: number | null; priority_score: number | null; partnership_status: string; source_id: string | null; last_inspected: number | null; inspect_error: string | null; evidence: Record; } export const PARTNERSHIP_STATUSES = [ "prospect", "to-contact", "contacted", "in-discussion", "feed-received", "live", "declined", ] as const; export const fetchAdminOverview = () => get("/api/admin/overview", adminHeaders()); export const fetchAdminSources = () => get<{ sources: AdminSource[] }>("/api/admin/sources", adminHeaders()); export const fetchAdminConnectors = () => get<{ families: ConnectorFamily[]; custom: CustomConnector[] }>( "/api/admin/connectors", adminHeaders()); export interface BrokerageFilters { state?: string; status?: string; feed_type?: string; idx_provider?: string; q?: string; min_priority?: string; inspected?: string; // "1" | "0" | "" sort?: string; // priority | feed | name | inspected } export function fetchAdminBrokerages(f: BrokerageFilters, limit = 50, offset = 0) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); params.set("limit", String(limit)); params.set("offset", String(offset)); return get<{ total: number; count: number; brokerages: Brokerage[] }>( `/api/admin/brokerages?${params}`, adminHeaders()); } export const inspectBrokerage = (id: number) => post>( `/api/admin/brokerages/${id}/inspect`, undefined, adminHeaders()); export const setBrokerageStatus = (id: number, status: string) => post<{ id: number; status: string }>( `/api/admin/brokerages/${id}/status`, { status }, adminHeaders()); export const runDiscovery = (limit = 25) => post<{ status: string; limit: number }>( `/api/admin/discover?limit=${limit}`, undefined, adminHeaders());