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

TypeCreated byPurpose
personalAutomatically on signupPersonal workspace — one per user, the billing anchor
teamUser action — Create OrganizationShared 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:

  1. Creates public.users profile
  2. Creates a personal organizations row (slug auto-generated)
  3. Creates an org_members row (role = owner)
  4. Creates a subscriptions row (plan = free)
  5. Seeds default feature_flags for the org
  6. Sets users.current_org_id to the new org
  7. 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) → boolean

Even 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.