Skip to content

Architecture

The configurator is a static front end + Vercel serverless functions + an edge gate, serving many tenants from one deployment. There is no long-running server; every api/* file is a serverless function.

flowchart TB
  subgraph Client
    G[gate.html<br/>access code] --> B[builder.html<br/>Three.js 3D build]
    BR[brand.js<br/>themes by /api/tenant]
  end

  subgraph Edge
    MW[middleware.js<br/>blocks app until unlocked]
  end

  subgraph Functions["Vercel functions (api/*)"]
    T[_tenant.js<br/>host → tenant + secrets]
    CAT[catalog.js]
    UN[unlock.js / build.js<br/>session]
    LEAD[lead.js<br/>quote fan-out]
    ODO[_odoo.js]
    HS[_hubspot.js]
  end

  subgraph Stores
    SB[(Supabase<br/>leads · people · events)]
    OD[(Odoo per tenant<br/>sale.order)]
    HUB[(HubSpot)]
    ANS[(GoAsk Answer)]
  end

  B --> MW --> Functions
  BR --> T
  CAT --> OD
  LEAD --> SB & ODO & HS
  LEAD --> ANS
  ODO --> OD
  HS --> HUB
  T -. reads .-> Tenants[tenants/&lt;slug&gt;.json]
  1. A request arrives at a hostname (e.g. ozchivalrydemo.goask.com.au).
  2. middleware.js (Vercel edge) gates everything except the public assets — api/, gate.html, brand.js, vendor/, images, favicon, robots. Without a valid viz_auth cookie the visitor is sent to the gate.
  3. brand.js fetches GET /api/tenant and themes the page (logo, accent colour, title, tagline) for the resolved tenant.
  4. The builder loads the catalogue from GET /api/catalog — the tenant’s catalogue with live Odoo prices overlaid by SKU.
  5. On submit, the client POSTs the build to /api/lead, which fans the quote out.

api/_tenant.js is the multi-tenant core:

  • resolveTenant(host) — strips the port, lowercases, and matches the host against the hosts array in each tenants/<slug>.json. No match → DEFAULT_TENANT (ozchivalry).
  • getTenantSecrets(tenant) — reads ODOO_URL_<ENV> etc. (from tenant.odooEnv), falling back to the unsuffixed vars for local single-tenant dev. Written so it can be swapped for a database lookup (encrypted org_secrets) later without touching any caller.

See Multi-tenancy for the full model.

POST /api/lead (session required) is the heart of the app. It never lets a CRM outage break the customer experience — every destination is isolated and best-effort:

flowchart TD
  Q[POST /api/lead<br/>session required] --> R[build the lead row]
  R --> S1{Supabase<br/>leads insert}
  S1 -- ok --> DONE[respond ok/safe]
  S1 -- failed --> S2[email backup via Resend<br/>lead is never lost]
  S2 --> DONE
  R --> S3[forward to GoAsk Answer<br/>ANSWER_INTAKE_URL]
  R --> S4[HubSpot: contact + build note + deal]
  R --> S5[Odoo: sale.order + lines by SKU]

The response is honest: safe is true when the lead landed somewhere (DB or email); crm, hubspot and odoo report each push independently. A stable external_id (sha256 of the build config) means re-saving a build updates one deal/quote instead of spawning duplicates.

Talks to Odoo’s External API over JSON-RPC. It:

  1. Finds or creates the customer (res.partner) — by email, then by phone (last 9 digits), then creates one.
  2. Maps the build selections to Odoo products by SKU (default_code); 12V extras that have no SKU are matched by product name (ilike). See the SKU map.
  3. Creates a sale.order with one sale.order.line per matched product (Odoo fills the price). Unmatched items become note lines instead of being dropped; the vehicle is added as a note line.

The reverse of the Odoo push: GET /api/catalog reads the tenant’s catalog.js, and — when that tenant has an Odoo — refreshes each item’s price live from Odoo, matched by default_code.

flowchart LR
  REQ[GET /api/catalog] --> FILE[tenants/&lt;slug&gt;/catalog.js]
  FILE --> COLLECT[collect all SKUs]
  COLLECT --> ODOO[Odoo product.product<br/>search_read default_code,list_price]
  ODOO --> OVERLAY[overlay retail prices]
  OVERLAY --> OUT[window.CATALOG = ...]
  ODOO -. unreachable .-> OUT

Fully isolated: if Odoo is unreachable, the tenant’s file prices are served unchanged (the served JS comment reports the source: odoo, or file (odoo unreachable)).

Store Holds Notes
Supabase leads, catalogue (row 1 = product catalogue, row 2 = people), events See Data model
Odoo (per tenant) res.partner, sale.order Source of truth for price
HubSpot contacts, deals Optional (HUBSPOT_TOKEN)
GoAsk Answer lead intake → CRM pipeline Optional (ANSWER_INTAKE_URL)