File size: 5,205 Bytes
119e471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// Lawn Estimator β€” admin dashboard: users, roles, audit, config draft/publish (P2a/2b)
//
// Render at https://dbdiagram.io β€” render this file on its own (a `tenants` stub below lets it
// stand alone). tenants.dbml holds the full `tenants` table; don't concatenate the two files.
//
// SECURITY POSTURE (owner directive: "ironclad, don't expose ourselves"):
//   - IDENTITY is delegated to Clerk (managed auth). Clerk owns passwords, MFA, reset flows,
//     and sessions. We NEVER store a password or password hash. Our DB stores only the mapping
//     Clerk-user -> tenant + role.
//   - AUTHORIZATION is ours: every dashboard request verifies the Clerk session, resolves the
//     `users` row, and scopes EVERY query to that user's tenant_id. A tenant_id is never taken
//     from the client.
//   - AUDIT: every mutating action appends to `audit_log` (append-only) β€” the record we defend
//     ourselves with.
//   - Roadmap: ROADMAP_APP.md P2a/2b. Login = password + MFA for MVP; magic-link / passkeys are
//     a documented later option (Clerk supports both β€” a config change, no schema change).

Project lawn_estimator_dashboard {
  database_type: 'PostgreSQL'
  Note: 'Admin dashboard identity (via Clerk), tenant-scoped authorization, and audit.'
}

// `tenants` is defined FULLY in tenants.dbml. dbdiagram.io loads one file at a time, so this
// minimal stub lets THIS file render standalone. Don't paste both files together (that would
// double-define `tenants`).
Table tenants {
  id varchar [pk, note: 'Stub for standalone rendering β€” full definition in tenants.dbml.']
}

Table users {
  id integer [pk, increment]
  tenant_id varchar [not null, ref: > tenants.id, note: 'The one company this user administers. All their dashboard queries are scoped to this.']
  clerk_user_id varchar [not null, unique, note: 'The Clerk user id. Clerk holds the credential (password/MFA/session); we hold NO password. This is the whole identity link.']
  email varchar [not null, note: 'Mirror of the Clerk email for display/audit; Clerk is the source of truth.']
  role varchar [not null, note: 'owner | staff. owner-only: config publish (2b), billing, user/role management. staff: view + batch quoting + batch-measurement edits.']
  status varchar [not null, default: 'invited', note: 'invited (Clerk invite sent, not yet accepted) | active | disabled']
  invited_by integer [null, ref: > users.id, note: 'Who sent the invite (invite-only onboarding for MVP β€” no open signup).']
  created_at timestamp [not null, default: `now()`]

  indexes {
    tenant_id
    clerk_user_id [unique]
  }
  Note: 'A dashboard operator. Onboarding is INVITE-ONLY for MVP: we (or a tenant owner) create the row + Clerk invite; open self-serve signup is v2 (Coverage-gated).'
}

Table audit_log {
  id integer [pk, increment]
  tenant_id varchar [null, ref: > tenants.id, note: 'null for platform/system actions']
  user_id integer [null, ref: > users.id, note: 'null for automated/system actions']
  action varchar [not null, note: 'dotted verb: config.publish | config.draft.save | lead.view | batch.measurement.edit | user.invite | user.role.change | login | logout | ...']
  target_type varchar [null, note: 'config | lead | measurement | user | ...']
  target_id varchar [null]
  before json [null, note: 'prior state (edits) β€” the defend-ourselves history']
  after json [null, note: 'new state (edits)']
  ip varchar [null, note: 'request IP (behind the proxy, best-effort)']
  created_at timestamp [not null, default: `now()`]

  indexes {
    (tenant_id, created_at)
    (user_id, created_at)
  }
  Note: 'EVERY mutating dashboard action appends one row (owner directive: log everything). APPEND-ONLY β€” never updated or deleted. Retention/policy TBD.'
}

// Config draft/publish (2b). The LIVE config stays in tenants.tenant_configs (the widget reads
// it). Editing happens on a DRAFT; PUBLISH validates the draft, promotes it to live, and appends
// a version + an audit_log row. Validation is a SECURITY boundary (tenant config renders in the
// public widget): reject javascript: in logo/cta_url, hex-only accent, non-negative + capped
// prices, 5-digit ZIPs (warn if outside Coverage), well-formed origins. See ROADMAP_APP.md 2b.
Table tenant_config_drafts {
  tenant_id varchar [pk, ref: - tenants.id]
  draft json [not null, note: 'Working copy (may be incomplete). Not served to the widget until published.']
  updated_by integer [null, ref: > users.id]
  updated_at timestamp [not null, default: `now()`]
  Note: 'One in-progress draft per tenant. Publish -> validate -> write tenant_configs (live) + append tenant_config_versions + audit_log; then clear/keep the draft.'
}

Table tenant_config_versions {
  id integer [pk, increment]
  tenant_id varchar [not null, ref: > tenants.id]
  config json [not null, note: 'The full config as published (snapshot).']
  published_by integer [null, ref: > users.id]
  published_at timestamp [not null, default: `now()`]

  indexes {
    (tenant_id, published_at)
  }
  Note: 'Append-only publish history β†’ change record + rollback. The live config = newest version (mirrored into tenants.tenant_configs for the widget fast-read path).'
}