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.
The big picture
Section titled “The big picture”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/<slug>.json]
Request lifecycle
Section titled “Request lifecycle”- A request arrives at a hostname (e.g.
ozchivalrydemo.goask.com.au). middleware.js(Vercel edge) gates everything except the public assets —api/,gate.html,brand.js,vendor/, images,favicon,robots. Without a validviz_authcookie the visitor is sent to the gate.brand.jsfetchesGET /api/tenantand themes the page (logo, accent colour, title, tagline) for the resolved tenant.- The builder loads the catalogue from
GET /api/catalog— the tenant’s catalogue with live Odoo prices overlaid by SKU. - On submit, the client POSTs the build to
/api/lead, which fans the quote out.
Tenant resolution
Section titled “Tenant resolution”api/_tenant.js is the multi-tenant core:
resolveTenant(host)— strips the port, lowercases, and matches the host against thehostsarray in eachtenants/<slug>.json. No match →DEFAULT_TENANT(ozchivalry).getTenantSecrets(tenant)— readsODOO_URL_<ENV>etc. (fromtenant.odooEnv), falling back to the unsuffixed vars for local single-tenant dev. Written so it can be swapped for a database lookup (encryptedorg_secrets) later without touching any caller.
See Multi-tenancy for the full model.
The quote fan-out
Section titled “The quote fan-out”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.
Odoo push (_odoo.js)
Section titled “Odoo push (_odoo.js)”Talks to Odoo’s External API over JSON-RPC. It:
- Finds or creates the customer (
res.partner) — by email, then by phone (last 9 digits), then creates one. - 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. - Creates a
sale.orderwith onesale.order.lineper matched product (Odoo fills the price). Unmatched items become note lines instead of being dropped; the vehicle is added as a note line.
Reverse price sync (catalog.js)
Section titled “Reverse price sync (catalog.js)”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/<slug>/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)).
Where state lives
Section titled “Where state lives”| 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) |