File size: 7,268 Bytes
aed1e3b
 
 
 
afaad57
 
 
 
 
 
 
aed1e3b
 
 
 
 
 
 
afaad57
 
119e471
afaad57
 
 
 
aed1e3b
 
 
 
 
119e471
b8f182d
119e471
cff518d
 
 
 
119e471
aed1e3b
 
 
 
a2a8f31
aed1e3b
a2a8f31
 
ce14383
 
 
 
119e471
ce14383
 
 
119e471
ce14383
 
aed1e3b
 
 
 
 
a2a8f31
aed1e3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2a8f31
aed1e3b
a2a8f31
 
aed1e3b
 
 
a2a8f31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aed1e3b
 
 
119e471
aed1e3b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// 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.