Data model
Supabase
Section titled “Supabase”The app deliberately avoids schema migrations where it can — the people store rides inside an existing table so “add People & Access” needed no SQL to run.
One row per submitted build.
| Column | Type | Notes |
|---|---|---|
name, mobile, email |
text | contact |
dealership, vehicle, colour, rear |
text | build summary fields |
build |
jsonb | full build: { config, plate, rear, bull, acc[], extras[], … } |
consent |
bool | nullable; some older tables predate this column (the insert falls back gracefully) |
total, weekly |
numeric | price + weekly finance figure |
created_at |
timestamptz |
catalogue (id → jsonb)
Section titled “catalogue (id → jsonb)”A two-row table doing double duty:
Row id |
Holds |
|---|---|
1 |
the product catalogue |
2 |
the people store — { users: [...] } |
events
Section titled “events”Funnel analytics sink (POST /api/event writes, GET /api/events reads, staff-only).
User record (people store, catalogue row 2)
Section titled “User record (people store, catalogue row 2)”{ "id": "uuid", "name": "Jane", "role": "staff | dealer | mfr", "dealer_slug": "norris", "code_hash": "hmac-sha256(VIZ_SECRET, CODE)", "active": true, "revoked_at": null, "last_login_at": "ISO8601"}The personal code is never stored — only its HMAC hash. A database leak alone therefore does not hand anyone a working login.
Roles & sessions
Section titled “Roles & sessions”Authentication is a signed cookie (viz_auth, HMAC-SHA256 with VIZ_SECRET) that is
re-validated against the database on every request — so revoking a user takes effect
immediately, not when their cookie eventually expires. It fails closed: no VIZ_SECRET, or an
unconfirmable session, means access is refused.
| Role | Granted by | Lifetime | Sees |
|---|---|---|---|
demo |
shared demo code / tenant gate code | 5 min | the builder |
view |
GET /api/build (rego + mobile re-open) |
2 h, read-only | their own saved build |
staff |
per-user account or VIZ_STAFF_CODES |
12 h | everything, incl. leads & people admin |
dealer:<slug> |
per-user account or VIZ_DEALER_CODES |
12 h | their dealer portal + margins |
mfr:<slug> |
per-user account or VIZ_MFR_CODES |
12 h | product demand across all dealers |
The token payload is exp.role.uid.iat (older shared-code tokens are just exp.role). Role is read
from the store, not the cookie, so changing someone’s role or dealership takes effect on their
next request.
SKU → Odoo mapping
Section titled “SKU → Odoo mapping”api/_odoo.js maps each configurator selection to an Odoo product. Matched by default_code (SKU);
the 12V extras have no SKU, so they match on product name (ilike). Price always comes from
Odoo.
| Selection | Match | SKU / name |
|---|---|---|
setup (fitting kit) |
SKU | ACTFITKIT |
rear.tray |
SKU | T1T1819RC |
rear.canopy |
SKU | X1F2D1817R |
bull.post2 |
SKU | ACASRR1400-1 |
bull.4x4 |
SKU | ACAFRR2400 |
acc.drawers |
SKU | ACADCB900B |
acc.rack |
SKU | ACAAFL |
acc.tank |
SKU | ACAFLJ20L-1 |
acc.toolbox |
SKU | X2F1286R |
acc.led |
SKU | ATBFDS0605-3D |
extras.antenna |
name | Smart 40A |
extras.suspension |
name | 200W |
extras.dualbattery |
name | 100Ah |
extras.drivinglights |
name | 200Ah |
extras.recovery |
name | Core 2K |