Architecture
Multi-tenancy
Every piece of data in OpsDash is scoped to an organization. Users can belong to multiple organizations simultaneously. Billing is anchored to a personal org, not individual workspaces.
Organization types
| Type | Created by | Purpose |
|---|---|---|
| personal | Automatically on signup | Personal workspace — one per user, the billing anchor |
| team | User action — Create Organization | Shared workspace — inherits billing from the owner's personal org |
Signup trigger
When a new user signs up, the handle_new_user() PostgreSQL trigger fires automatically:
- Creates
public.usersprofile - Creates a personal
organizationsrow (slug auto-generated) - Creates an
org_membersrow (role = owner) - Creates a
subscriptionsrow (plan = free) - Seeds default
feature_flagsfor the org - Sets
users.current_org_idto the new org - Writes to
audit_logs
No manual inserts are needed — everything is automatic.
Billing anchor
Billing is anchored to a user's personal org, not to individual team workspaces. This means:
- One Stripe subscription per user, not per workspace
- Team orgs inherit the plan from their
billing_owner_user_id - Upgrading your personal org upgrades all team orgs you own
Row-Level Security
All data tables enforce org_id scoping via Supabase RLS policies. The core helper functions used in policies:
-- True if auth.uid() is a member of the given org
is_org_member(org_id uuid) → boolean
-- True if auth.uid() is an admin or owner of the given org
is_org_admin(org_id uuid) → booleanEven if a server action had a bug, the database would not return another org's data — RLS is the last line of defense.
Switching organizations
The org switcher (top of the sidebar) sets users.current_org_id and reloads the app. All subsequent requests resolve to the new org via the URL slug (/org/[slug]/). Middleware reads the slug on every request — the current_org_id value is only used for the initial redirect after login.
Soft-delete
Organizations are never hard-deleted. The deleted_at column is set instead. All RLS SELECT policies include deleted_at IS NULL, so deleted orgs are invisible to all queries automatically.