TempuraML's picture
feat(admin): DB-backed tenant + key issuance — one-click client onboarding (#141)
a2a8f31 unverified
Raw
History Blame Contribute Delete
7.27 kB
// Lawn Estimator — tenant / pricing / service-area data model
//
// Render at https://dbdiagram.io (paste this file) or with the dbml CLI.
//
// NOTE ON STORAGE: the pricing_configs/<tenant>.json files remain the SEED. As of P1c the
// config is loaded from the DB (db.TenantStore) — currently the `tenant_configs` table
// below: one row per tenant holding that SAME nested JSON (start simple), seeded from the
// files on first boot, then the read/write path the P2 self-serve editor uses. The
// normalized tables below (tenants / services / service_areas / …) are the *logical* model
// the JSON maps to and the eventual target once the config schema stabilizes (plan §3.2
// "start simple, scale later"). Keep in sync with tenants.py / pricing.py.
// Refs: docs/product-design-plan.md §23.2, ROADMAP_APP.md.
Project lawn_estimator_tenants {
database_type: 'PostgreSQL'
Note: 'Multi-tenant pricing + ZIP service areas for the lawn-quoting SaaS.'
}
Table tenant_configs {
tenant_id varchar [pk, ref: - tenants.id, note: 'API-key label = tenant id']
config json [not null, note: 'The full tenant config as the nested JSON the pricing_configs/<id>.json file holds (company/currency/presentation/services/areas/service_area_zips/allowed_origins/email/branding). INTERIM storage (P1c) — normalizing into the tables below is the eventual target.']
updated_at timestamp [not null, default: `now()`, note: 'Last edit (P2 self-serve editor writes here).']
Note: 'Durable tenant config, db.TenantStore. Seeded from pricing_configs/<id>.json on first boot; a stored edit then wins over the file. Becomes durable in prod with P1d (Neon).'
}
Table tenants {
id varchar [pk, note: 'Tenant id = the ALLOWED_API_KEYS label (the company). JSON file: pricing_configs/<id>.json']
company varchar [not null]
currency varchar [not null, default: 'USD']
presentation varchar [not null, default: 'estimate', note: 'firm | estimate (labeled "starting at")']
email_from varchar [null, note: 'Per-tenant sender, e.g. "The Lawn Standard <quotes@thelawnstandard.net>". Emails send FROM the tenant-owned (ESP-authenticated) domain; ESP provider+key stay global. NULL → global EMAIL_FROM env.']
email_reply_to varchar [null]
email_lead_notify varchar [null, note: 'Where new-lead notifications for this tenant go (their inbox).']
brand_name varchar [null, note: 'Widget branding (config "branding" block, W6) — applied client-side so the embed looks like the business.']
brand_logo varchar [null, note: 'Logo URL/data-URI shown in the widget header.']
brand_accent varchar [null, note: 'Accent color (hex) — overrides the widget theme.']
cta_label varchar [null, note: 'Call-to-action button label after the price.']
cta_url varchar [null, note: 'Where the CTA links (the business booking page, until W5 checkout).']
Note: 'One lawn-care business. Maps to tenants.Tenant; base package = its services rows with service_area_id NULL.'
}
Table api_keys {
key varchar [pk, note: 'X-API-Key (token_urlsafe). kind=secret → full access; kind=publishable → widget, quote-only + domain-locked.']
tenant_id varchar [not null, ref: > tenants.id]
kind varchar [not null, default: 'publishable', note: 'publishable | secret']
Note: 'IMPLEMENTED as the `api_keys` table (db.ApiKeyStore). /admin provisioning mints keys here at runtime (no restart); merged into the in-memory maps at startup ALONGSIDE the ALLOWED_API_KEYS / WIDGET_API_KEYS env entries (which still work). Was env-only before.'
}
Table tenant_origins {
tenant_id varchar [not null, ref: > tenants.id]
origin varchar [not null, note: 'Full origin, e.g. https://acme.com — where the tenant widget may call from.']
indexes {
(tenant_id, origin) [pk]
}
Note: 'Allowed browser origins for the tenant widget. A publishable-key /quote is rejected (403) unless the request Origin/Referer host matches a row here. Empty ⇒ no widget origin authorized. Today: the config JSON "allowed_origins" list.'
}
Table service_areas {
id integer [pk, increment]
tenant_id varchar [not null, ref: > tenants.id]
name varchar [not null]
travel_time_factor decimal [not null, default: 1.0, note: 'Scales every line item for jobs in this area (1.0 = no change).']
Note: 'A named zone of the tenant service area (tenants.ServiceArea). NO rows for a tenant → it serves all of Coverage at base pricing. The union of the zips in this table forms the tenant overall service area (plan §2); a lead outside it is still captured but gets no bookable price. WS4: a tenant may ALSO have a map-drawn `service_area_polygon` (GeoJSON in the config JSON) that decides serve/don''t-serve by point-in-polygon on the geocoded address; pricing still resolves by ZIP as below.'
}
Table service_area_zips {
service_area_id integer [not null, ref: > service_areas.id]
zip varchar [not null, note: '5-digit ZIP (ZIP+4 normalized to the first 5).']
indexes {
(service_area_id, zip) [pk]
}
}
Table services {
id integer [pk, increment]
tenant_id varchar [not null, ref: > tenants.id]
service_area_id integer [null, ref: > service_areas.id, note: 'NULL = tenant base package; set = per-area override used for that area only.']
service_id varchar [not null, note: 'Auto-generated slug of the label (WS2); stable once created so `requires` refs hold.']
label varchar [not null]
rate decimal [not null, note: 'The rate NUMBER; meaning depends on `unit`. Stored as rate_per_1000_sqft for back-compat.']
unit varchar [not null, default: 'per_1000_sqft', note: 'per_1000_sqft | per_sqft | flat (WS2).']
min_charge decimal [not null, default: 0]
measure varchar [not null, default: 'lawn_sqft', note: 'Which pipeline measurement drives this service.']
requires varchar [null, note: 'Another service_id this is always paired with (e.g. overseeding requires aeration).']
Note: 'A priced service (pricing.Service). Line price per `unit`: per_1000_sqft = rate/1000×sqft; per_sqft = rate×sqft; flat = rate — then ×area.travel_time_factor, floored at min_charge.'
}
Table bundles {
id varchar [pk, note: 'Auto-generated slug of the name (WS5).']
tenant_id varchar [not null, ref: > tenants.id]
name varchar [not null]
mode varchar [not null, note: 'fixed (amount IS the bundle price) | percent (amount = % off the summed member services).']
amount decimal [not null]
cadence varchar [not null, default: 'one_time', note: 'one_time | weekly | biweekly | monthly | seasonal — recurring metadata until Stripe (D5).']
Note: 'A named package of services bookable as one (pricing.Bundle). Member service_ids in bundle_services. Shown in the widget as a selectable package; a booked package is recorded on the lead (detail.bundle) → Overview "Top packages".'
}
Table bundle_services {
bundle_id varchar [not null, ref: > bundles.id]
service_id varchar [not null, note: 'Must reference a services.service_id for the same tenant.']
indexes {
(bundle_id, service_id) [pk]
}
}
// Resolution (tenants.Tenant.pricing_for_zip): find the service_area whose zips contain
// the quote ZIP → use that area's services (or the tenant base if it has none) and its
// travel_time_factor. No matching area → tenant base package at factor 1.0.