SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
11.9 KB · 403 lines typescript
Raw Blame History
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + robust API client (timeout, typed errors) for the Home-Ka5//   FastAPI backend (homeka/web.py) — public search/map/detail + admin API.6// -----------------------------------------------------------------------------78/** details: free-form dictionary of source characteristics (label → value). */9export interface ListingDetails {10  price_from?: boolean;11  cover_thumb?: string;12  photo_captions?: string[];13  [key: string]: unknown;14}1516/** A LISTING = one publication of a property by one source. */17export interface Listing {18  uid: string;19  source: string;20  external_id: string;21  property_id?: number | null;22  url: string;23  title: string;24  street_address: string;25  unit?: string | null;26  city: string;27  state: string;               // 2-letter USPS28  zip_code: string;29  county: string;30  property_type: string;31  property_subtype?: string | null;32  list_price: number | null;33  price_label: string;34  bedrooms: number | null;35  bathrooms: number | null;    // can be 2.536  bathrooms_full?: number | null;37  bathrooms_half?: number | null;38  living_area_sqft: number | null;39  lot_size_sqft: number | null;40  year_built: number | null;41  apn?: string | null;42  mls_id: string;43  mls_name?: string | null;44  status: string;              // active | pending | sold | withdrawn | coming-soon45  listed_at?: string | null;46  days_on_market?: number | null;47  brokerage_name: string;48  office_name?: string | null;49  agent_name: string;50  agent_phone: string;51  agent_email?: string | null;52  description: string;53  features: string[];54  details: ListingDetails;55  images: string[];56  lat: number | null;57  lng: number | null;58  price_history?: { ts: number; price: number | null }[];59  duplicates?: DuplicateListing[];   // same property published elsewhere60  property?: Property | null;        // the physical PROPERTY behind this listing61  first_seen?: number;62  last_seen?: number;63  updated_at?: number;64  active?: number;65  published?: number;66}6768export interface DuplicateListing {69  uid: string;70  source: string;71  url: string;72  brokerage_name: string;73  agent_name: string;74  price_label: string;75}7677/** One row of a property's listing history (properties → listings). */78export interface PropertyListingRef {79  uid: string;80  source: string;81  status: string;82  list_price: number | null;83  first_seen: number | null;84  last_seen: number | null;85  active: number;86}8788/** The physical PROPERTY — Home-Ka separates PROPERTY from LISTING. */89export interface Property {90  id: number;91  apn?: string | null;92  street_address?: string | null;93  unit?: string | null;94  city?: string | null;95  state?: string | null;96  zip_code?: string | null;97  county?: string | null;98  property_type?: string | null;99  year_built?: number | null;100  living_area_sqft?: number | null;101  lot_size_sqft?: number | null;102  lat?: number | null;103  lng?: number | null;104  details?: Record<string, unknown>;105  first_seen?: number | null;106  last_seen?: number | null;107  listing_history?: PropertyListingRef[];108}109110export interface Facets {111  states: { state: string; n: number }[];112  cities: { city: string; n: number }[];113  property_types: string[];114  sources: { source: string; n: number }[];115}116117export interface Source {118  id: string;119  name: string;120  connector_type: string | null;121  states: string[];122  enabled: number;123  active_listings: number;124  last_sync: number | null;125}126127export interface SyncLogRow {128  source: string;129  ts: number;130  found: number;131  added: number;132  updated: number;133  removed: number;134  ok: number;135  message: string;136}137138export interface Stats {139  total: number;140  sources: number;141  cities: number;142  states: number;143  avg_price: number | null;144  min_price: number | null;145  max_price: number | null;146  properties: number;147  by_state: { state: string; n: number; avg_price: number | null }[];148  by_type: { property_type: string; n: number; avg_price: number | null }[];149  quality?: Record<string, unknown>;150  recent_syncs?: SyncLogRow[];151}152153// --- Source display names -----------------------------------------------------154const SOURCE_NAMES: Record<string, string> = {};155export function registerSourceNames(sources: Source[]) {156  for (const s of sources) SOURCE_NAMES[s.id] = s.name;157}158export function sourceName(id: string): string {159  if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];160  return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());161}162163async function get<T>(path: string, headers?: Record<string, string>): Promise<T> {164  const ctrl = new AbortController();165  const timer = setTimeout(() => ctrl.abort(), 25000);166  try {167    const res = await fetch(path, { signal: ctrl.signal, headers });168    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);169    return (await res.json()) as T;170  } finally {171    clearTimeout(timer);172  }173}174175async function post<T>(176  path: string, body?: unknown, headers?: Record<string, string>,177): Promise<T> {178  const ctrl = new AbortController();179  const timer = setTimeout(() => ctrl.abort(), 60000);180  try {181    const res = await fetch(path, {182      method: "POST",183      headers: { "Content-Type": "application/json", ...(headers ?? {}) },184      body: body === undefined ? undefined : JSON.stringify(body),185      signal: ctrl.signal,186    });187    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);188    return (await res.json()) as T;189  } finally {190    clearTimeout(timer);191  }192}193194export interface ListingFilters {195  q?: string;196  city?: string;197  state?: string;198  zip_code?: string;199  county?: string;200  property_type?: string;201  status?: string;202  source?: string;203  price_min?: string;204  price_max?: string;205  beds_min?: string;206  baths_min?: string;207  sqft_min?: string;208  sort?: string;      // recent | price_asc | price_desc209}210211export function listingParams(f: ListingFilters): URLSearchParams {212  const params = new URLSearchParams();213  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);214  return params;215}216217export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {218  const params = listingParams(f);219  params.set("limit", String(limit));220  params.set("offset", String(offset));221  return get<{ total: number; count: number; listings: Listing[] }>(222    `/api/listings?${params}`);223}224225export const fetchListing = (uid: string) =>226  get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);227export const fetchFacets = (state?: string) =>228  get<Facets>(`/api/facets${state ? `?state=${encodeURIComponent(state)}` : ""}`);229export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");230export const fetchStats = () => get<Stats>("/api/stats");231232// --- Formatting (US) ----------------------------------------------------------233export const fmtPrice = (p: number | null | undefined, label?: string) =>234  p != null235    ? "$" + Math.round(p).toLocaleString("en-US")236    : label || "Price on request";237238export const fmtArea = (a: number | null | undefined): string | null =>239  a != null ? `${Math.round(a).toLocaleString("en-US")} sq ft` : null;240241export const fmtDate = (ts: number): string =>242  new Date(ts * 1000).toLocaleDateString("en-US", {243    month: "long", day: "numeric", year: "numeric",244  });245246/** 2.5 → "2.5", 2 → "2" — bathrooms can be half-counts. */247export const fmtBaths = (b: number | null | undefined): string =>248  b == null ? "—" : (Number.isInteger(b) ? String(b) : b.toFixed(1));249250/** "Austin, TX 78704" from the parts that exist. */251export const fmtCityLine = (252  l: { city?: string | null; state?: string | null; zip_code?: string | null },253): string =>254  [255    [l.city, l.state].filter(Boolean).join(", "),256    l.zip_code ?? "",257  ].filter(Boolean).join(" ");258259export const STATUS_LABELS: Record<string, string> = {260  "active": "For sale",261  "pending": "Pending",262  "sold": "Sold",263  "withdrawn": "Withdrawn",264  "coming-soon": "Coming soon",265};266267// =============================================================================268// Admin API — /api/admin/* (optional X-Admin-Token header, stored locally)269// =============================================================================270271export const ADMIN_TOKEN_KEY = "homeka_admin_token";272273export function getAdminToken(): string {274  try { return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; } catch { return ""; }275}276export function setAdminToken(token: string) {277  try {278    if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);279    else localStorage.removeItem(ADMIN_TOKEN_KEY);280  } catch { /* storage unavailable */ }281}282function adminHeaders(): Record<string, string> {283  const t = getAdminToken();284  return t ? { "X-Admin-Token": t } : {};285}286287export interface AdminOverview {288  active_listings: number;289  properties: number;290  enabled_sources: number;291  errors_24h: number;292  brokerages_by_status: { partnership_status: string; n: number }[];293  last_successful_sync: number | null;294}295296export interface AdminSource {297  id: string;298  name: string;299  connector_type: string | null;300  enabled: number;301  authority: number | null;302  states: string[];303  notes: string;304  brokerage_id: number | null;305  config: Record<string, unknown>;306  active_listings: number;307  published: number;308  no_geo: number;309  last_sync: number | null;310  last_ok: number | null;311  last_message: string | null;312  last_found: number | null;313  last_added: number | null;314  last_removed: number | null;315  errors_7d: number;316  freshness_hours: number | null;317}318319export interface ConnectorFamily {320  family: string;321  class: string;322  module: string;323  doc: string;324  instances: number;325}326export interface CustomConnector {327  source_id: string;328  class: string;329  module: string;330}331332export interface Brokerage {333  id: number;334  name: string;335  website: string | null;336  states: string[];337  cities: string[];338  estimated_agents: number | null;339  estimated_listings: number | null;340  mls_affiliations: string[];341  idx_present: number | null;342  idx_provider: string | null;343  reso_detected: number | null;344  possible_feed_type: string | null;345  contact_page: string | null;346  partnership_contact: string | null;347  technical_contact: string | null;348  feed_probability_score: number | null;349  priority_score: number | null;350  partnership_status: string;351  source_id: string | null;352  last_inspected: number | null;353  inspect_error: string | null;354  evidence: Record<string, unknown>;355}356357export const PARTNERSHIP_STATUSES = [358  "prospect", "to-contact", "contacted", "in-discussion",359  "feed-received", "live", "declined",360] as const;361362export const fetchAdminOverview = () =>363  get<AdminOverview>("/api/admin/overview", adminHeaders());364365export const fetchAdminSources = () =>366  get<{ sources: AdminSource[] }>("/api/admin/sources", adminHeaders());367368export const fetchAdminConnectors = () =>369  get<{ families: ConnectorFamily[]; custom: CustomConnector[] }>(370    "/api/admin/connectors", adminHeaders());371372export interface BrokerageFilters {373  state?: string;374  status?: string;375  feed_type?: string;376  idx_provider?: string;377  q?: string;378  min_priority?: string;379  inspected?: string;   // "1" | "0" | ""380  sort?: string;        // priority | feed | name | inspected381}382383export function fetchAdminBrokerages(f: BrokerageFilters, limit = 50, offset = 0) {384  const params = new URLSearchParams();385  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);386  params.set("limit", String(limit));387  params.set("offset", String(offset));388  return get<{ total: number; count: number; brokerages: Brokerage[] }>(389    `/api/admin/brokerages?${params}`, adminHeaders());390}391392export const inspectBrokerage = (id: number) =>393  post<Record<string, unknown>>(394    `/api/admin/brokerages/${id}/inspect`, undefined, adminHeaders());395396export const setBrokerageStatus = (id: number, status: string) =>397  post<{ id: number; status: string }>(398    `/api/admin/brokerages/${id}/status`, { status }, adminHeaders());399400export const runDiscovery = (limit = 25) =>401  post<{ status: string; limit: number }>(402    `/api/admin/discover?limit=${limit}`, undefined, adminHeaders());403