// ----------------------------------------------------------------------------- // House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first) // Author: Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + robust API client (timeout, typed errors) // ----------------------------------------------------------------------------- export interface Room { nom?: string; niveau?: string; dimensions?: string; revetement?: string; } /** details: free-form dictionary of DDF fields (label → value), with the * special keys `pieces` (rooms) and `photo_captions`. */ export interface ListingDetails { pieces?: Room[]; price_from?: boolean; [key: string]: unknown; } export interface Listing { uid: string; source: string; external_id: string; url: string; title: string; address: string; sector: string; city: string; region: string; property_type: string; price: number | null; price_label: string; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null; mls: string; status: string; broker_name: string; broker_phone: string; description: string; features: string[]; details: ListingDetails; images: string[]; lat: number | null; lng: number | null; price_history?: { ts: number; price: number | null }[]; duplicates?: DuplicateListing[]; // other publications of the same property poi?: Poi[]; // nearby amenities (listing page only) first_seen?: number; last_seen?: number; updated_at?: number; active?: number; days_on_market?: number; } export interface DuplicateListing { uid: string; source: string; url: string; broker_name: string; agency: string; price_label: string; } export interface Poi { cat: string; name: string; dist_m: number } export interface Facets { cities: string[]; sectors: string[]; property_types: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; listing_url?: string; coverage?: string; type?: string; connector?: string | null; status: string; active_listings: number; last_sync: number | null; } export interface Stats { total: number; sources: number; cities: number; avg_price: number | null; min_price: number | null; max_price: number | null; recent_syncs?: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string; }[]; qualite?: Quality; } /** Data quality (completeness, quarantine, anomalies) — immoka/quality.py */ export interface Quality { actives: number; publiees: number; quarantaine: number; completude_moyenne: number | null; anomalies: Record; par_source: { source: string; n: number; publiees: number; completude: number | null; anomalies: number; }[]; } // --- Source names (pretty labels) -------------------------------------------- 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]; // readable fallback for generated RealtyPress sources (rp_ag_xxx) const base = id.replace(/^rp_ag_/, "").replace(/^rp_/, ""); return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } async function get(path: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 25000); try { const res = await fetch(path, { 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 { city?: string; sector?: string; region?: string; property_type?: string; source?: string; price_min?: string; price_max?: string; bedrooms_min?: string; bathrooms_min?: string; area_min?: string; q?: string; sort?: string; // price_asc | price_desc | recent } 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 = (city?: string) => get(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); export interface SubAgency { name: string; count: number; sources: string[] } export interface Franchise { franchise: string; total: number; sub_agencies: number; agencies: SubAgency[]; } export const fetchAgencies = () => get<{ franchises: Franchise[] }>("/api/agencies"); // --- Formatting --------------------------------------------------------------- export const fmtPrice = (p: number | null, label?: string) => p != null ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 }) : label || "Price on request"; export const fmtArea = (a: number | null): string | null => a != null ? `${Math.round(a).toLocaleString("en-CA")} sq ft` : null; export const fmtDate = (ts: number): string => new Date(ts * 1000).toLocaleDateString("en-CA", { day: "numeric", month: "long", year: "numeric", }); /** 250 -> "250 m", 1240 -> "1.2 km" */ export const fmtDist = (m: number): string => m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`; // ----------------------------------------------------------------------------- // Mortgage rates (immoka/mortgage) — real rates observed at the banks // ----------------------------------------------------------------------------- export interface MortgageRate { provider: string; institution: string; product_key: string; product_name: string | null; rate_type: "fixed" | "variable" | "adjustable" | "other"; term_months: number; kind: "posted" | "special"; rate: number; apr: number | null; insured_status: "insured" | "insurable" | "uninsured" | "unknown"; purpose: string; conditions: string | null; source_url: string | null; last_checked: number; age_minutes: number; stale: boolean; } export interface MortgageBest extends MortgageRate { median_rate: number | null; institutions_count: number; per_institution: MortgageRate[]; } export interface MortgageMarket { rate_type: string; term_months: number; best: number; best_provider: string; best_institution: string; best_kind: string; median: number | null; spread: number | null; institutions_count: number; var_7d: number | null; var_30d: number | null; var_90d: number | null; lowest_6m: number | null; } export interface MortgageIntelligence { products: MortgageMarket[]; prime_rates: { institution: string; rate: number; product_name: string; age_minutes: number }[]; } export interface MortgageHistoryRow { provider: string; institution: string; product_name: string | null; kind: string; rate: number; insured_status: string; valid_from: number; valid_to: number | null; last_checked: number; source_url: string | null; } export interface MortgageProviderHealth { provider: string; institution: string; source_url: string | null; level: "OK" | "WARNING" | "ERROR"; status: string | null; age_minutes: number; current_products: number; last_data_at: number | null; } export interface MortgageRateSource { provider: string; institution: string; product_name: string | null; kind: string; rate: number; apr?: number | null; insured_status?: string; source_url: string | null; last_checked?: number; age_minutes: number; stale: boolean; } export interface MortgageInsurance { required: boolean; eligible: boolean; premium: number; premium_rate: number; loan_before: number; total_mortgage: number; qc_tax: number; ltv: number | null; issues: string[]; } export interface MortgageCalc { inputs: { price: number; down_payment: number; down_payment_pct: number; rate: number; rate_type: string; term_months: number; amortization_years: number; frequency: string; compounding: string; }; insurance: MortgageInsurance; principal: number; payment: number; payment_monthly_equivalent: number; qualifying: { rate: number; payment: number; note: string }; term: { payment: number; frequency: string; payments_per_year: number; payments_in_term: number; annual_cost: number; principal_paid: number; interest_paid: number; balance_end_of_term: number; paid_off: boolean; }; stress: { bump: number; rate: number; payment: number }[]; renewal: { balance_at_renewal: number; remaining_amortization_years: number; scenarios: { bump: number; rate: number; payment: number }[]; }; payoff_years: number; rate_source: MortgageRateSource | null; annual: { year: number; payment: number; interest: number; principal: number; balance: number }[]; ratios?: { gds: number | null; tds: number | null; gds_ok: boolean | null; tds_ok: boolean | null }; } export interface MortgageCalcInput { price: number; down_payment?: number; down_payment_pct?: number; amortization_years?: number; term_months?: number; frequency?: string; rate_type?: "fixed" | "variable"; rate?: number; income?: number; property_tax_monthly?: number; heating_monthly?: number; condo_fees_monthly?: number; other_debts_monthly?: number; } async function post(path: string, body: unknown): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 25000); try { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: 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 const fetchMortgageIntelligence = () => get("/api/mortgage/intelligence"); export const fetchMortgageBest = (rateType: string, termMonths: number) => get( `/api/mortgage/rates/best?rate_type=${rateType}&term_months=${termMonths}`); export const fetchMortgageHistory = ( rateType: string, termMonths: number, days = 365, kind?: string, ) => get<{ count: number; days: number; history: MortgageHistoryRow[] }>( `/api/mortgage/rates/history?rate_type=${rateType}` + `&term_months=${termMonths}&days=${days}${kind ? `&kind=${kind}` : ""}`); export const fetchMortgageProviders = () => get<{ providers: MortgageProviderHealth[]; registered: string[] }>( "/api/mortgage/providers"); export const calculateMortgage = (input: MortgageCalcInput) => post("/api/mortgage/calculate", input); /** 4.19 -> "4.19%" */ export const fmtRate = (r: number | null | undefined): string => r == null ? "—" : `${r.toFixed(2)}%`; // ----------------------------------------------------------------------------- // Nearby places (Mapbox Search Box + OSM) — listing page block // ----------------------------------------------------------------------------- export interface CommerceItem { id: string; commerce: string; nom: string; adresse: string; dist_m: number; lat: number; lng: number; } export interface CommercesNearby { n: number; commerces: CommerceItem[]; transit?: CommerceItem[]; } export const fetchCommerces = (lat: number, lng: number, region?: string) => get( `/api/commerces?lat=${lat}&lng=${lng}` + (region ? `®ion=${encodeURIComponent(region)}` : "")); // ----------------------------------------------------------------------------- // KA ID account (SSO hub groupe-ka.com) + "My Ka universe" favourites // ----------------------------------------------------------------------------- export type Socials = Partial>; export interface User { sub: string; email: string; name: string; picture?: string; ka_id?: string; provider?: string; // "ka-id" | "google" created_at?: number | null; // epoch (s) last_login?: number | null; // epoch (s) // profile enriched by the Groupe KA HUB (source of truth — groupe-ka.com/compte) bio?: string; city?: string; phone?: string; website?: string; socials?: Socials; public?: boolean; role_label?: string; job_title?: string; company?: string; age?: number | null; public_url?: string; profile_source?: string; // "groupe-ka" | "local" } /** Session profile ({user: null} if signed out; `enabled` = SSO configured). */ export const fetchMe = () => get<{ user: User | null; enabled: boolean }>("/api/auth/me"); export const logout = () => fetch("/api/auth/logout", { method: "POST" }); /** Favourite item as expected by the hub (see immoka/favorites.py). */ export interface FavItem { item_id: string; title: string; subtitle: string; price_label: string; image_url: string; url: string; } export function favItemFromListing(l: Listing): FavItem { return { item_id: l.uid, title: l.title || l.address || l.property_type || "Property", subtitle: [l.city, l.region].filter(Boolean).join(", "), price_label: l.price_label || fmtPrice(l.price), image_url: l.images?.[0] ?? "", url: `/property/${l.uid}`, }; } export const fetchFavorites = () => get<{ ids: string[]; items: FavItem[] }>("/api/favorites"); export async function toggleFavorite(on: boolean, item: FavItem) { const res = await fetch("/api/favorites/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on, item }), }); if (!res.ok) throw new Error(`favourites ${res.status}`); return (await res.json()) as { ok: boolean; on: boolean }; }