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.
Identity & roles
Section titled “Identity & roles”Membership is the better-auth organization plugin (teams sub-grouping is disabled for now — org + roles only):
- Tables:
organization,member,invitation. Amemberrow binds a user to an org with a role:owner,admin, ormember. “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 intoorgId+orgRoleon every request’s context, the same wayuserIdis 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 theorganizationClient()plugin.
Tenancy: org_id alongside userId
Section titled “Tenancy: org_id alongside userId”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.
The org pool ⇄ assignment seam
Section titled “The org pool ⇄ assignment seam”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. assigneeIdroutes it. Tasks and quote requests carry an optionalassigneeId. Assigning a quote also stamps itsorgId, claiming it out of the pool. The FK isON 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.
- an admin / owner sees the org’s rows plus the unrouted pool (
- 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_robinis 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) androleFilter(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.
In the dashboard
Section titled “In the dashboard”- An org switcher in the sidebar lists your organizations and sets the active one.
/organizationis 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.
Seat limits
Section titled “Seat limits”How many members an org may hold is a property of its plan, not a global constant:
| Plan state | Members allowed |
|---|---|
| Free (no entitlement, trial lapsed) | 1 |
| Subscribed, or inside the signup trial | 100 |
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+resolveMemberLimitin@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.tsturns anorgIdinto the limit (entitlement + trial), and counts committed seats as members + pending invitations.- Enforcement is better-auth’s
membershipLimit(checked onaccept-invitationand server-sideaddMember) plus abeforeCreateInvitationhook, so an over-limit invite is refused when it is sent —403 ORGANIZATION_MEMBERSHIP_LIMIT_REACHED— instead of stranding the recipient at accept time. GET /api/entitlements/gatereportsmemberLimitfrom the same resolver, which is what/organizationuses 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.
What you get for free
Section titled “What you get for free”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.
Integration & testing notes
Section titled “Integration & testing notes”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
Originheader. better-auth guards/api/auth/organization/*(invite-member,accept-invitation,set-active, …) against CSRF, so a request with noOriginis rejected withMISSING_OR_NULL_ORIGIN. Send anOriginthat’s in the trusted set (CLIENT_URLS— e.g.https://business-studio.viite.ai). Browsers send this automatically;curl/fetchscripts 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 theyset-activethe 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_memberclaims 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.
