SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
14.2 KB · 456 lines typescript
Raw Blame History
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + robust API client (timeout, typed errors)5// -----------------------------------------------------------------------------67export interface Room {8  nom?: string;9  niveau?: string;10  dimensions?: string;11  revetement?: string;12}1314/** details: free-form dictionary of DDF fields (label → value), with the15 *  special keys `pieces` (rooms) and `photo_captions`. */16export interface ListingDetails {17  pieces?: Room[];18  price_from?: boolean;19  [key: string]: unknown;20}2122export interface Listing {23  uid: string;24  source: string;25  external_id: string;26  url: string;27  title: string;28  address: string;29  sector: string;30  city: string;31  region: string;32  property_type: string;33  price: number | null;34  price_label: string;35  bedrooms: number | null;36  bathrooms: number | null;37  powder_rooms: number | null;38  area_sqft: number | null;39  lot_sqft: number | null;40  year_built: number | null;41  mls: string;42  status: string;43  broker_name: string;44  broker_phone: string;45  description: string;46  features: string[];47  details: ListingDetails;48  images: string[];49  lat: number | null;50  lng: number | null;51  price_history?: { ts: number; price: number | null }[];52  duplicates?: DuplicateListing[];  // other publications of the same property53  poi?: Poi[];                      // nearby amenities (listing page only)54  first_seen?: number;55  last_seen?: number;56  updated_at?: number;57  active?: number;58  days_on_market?: number;59}6061export interface DuplicateListing {62  uid: string;63  source: string;64  url: string;65  broker_name: string;66  agency: string;67  price_label: string;68}6970export interface Poi { cat: string; name: string; dist_m: number }7172export interface Facets {73  cities: string[];74  sectors: string[];75  property_types: string[];76  sources: { source: string; n: number }[];77}7879export interface Source {80  id: string;81  name: string;82  url: string;83  listing_url?: string;84  coverage?: string;85  type?: string;86  connector?: string | null;87  status: string;88  active_listings: number;89  last_sync: number | null;90}9192export interface Stats {93  total: number;94  sources: number;95  cities: number;96  avg_price: number | null;97  min_price: number | null;98  max_price: number | null;99  recent_syncs?: {100    source: string; ts: number; found: number; added: number;101    updated: number; removed: number; ok: number; message: string;102  }[];103  qualite?: Quality;104}105106/** Data quality (completeness, quarantine, anomalies) — immoka/quality.py */107export interface Quality {108  actives: number;109  publiees: number;110  quarantaine: number;111  completude_moyenne: number | null;112  anomalies: Record<string, number>;113  par_source: {114    source: string; n: number; publiees: number;115    completude: number | null; anomalies: number;116  }[];117}118119// --- Source names (pretty labels) --------------------------------------------120const SOURCE_NAMES: Record<string, string> = {};121export function registerSourceNames(sources: Source[]) {122  for (const s of sources) SOURCE_NAMES[s.id] = s.name;123}124export function sourceName(id: string): string {125  if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];126  // readable fallback for generated RealtyPress sources (rp_ag_xxx)127  const base = id.replace(/^rp_ag_/, "").replace(/^rp_/, "");128  return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());129}130131async function get<T>(path: string): Promise<T> {132  const ctrl = new AbortController();133  const timer = setTimeout(() => ctrl.abort(), 25000);134  try {135    const res = await fetch(path, { signal: ctrl.signal });136    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);137    return (await res.json()) as T;138  } finally {139    clearTimeout(timer);140  }141}142143export interface ListingFilters {144  city?: string;145  sector?: string;146  region?: string;147  property_type?: string;148  source?: string;149  price_min?: string;150  price_max?: string;151  bedrooms_min?: string;152  bathrooms_min?: string;153  area_min?: string;154  q?: string;155  sort?: string;      // price_asc | price_desc | recent156}157158export function listingParams(f: ListingFilters): URLSearchParams {159  const params = new URLSearchParams();160  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);161  return params;162}163164export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {165  const params = listingParams(f);166  params.set("limit", String(limit));167  params.set("offset", String(offset));168  return get<{ total: number; count: number; listings: Listing[] }>(169    `/api/listings?${params}`);170}171172export const fetchListing = (uid: string) =>173  get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);174export const fetchFacets = (city?: string) =>175  get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);176export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");177export const fetchStats = () => get<Stats>("/api/stats");178179export interface SubAgency { name: string; count: number; sources: string[] }180export interface Franchise {181  franchise: string;182  total: number;183  sub_agencies: number;184  agencies: SubAgency[];185}186export const fetchAgencies = () =>187  get<{ franchises: Franchise[] }>("/api/agencies");188189// --- Formatting ---------------------------------------------------------------190export const fmtPrice = (p: number | null, label?: string) =>191  p != null192    ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })193    : label || "Price on request";194195export const fmtArea = (a: number | null): string | null =>196  a != null ? `${Math.round(a).toLocaleString("en-CA")} sq ft` : null;197198export const fmtDate = (ts: number): string =>199  new Date(ts * 1000).toLocaleDateString("en-CA", {200    day: "numeric", month: "long", year: "numeric",201  });202203/** 250 -> "250 m", 1240 -> "1.2 km" */204export const fmtDist = (m: number): string =>205  m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;206207// -----------------------------------------------------------------------------208// Mortgage rates (immoka/mortgage) — real rates observed at the banks209// -----------------------------------------------------------------------------210export interface MortgageRate {211  provider: string;212  institution: string;213  product_key: string;214  product_name: string | null;215  rate_type: "fixed" | "variable" | "adjustable" | "other";216  term_months: number;217  kind: "posted" | "special";218  rate: number;219  apr: number | null;220  insured_status: "insured" | "insurable" | "uninsured" | "unknown";221  purpose: string;222  conditions: string | null;223  source_url: string | null;224  last_checked: number;225  age_minutes: number;226  stale: boolean;227}228229export interface MortgageBest extends MortgageRate {230  median_rate: number | null;231  institutions_count: number;232  per_institution: MortgageRate[];233}234235export interface MortgageMarket {236  rate_type: string;237  term_months: number;238  best: number;239  best_provider: string;240  best_institution: string;241  best_kind: string;242  median: number | null;243  spread: number | null;244  institutions_count: number;245  var_7d: number | null;246  var_30d: number | null;247  var_90d: number | null;248  lowest_6m: number | null;249}250251export interface MortgageIntelligence {252  products: MortgageMarket[];253  prime_rates: { institution: string; rate: number; product_name: string;254                 age_minutes: number }[];255}256257export interface MortgageHistoryRow {258  provider: string; institution: string; product_name: string | null;259  kind: string; rate: number; insured_status: string;260  valid_from: number; valid_to: number | null; last_checked: number;261  source_url: string | null;262}263264export interface MortgageProviderHealth {265  provider: string; institution: string; source_url: string | null;266  level: "OK" | "WARNING" | "ERROR"; status: string | null;267  age_minutes: number; current_products: number; last_data_at: number | null;268}269270export interface MortgageRateSource {271  provider: string; institution: string; product_name: string | null;272  kind: string; rate: number; apr?: number | null; insured_status?: string;273  source_url: string | null; last_checked?: number;274  age_minutes: number; stale: boolean;275}276277export interface MortgageInsurance {278  required: boolean; eligible: boolean; premium: number; premium_rate: number;279  loan_before: number; total_mortgage: number; qc_tax: number;280  ltv: number | null; issues: string[];281}282283export interface MortgageCalc {284  inputs: {285    price: number; down_payment: number; down_payment_pct: number;286    rate: number; rate_type: string; term_months: number;287    amortization_years: number; frequency: string; compounding: string;288  };289  insurance: MortgageInsurance;290  principal: number;291  payment: number;292  payment_monthly_equivalent: number;293  qualifying: { rate: number; payment: number; note: string };294  term: {295    payment: number; frequency: string; payments_per_year: number;296    payments_in_term: number; annual_cost: number; principal_paid: number;297    interest_paid: number; balance_end_of_term: number; paid_off: boolean;298  };299  stress: { bump: number; rate: number; payment: number }[];300  renewal: {301    balance_at_renewal: number; remaining_amortization_years: number;302    scenarios: { bump: number; rate: number; payment: number }[];303  };304  payoff_years: number;305  rate_source: MortgageRateSource | null;306  annual: { year: number; payment: number; interest: number;307            principal: number; balance: number }[];308  ratios?: { gds: number | null; tds: number | null;309             gds_ok: boolean | null; tds_ok: boolean | null };310}311312export interface MortgageCalcInput {313  price: number;314  down_payment?: number;315  down_payment_pct?: number;316  amortization_years?: number;317  term_months?: number;318  frequency?: string;319  rate_type?: "fixed" | "variable";320  rate?: number;321  income?: number;322  property_tax_monthly?: number;323  heating_monthly?: number;324  condo_fees_monthly?: number;325  other_debts_monthly?: number;326}327328async function post<T>(path: string, body: unknown): Promise<T> {329  const ctrl = new AbortController();330  const timer = setTimeout(() => ctrl.abort(), 25000);331  try {332    const res = await fetch(path, {333      method: "POST",334      headers: { "Content-Type": "application/json" },335      body: JSON.stringify(body),336      signal: ctrl.signal,337    });338    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);339    return (await res.json()) as T;340  } finally {341    clearTimeout(timer);342  }343}344345export const fetchMortgageIntelligence = () =>346  get<MortgageIntelligence>("/api/mortgage/intelligence");347348export const fetchMortgageBest = (rateType: string, termMonths: number) =>349  get<MortgageBest>(350    `/api/mortgage/rates/best?rate_type=${rateType}&term_months=${termMonths}`);351352export const fetchMortgageHistory = (353  rateType: string, termMonths: number, days = 365, kind?: string,354) =>355  get<{ count: number; days: number; history: MortgageHistoryRow[] }>(356    `/api/mortgage/rates/history?rate_type=${rateType}` +357    `&term_months=${termMonths}&days=${days}${kind ? `&kind=${kind}` : ""}`);358359export const fetchMortgageProviders = () =>360  get<{ providers: MortgageProviderHealth[]; registered: string[] }>(361    "/api/mortgage/providers");362363export const calculateMortgage = (input: MortgageCalcInput) =>364  post<MortgageCalc>("/api/mortgage/calculate", input);365366/** 4.19 -> "4.19%" */367export const fmtRate = (r: number | null | undefined): string =>368  r == null ? "—" : `${r.toFixed(2)}%`;369370// -----------------------------------------------------------------------------371// Nearby places (Mapbox Search Box + OSM) — listing page block372// -----------------------------------------------------------------------------373export interface CommerceItem {374  id: string; commerce: string; nom: string; adresse: string;375  dist_m: number; lat: number; lng: number;376}377378export interface CommercesNearby {379  n: number; commerces: CommerceItem[]; transit?: CommerceItem[];380}381382export const fetchCommerces = (lat: number, lng: number, region?: string) =>383  get<CommercesNearby>(384    `/api/commerces?lat=${lat}&lng=${lng}` +385    (region ? `&region=${encodeURIComponent(region)}` : ""));386387// -----------------------------------------------------------------------------388// KA ID account (SSO hub groupe-ka.com) + "My Ka universe" favourites389// -----------------------------------------------------------------------------390export type Socials = Partial<Record<391  "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string392>>;393394export interface User {395  sub: string;396  email: string;397  name: string;398  picture?: string;399  ka_id?: string;400  provider?: string;            // "ka-id" | "google"401  created_at?: number | null;   // epoch (s)402  last_login?: number | null;   // epoch (s)403  // profile enriched by the Groupe KA HUB (source of truth — groupe-ka.com/compte)404  bio?: string;405  city?: string;406  phone?: string;407  website?: string;408  socials?: Socials;409  public?: boolean;410  role_label?: string;411  job_title?: string;412  company?: string;413  age?: number | null;414  public_url?: string;415  profile_source?: string;      // "groupe-ka" | "local"416}417418/** Session profile ({user: null} if signed out; `enabled` = SSO configured). */419export const fetchMe = () => get<{ user: User | null; enabled: boolean }>("/api/auth/me");420421export const logout = () => fetch("/api/auth/logout", { method: "POST" });422423/** Favourite item as expected by the hub (see immoka/favorites.py). */424export interface FavItem {425  item_id: string;426  title: string;427  subtitle: string;428  price_label: string;429  image_url: string;430  url: string;431}432433export function favItemFromListing(l: Listing): FavItem {434  return {435    item_id: l.uid,436    title: l.title || l.address || l.property_type || "Property",437    subtitle: [l.city, l.region].filter(Boolean).join(", "),438    price_label: l.price_label || fmtPrice(l.price),439    image_url: l.images?.[0] ?? "",440    url: `/property/${l.uid}`,441  };442}443444export const fetchFavorites = () =>445  get<{ ids: string[]; items: FavItem[] }>("/api/favorites");446447export async function toggleFavorite(on: boolean, item: FavItem) {448  const res = await fetch("/api/favorites/toggle", {449    method: "POST",450    headers: { "Content-Type": "application/json" },451    body: JSON.stringify({ on, item }),452  });453  if (!res.ok) throw new Error(`favourites ${res.status}`);454  return (await res.json()) as { ok: boolean; on: boolean };455}456