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