Skip to content

Organizations & teams

Multi-tenancy scopes everything to a userId. Organizations add the layer above it: a shared tenant that several people belong to, so work can arrive unassigned and an admin can route it to a team member. The canonical case is “a quote lands in the org, then a triage assigns it to a sales person” — but the mechanics are generic over any team.

Membership is the better-auth organization plugin (teams sub-grouping is disabled for now — org + roles only):

  • Tables: organization, member, invitation. A member row binds a user to an org with a role: owner, admin, or member. “Admin” means owner or admin.
  • Personal org on signup: every new user automatically gets their own organization (they are its owner), so single-user flows work unchanged — one member per org until someone is invited.
  • Active org: the session carries activeOrganizationId. The auth middleware resolves it into orgId + orgRole on every request’s context, the same way userId is resolved.
  • Endpoints: organization management (create, invite, set-active, change role, remove) is served by better-auth at /api/auth/organization/*; the dashboard drives it through the organizationClient() plugin.

Every owner-scoped table carries an org_id column (backfilled from each user’s personal org). The job runner threads context.metadata.orgId next to userId, so steps that create entities can stamp the owning org.

userId scoping stays in place everywhere it already worked — it’s equivalent while an org has a single member, and each vertical converts to org-aware reads only when it grows a shared UI. Today the pool ⇄ assignment behaviour below is exercised on Tasks and Quote requests.

This is what makes work shared rather than personal:

  • Unrouted = the pool. A public quote request lands with org_id = NULL — it belongs to no one yet. That is the “needs attention” pool.
  • assigneeId routes it. Tasks and quote requests carry an optional assigneeId. Assigning a quote also stamps its orgId, claiming it out of the pool. The FK is ON DELETE SET NULL, so removing a user returns their work to the pool rather than orphaning it.
  • Visibility follows role. When listing quote requests (and in the attention inbox):
    • an admin / owner sees the org’s rows plus the unrouted pool (includeUnrouted);
    • a member sees only the requests assigned to them.
  • Who can assign. An admin can assign any member of the org; a member can only self-claim (assign to themselves). The assignee is validated to actually be a member of the org before the write.

Assigning automatically: the assign_to_member step

Section titled “Assigning automatically: the assign_to_member step”

Manual assignment is a PATCH. For hands-off routing, the assign_to_member job step claims the unrouted pool and distributes it:

  • Strategy round_robin is least-loaded: it seeds each candidate’s current load from their existing pending assignments, then routes each pooled item to whoever has the fewest.
  • Generic over any team via config: assignees (an explicit member list — array or comma-separated string, intersected with real membership so ghosts drop out) and roleFilter (restrict candidates to a role).
  • Idempotent: only the unassigned pool is touched, so re-runs only pick up newly arrived work.

The quoteToSales preset wraps this as a single self-sourcing step — point a schedule at it (with an optional assignees input) and incoming quotes get routed to the sales team on every tick. Leave assignees empty to round-robin across the whole org.

  • An org switcher in the sidebar lists your organizations and sets the active one.
  • /organization is the members admin page: invite by email + role, change roles, remove members, cancel pending invitations (admin-only; members see a read-only roster).
  • Tasks and Quote requests show an assignee, an All / Unassigned / member filter, and an admin assign control (members get a “Claim” button on quotes).
  • The inbox already reflects the role split — admins get the pool, members get their assignments — with no per-view configuration.

How many members an org may hold is a property of its plan, not a global constant:

Plan stateMembers allowed
Free (no entitlement, trial lapsed)1
Subscribed, or inside the signup trial100

A trial counts as paid — it is a taste of the paid tier — so a trialing team can invite freely and simply can’t add more people once the trial lapses. Nobody is ever removed: the limit is checked when a seat is taken, never retroactively.

Where it lives:

  • FREE_TIER_MEMBER_LIMIT / PAID_TIER_MEMBER_LIMIT + resolveMemberLimit in @abeauvois/platform-billing (pure). The free number is the same one the pricing card advertises — a test asserts the bullet and the constant agree.
  • apps/api/entitlements/orgMemberLimit.ts turns an orgId into the limit (entitlement + trial), and counts committed seats as members + pending invitations.
  • Enforcement is better-auth’s membershipLimit (checked on accept-invitation and server-side addMember) plus a beforeCreateInvitation hook, so an over-limit invite is refused when it is sent403 ORGANIZATION_MEMBERSHIP_LIMIT_REACHED — instead of stranding the recipient at accept time.
  • GET /api/entitlements/gate reports memberLimit from the same resolver, which is what /organization uses to disable its invite form. Clients may hide the control; the server is the authority.

The cap is deliberately independent of ENTITLEMENT_PAYWALL_ENABLED: that flag decides whether the dashboard locks entirely, whereas seats are part of what the tier is.

Because the org dimension is additive — an orgId/assigneeId next to the existing userId scoping rather than a rewrite — personal, single-user usage is unchanged, and any vertical that already follows the tenancy pattern can opt into shared/pooled behaviour by adding the same two columns and the three read modes (personal, org pool, assigned-to-me). The routing logic (assign_to_member) is subject-agnostic, so the same round-robin that distributes sales quotes will distribute any future pooled entity.

Two behaviours surprise people the first time they drive the org API directly (e.g. a UAT script hitting the deployed endpoints):

  • Org mutations require an Origin header. better-auth guards /api/auth/organization/* (invite-member, accept-invitation, set-active, …) against CSRF, so a request with no Origin is rejected with MISSING_OR_NULL_ORIGIN. Send an Origin that’s in the trusted set (CLIENT_URLS — e.g. https://business-studio.viite.ai). Browsers send this automatically; curl/fetch scripts must set it explicitly. (The same guard applies to API-key creation.)
  • Visibility follows the active org, not every org you belong to. A member’s “assigned to me” reads are scoped to session.activeOrganizationId. Right after accepting an invitation a user is still active in their personal org, so org-pool / assigned rows won’t appear until they set-active the shared org (the sidebar switcher does this in the UI). Accepting an invite adds the membership; it does not switch the active org.

When scripting the end-to-end flow against a shared environment, remember assign_to_member claims the whole unrouted pending pool (org_id IS NULL), not just your own test rows — submit into a clean pool, or snapshot and restore any pre-existing unrouted quotes afterward.