Spaces:
Running
Running
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).'
}
|